@tangle-network/agent-app 0.60.152 → 0.60.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -148,8 +148,10 @@ export interface AppAuthConfig {
148
148
  /** Reset/verification mail. Omit to disable password reset entirely. */
149
149
  email?: AppAuthEmailConfig;
150
150
  social?: AppAuthSocialConfig;
151
- /** Session cookie-cache TTL in seconds; `false` disables the cache.
152
- * Default 300. */
151
+ /** How long, in seconds, one isolate reuses a verified session across
152
+ * requests (capped at 30). Each reuse still re-reads the session row by its
153
+ * token, so a revoked session is refused on the next request everywhere.
154
+ * `false` reads the full session on every request. Default 300 (so 30). */
153
155
  sessionCookieCacheSeconds?: number | false;
154
156
  /** Tangle cross-site SSO (start/callback handlers). */
155
157
  sso?: AppAuthSsoConfig | AppAuthOidcSsoConfig | AppAuthIdentitySsoConfig;
@@ -22,7 +22,7 @@ var SESSION_MEMO_LIMIT = 500;
22
22
  var requestSessions = /* @__PURE__ */ new WeakMap();
23
23
  var isolateSessions = /* @__PURE__ */ new Map();
24
24
  var scopeGenerations = /* @__PURE__ */ new Map();
25
- function memoizedSession(scope, ttlMs, request, read) {
25
+ function memoizedSession(scope, ttlMs, request, read, isLive, log) {
26
26
  const known = requestSessions.get(request);
27
27
  if (known) return known;
28
28
  const cookie = request.headers.get("cookie") ?? "";
@@ -32,9 +32,26 @@ function memoizedSession(scope, ttlMs, request, read) {
32
32
  const startedAt = Date.now();
33
33
  const hit = isolateSessions.get(key);
34
34
  if (hit && hit.until > startedAt) {
35
- const session2 = Promise.resolve(hit.session);
36
- requestSessions.set(request, session2);
37
- return session2;
35
+ const remembered = hit.session;
36
+ const checked = (async () => {
37
+ const token = remembered.session.token;
38
+ if (!token) {
39
+ if (isolateSessions.get(key) === hit) isolateSessions.delete(key);
40
+ return read(request);
41
+ }
42
+ try {
43
+ if (await isLive(token)) return remembered;
44
+ } catch (error) {
45
+ log("[app-auth] session row check failed; serving the remembered session", {
46
+ error: error instanceof Error ? error.message : String(error)
47
+ });
48
+ return remembered;
49
+ }
50
+ if (isolateSessions.get(key) === hit) isolateSessions.delete(key);
51
+ return null;
52
+ })();
53
+ requestSessions.set(request, checked);
54
+ return checked;
38
55
  }
39
56
  const generation = scopeGenerations.get(scope) ?? 0;
40
57
  const session = read(request).then((value) => {
@@ -180,24 +197,27 @@ function createAppAuth(config) {
180
197
  ...config.emailAndPassword === false ? {} : { emailAndPassword: emailAndPasswordOptions(config) },
181
198
  ...config.email?.verifyOnSignUp ? { emailVerification: emailVerificationOptions(config) } : {},
182
199
  ...socialProviders ? { socialProviders } : {},
183
- ...config.sessionCookieCacheSeconds === false ? {} : {
184
- session: {
185
- cookieCache: {
186
- enabled: true,
187
- maxAge: config.sessionCookieCacheSeconds ?? DEFAULT_SESSION_COOKIE_CACHE_SECONDS
188
- }
189
- }
190
- },
200
+ // better-auth's own session cookie cache answers from a signed cookie for
201
+ // its whole maxAge, so a revoked session kept working for up to five
202
+ // minutes on any client holding the cookie. It stays off; the per-isolate
203
+ // reuse above re-checks the session row on every request instead.
191
204
  advanced: { cookiePrefix, ...config.advanced }
192
205
  };
193
206
  const auth = betterAuth(options);
207
+ const sessionRowIsLive = async (token) => {
208
+ const ctx = await auth.$context;
209
+ const row = await ctx.adapter.findOne({ model: "session", where: [{ field: "token", value: token }] });
210
+ const expiresAt = row ? new Date(row.expiresAt).getTime() : 0;
211
+ return expiresAt > Date.now();
212
+ };
194
213
  const readSession = async (request) => {
195
214
  const session = await auth.api.getSession({ headers: request.headers });
196
215
  return session ?? null;
197
216
  };
198
217
  const memoMs = config.sessionCookieCacheSeconds === false ? 0 : Math.min(SESSION_MEMO_MAX_MS, (config.sessionCookieCacheSeconds ?? DEFAULT_SESSION_COOKIE_CACHE_SECONDS) * 1e3);
199
218
  const memoScope = `${config.baseURL}\0${cookiePrefix}`;
200
- const getSession = (request) => memoMs > 0 ? memoizedSession(memoScope, memoMs, request, readSession) : readSession(request);
219
+ const logSession = (message, meta) => console.warn(message, meta);
220
+ const getSession = (request) => memoMs > 0 ? memoizedSession(memoScope, memoMs, request, readSession, sessionRowIsLive, logSession) : readSession(request);
201
221
  const handler = auth.handler;
202
222
  auth.handler = async (request) => {
203
223
  try {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/app-auth/index.ts"],"sourcesContent":["/**\n * better-auth config factory for agent products (#188 Phase 1). Every product\n * hand-rolls the same ~70–150 line setup — drizzle adapter over the standard\n * users/sessions/accounts/verifications tables, email+password with branded\n * reset/verification mail (`./email`), env-gated GitHub/Google social providers, session\n * cookie cache, and a per-app cookie prefix — and tax additionally\n * re-implemented better-auth's cookie signing for its Tangle SSO callback.\n * `createAppAuth` owns that mechanism once and returns the configured\n * better-auth instance plus the request guards products actually use.\n *\n * Domain stays a parameter: the drizzle db + schema, email client, provider\n * credentials, and SSO store/client all come from the product. No product\n * import, no engine re-implementation — the SSO path composes the existing\n * `platform/sso` cookie minter (`createBetterAuthSessionCookieMinter`), which\n * is byte-compatible with better-auth's own `makeSignature` contract.\n *\n * better-auth is an OPTIONAL peer: only this subpath imports it, so it is\n * deliberately NOT re-exported from the root barrel.\n */\n\nimport { betterAuth, type Auth, type BetterAuthOptions, type Session, type User } from 'better-auth'\nimport { drizzleAdapter } from 'better-auth/adapters/drizzle'\nimport { noticeEmail, type EmailAttachment, type EmailMessage, type EmailProduct } from '../email'\nimport { createAuthGuard, type AuthGuard } from '../platform/guards'\nimport {\n createBetterAuthSessionCookieMinter,\n createTangleSsoHandlers,\n type TangleSsoAccountStore,\n type TangleSsoAuthClient,\n type TangleSsoHandlers,\n type TangleIdentitySsoAccountStore,\n type TangleIdentitySsoAuthClient,\n type TangleOidcSsoAccountStore,\n type TangleOidcSsoAuthClient,\n} from '../platform/sso'\n\nconst DEFAULT_STATE_COOKIE = 'tangle_sso_state'\nconst DEFAULT_SESSION_COOKIE_CACHE_SECONDS = 5 * 60\n\n/**\n * A session read costs two serial database round trips (session, then user),\n * and a request often resolves it more than once. Resolved sessions are reused\n * within a request and, for at most SESSION_MEMO_MAX_MS, across requests in one\n * isolate, keyed by the exact credentials sent. That stays inside the staleness\n * the session cookie cache already accepts, and any non-GET auth call (sign-out,\n * revocation) clears the app's entries in this isolate.\n */\nconst SESSION_MEMO_MAX_MS = 30_000\nconst SESSION_MEMO_LIMIT = 500\nconst requestSessions = new WeakMap<Request, Promise<AppAuthSession | null>>()\nconst isolateSessions = new Map<string, { until: number; session: AppAuthSession }>()\n// Bumped by forgetSessions, so a read that began before a sign-out cannot remember its result.\nconst scopeGenerations = new Map<string, number>()\n\nfunction memoizedSession(scope: string, ttlMs: number, request: Request,\n read: (request: Request) => Promise<AppAuthSession | null>): Promise<AppAuthSession | null> {\n const known = requestSessions.get(request)\n if (known) return known\n const cookie = request.headers.get('cookie') ?? ''\n const authorization = request.headers.get('authorization') ?? ''\n if (!cookie && !authorization) return read(request)\n const key = `${scope}\\u0000${authorization}\\u0000${cookie}`\n const startedAt = Date.now()\n const hit = isolateSessions.get(key)\n if (hit && hit.until > startedAt) {\n const session = Promise.resolve(hit.session)\n requestSessions.set(request, session)\n return session\n }\n // Only a resolved session is shared across requests, never a read in flight.\n // Workers ties a read's I/O to the request that started it: when that request\n // is cancelled (a navigation), the read never settles, and every other request\n // awaiting it hung until its client gave up (Hospitality production, 20.4-21.0 s,\n // 2026-10-11).\n const generation = scopeGenerations.get(scope) ?? 0\n const session = read(request).then((value) => {\n const expiresAt = value ? new Date(value.session.expiresAt).getTime() : 0\n isolateSessions.delete(key)\n // Only a live session is reused; a miss or an expired session is read again next time.\n if (value && expiresAt > Date.now() && (scopeGenerations.get(scope) ?? 0) === generation) {\n isolateSessions.set(key, { until: Math.min(startedAt + ttlMs, expiresAt), session: value })\n if (isolateSessions.size > SESSION_MEMO_LIMIT) isolateSessions.delete(isolateSessions.keys().next().value!)\n }\n return value\n })\n requestSessions.set(request, session)\n return session\n}\n\nfunction forgetSessions(scope: string): void {\n scopeGenerations.set(scope, (scopeGenerations.get(scope) ?? 0) + 1)\n for (const key of isolateSessions.keys()) if (key.startsWith(`${scope}\\u0000`)) isolateSessions.delete(key)\n}\n\n/** Structural slice of a Resend-style client — no `resend` import. */\nexport interface AppAuthEmailClient {\n emails: {\n send(message: {\n from: string\n to: string\n subject: string\n html: string\n text?: string\n /** Inline images the HTML references by `cid:`, such as the Tangle mark. */\n attachments?: EmailAttachment[]\n }): Promise<unknown>\n }\n}\n\n/** Define email configuration for app authentication including client, sender, verification, and warning options */\nexport interface AppAuthEmailConfig {\n /** A Resend-style client, or a lazy getter returning null when the API key\n * is absent (the products' dev default — mail is skipped with a warning,\n * sign-up itself must not crash). */\n resend: AppAuthEmailClient | (() => AppAuthEmailClient | null)\n /** RFC 5322 From; build it with `emailSender` from `./email`, e.g. `Legal Agent · Tangle <noreply@tangle.tools>`. */\n from: string\n /** Send a verification email on sign-up and auto-sign-in after verifying\n * (the tax/gtm behavior). Default false (the legal behavior). */\n verifyOnSignUp?: boolean\n /** Receives the \"email client unavailable\" warning. Default console.warn. */\n warn?: (message: string) => void\n}\n\n/** Env-shaped: pass the env vars straight through; the provider is registered\n * only when BOTH values are non-empty, so unset env disables it. */\nexport interface AppAuthSocialProviderConfig {\n clientId?: string\n clientSecret?: string\n}\n\n/** Define social authentication configuration options for GitHub and Google providers */\nexport interface AppAuthSocialConfig {\n github?: AppAuthSocialProviderConfig\n google?: AppAuthSocialProviderConfig\n}\n\n/** Cross-site Tangle SSO wiring. The factory supplies the better-auth side —\n * `setSessionCookie` via `createBetterAuthSessionCookieMinter(auth)` (signed\n * `__Secure-`/prefixed cookie that `auth.api.getSession` accepts) — so the\n * product no longer touches `better-auth/crypto` itself. */\nexport interface AppAuthSsoConfig {\n protocol?: 'legacy'\n /** Platform wire client (authorizeUrl + exchange). */\n client: TangleSsoAuthClient\n /** Product persistence: user upsert, session row, platform link. */\n store: TangleSsoAccountStore\n /** Absolute callback URL registered with the platform. */\n callbackUrl: string\n /** Default 'tangle_sso_state'. */\n stateCookieName?: string\n /** HMAC secret for the CSRF state cookie. Default: the auth `secret`. */\n stateSecret?: string\n /** Default: `baseURL` is https. Must match better-auth's own\n * secure-cookie decision or the cookie name lookup diverges. */\n secureCookies?: boolean\n sessionTtlSeconds?: number\n stateTtlSeconds?: number\n /** Post-login redirect fallback. Default '/app'. */\n defaultRedirectPath?: string\n /** Default: the top-level `loginPath`. */\n loginPath?: string\n /** Failure log hook (e.g. console.error). Default no-op. */\n log?: (message: string, error?: unknown) => void\n}\n\n/** Registered OIDC client configuration. The caller supplies Runtime PKCE; login does not mint an API key. */\nexport interface AppAuthOidcSsoConfig extends Omit<AppAuthSsoConfig, 'protocol' | 'client' | 'store'> {\n protocol: 'oidc'\n client: TangleOidcSsoAuthClient\n store: TangleOidcSsoAccountStore\n /** Pass the Runtime's `createPkcePair` helper from the OIDC-only caller. */\n createPkcePair: () => Promise<{ verifier: string; challenge: string }>\n}\n\n/** First-party identity login; no Platform API key is issued or stored. */\nexport interface AppAuthIdentitySsoConfig extends Omit<AppAuthSsoConfig, 'protocol' | 'client' | 'store'> {\n protocol: 'identity'\n client: TangleIdentitySsoAuthClient\n store: TangleIdentitySsoAccountStore\n}\n\n/** Define the structure for application authentication data including users, sessions, accounts, and verifications */\nexport interface AppAuthSchema {\n users: unknown\n sessions: unknown\n accounts: unknown\n verifications: unknown\n}\n\n/** Define configuration settings for app authentication including app name, base URL, secrets, and trusted origins */\nexport interface AppAuthConfig {\n /** Product name — used in email subjects and as the cookie-prefix default. */\n appName: string\n /** Absolute origin better-auth serves from (`BETTER_AUTH_URL`). */\n baseURL: string\n /** better-auth HMAC secret. Optional only because better-auth falls back to\n * the BETTER_AUTH_SECRET env var; `sso` needs it explicitly (or\n * `sso.stateSecret`). */\n secret?: string\n trustedOrigins?: string[]\n /**\n * Session-cookie prefix. Default: slugified `appName`. A per-app prefix is\n * load-bearing, not cosmetic: the platform (id.tangle.tools) mints its own\n * better-auth cookie `Domain=.tangle.tools`-wide under the DEFAULT name, and\n * the platform's (older) cookie wins the Cookie-header order — an app on the\n * default prefix reads the platform's token, fails its own signature check,\n * and every fresh login lands back on /login.\n */\n cookiePrefix?: string\n /** Drizzle database instance; wired through better-auth's drizzle adapter\n * together with `schema`. */\n db?: unknown\n /** The product's users/sessions/accounts/verifications tables (mapped to\n * better-auth's user/session/account/verification models). */\n schema?: AppAuthSchema\n /** Drizzle dialect. Default 'sqlite' (D1). */\n provider?: 'sqlite' | 'pg' | 'mysql'\n /** Escape hatch: a pre-built better-auth database adapter (e.g.\n * `memoryAdapter` in tests). Wins over `db`/`schema`. */\n database?: BetterAuthOptions['database']\n /** Email+password sign-in. Default true (all current products enable it). */\n emailAndPassword?: boolean\n /** Reset/verification mail. Omit to disable password reset entirely. */\n email?: AppAuthEmailConfig\n social?: AppAuthSocialConfig\n /** Session cookie-cache TTL in seconds; `false` disables the cache.\n * Default 300. */\n sessionCookieCacheSeconds?: number | false\n /** Tangle cross-site SSO (start/callback handlers). */\n sso?: AppAuthSsoConfig | AppAuthOidcSsoConfig | AppAuthIdentitySsoConfig\n /** Where guards redirect unauthenticated page requests. Default '/login'. */\n loginPath?: string\n /** Merged over the factory's `advanced` block (cookiePrefix stays unless\n * overridden here). */\n advanced?: BetterAuthOptions['advanced']\n}\n\n/** The configured better-auth instance, typed at better-auth's base surface\n * (`handler`, `api`, `$context`, `$Infer`). */\nexport type AppAuthInstance = Auth\n\n/** What `getSession`/guards resolve: better-auth's base session + user rows. */\nexport interface AppAuthSession {\n session: Session\n user: User\n}\n\n/** Define authentication guard with session retrieval and optional SSO handlers for app requests */\nexport interface AppAuth extends AuthGuard<AppAuthSession> {\n auth: AppAuthInstance\n /** `auth.api.getSession` over a `Request` — the seam the guards consume. */\n getSession(request: Request): Promise<AppAuthSession | null>\n /** Tangle SSO start/callback handlers; null unless `sso` was configured. */\n sso: TangleSsoHandlers | null\n}\n\n/** Lowercased, non-alphanumerics collapsed to '-': `'Legal Agent'` → `'legal-agent'`. */\nfunction slugify(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n\nfunction emailProduct(config: AppAuthConfig): EmailProduct {\n return { name: config.appName, url: config.baseURL }\n}\n\nfunction resolveEmailClient(email: AppAuthEmailConfig): AppAuthEmailClient | null {\n return typeof email.resend === 'function' ? email.resend() : email.resend\n}\n\ntype DrizzleAuthAdapter = ReturnType<ReturnType<typeof drizzleAdapter>>\n\n/** Better Auth reads adapter identity during startup, before Workers inject D1.\n * Build the Drizzle adapter on its first database operation. */\nfunction deferDrizzleAdapter(\n db: Record<string, unknown>,\n config: Parameters<typeof drizzleAdapter>[1],\n): ReturnType<typeof drizzleAdapter> {\n return (options) => {\n let adapter: DrizzleAuthAdapter | undefined\n const resolve = () => (adapter ??= drizzleAdapter(db, config)(options))\n\n return {\n id: 'drizzle',\n create: (data) => resolve().create(data),\n findOne: (data) => resolve().findOne(data),\n findMany: (data) => resolve().findMany(data),\n count: (data) => resolve().count(data),\n update: (data) => resolve().update(data),\n updateMany: (data) => resolve().updateMany(data),\n delete: (data) => resolve().delete(data),\n deleteMany: (data) => resolve().deleteMany(data),\n consumeOne: (data) => resolve().consumeOne(data),\n incrementOne: (data) => resolve().incrementOne(data),\n transaction: (callback) => resolve().transaction(callback),\n }\n }\n}\n\n/** Resend reports failures in the resolved value, not by rejecting — surface\n * them loud so better-auth's flow (and the caller's logs) see the failure. */\nasync function sendEmail(client: AppAuthEmailClient, from: string, to: string, message: EmailMessage): Promise<void> {\n const { subject, html, text, attachments } = message\n const result = (await client.emails.send({ from, to, subject, html, text, attachments })) as\n | { error?: { message?: string } | null }\n | null\n | undefined\n if (result && typeof result === 'object' && result.error) {\n throw new Error(`[app-auth] email send failed: ${result.error.message ?? 'unknown error'}`)\n }\n}\n\nfunction resolveDatabase(config: AppAuthConfig): NonNullable<BetterAuthOptions['database']> {\n if (config.database) return config.database\n if (config.db && config.schema) {\n return deferDrizzleAdapter(config.db as Record<string, unknown>, {\n provider: config.provider ?? 'sqlite',\n schema: {\n user: config.schema.users,\n session: config.schema.sessions,\n account: config.schema.accounts,\n verification: config.schema.verifications,\n } as Record<string, unknown>,\n })\n }\n throw new Error(\n 'createAppAuth requires a database: pass `db` + `schema` (drizzle) or `database` (a better-auth adapter)',\n )\n}\n\nfunction resolveSocialProviders(social: AppAuthSocialConfig | undefined): BetterAuthOptions['socialProviders'] {\n const providers: NonNullable<BetterAuthOptions['socialProviders']> = {}\n if (social?.github?.clientId && social.github.clientSecret) {\n providers.github = { clientId: social.github.clientId, clientSecret: social.github.clientSecret }\n }\n if (social?.google?.clientId && social.google.clientSecret) {\n providers.google = { clientId: social.google.clientId, clientSecret: social.google.clientSecret }\n }\n return Object.keys(providers).length > 0 ? providers : undefined\n}\n\nfunction emailAndPasswordOptions(config: AppAuthConfig): BetterAuthOptions['emailAndPassword'] {\n const email = config.email\n if (!email) return { enabled: true }\n const warn = email.warn ?? ((message: string) => console.warn(message))\n return {\n enabled: true,\n sendResetPassword: async ({ user, url }) => {\n const client = resolveEmailClient(email)\n if (!client) {\n warn('[app-auth] email client unavailable — password reset email not sent')\n return\n }\n await sendEmail(client, email.from, user.email, noticeEmail(emailProduct(config), {\n subject: 'Reset your password',\n title: 'Reset your password',\n body: `Someone asked to reset the password for ${user.email}. If it was not you, ignore this email.`,\n action: { label: 'Reset password', url },\n showLink: true,\n note: 'This link expires in 1 hour.',\n footer: { reason: 'You received this because a password reset was requested for this address.' },\n }))\n },\n }\n}\n\nfunction emailVerificationOptions(config: AppAuthConfig): BetterAuthOptions['emailVerification'] {\n const email = config.email\n if (!email?.verifyOnSignUp) return undefined\n const warn = email.warn ?? ((message: string) => console.warn(message))\n return {\n sendOnSignUp: true,\n autoSignInAfterVerification: true,\n sendVerificationEmail: async ({ user, url }) => {\n const client = resolveEmailClient(email)\n if (!client) {\n warn('[app-auth] email client unavailable — verification email not sent')\n return\n }\n await sendEmail(client, email.from, user.email, noticeEmail(emailProduct(config), {\n subject: 'Verify your email',\n title: 'Verify your email',\n body: `Confirm ${user.email} to finish signing in to ${config.appName}.`,\n action: { label: 'Verify email', url },\n showLink: true,\n note: 'This link expires in 1 hour.',\n footer: { reason: `You received this because this address was used to sign in to ${config.appName}.` },\n }))\n },\n }\n}\n\n/**\n * Build the product's better-auth instance plus the request-boundary helpers:\n * `getSession` (Request → session|null), the `createAuthGuard` quartet\n * (`requireUser` 302, `requireApiUser` JSON 401, `requireSession`,\n * `getOptionalSession`), and — when `sso` is configured — the Tangle SSO\n * start/callback handlers with the session cookie minted through better-auth's\n * own name/attributes/signing (no `better-auth/crypto` in product code).\n */\nexport function createAppAuth(config: AppAuthConfig): AppAuth {\n if (!config.appName) throw new Error('createAppAuth: appName is required')\n if (!config.baseURL) throw new Error('createAppAuth: baseURL is required')\n\n const cookiePrefix = config.cookiePrefix ?? slugify(config.appName)\n if (!cookiePrefix) throw new Error('createAppAuth: cookiePrefix (or a slugifiable appName) is required')\n\n const socialProviders = resolveSocialProviders(config.social)\n const options: BetterAuthOptions = {\n appName: config.appName,\n baseURL: config.baseURL,\n ...(config.secret ? { secret: config.secret } : {}),\n ...(config.trustedOrigins ? { trustedOrigins: config.trustedOrigins } : {}),\n database: resolveDatabase(config),\n ...(config.emailAndPassword === false ? {} : { emailAndPassword: emailAndPasswordOptions(config) }),\n ...(config.email?.verifyOnSignUp ? { emailVerification: emailVerificationOptions(config) } : {}),\n ...(socialProviders ? { socialProviders } : {}),\n ...(config.sessionCookieCacheSeconds === false\n ? {}\n : {\n session: {\n cookieCache: {\n enabled: true,\n maxAge: config.sessionCookieCacheSeconds ?? DEFAULT_SESSION_COOKIE_CACHE_SECONDS,\n },\n },\n }),\n advanced: { cookiePrefix, ...config.advanced },\n }\n\n const auth: AppAuthInstance = betterAuth(options)\n\n const readSession = async (request: Request): Promise<AppAuthSession | null> => {\n const session = await auth.api.getSession({ headers: request.headers })\n return (session as AppAuthSession | null) ?? null\n }\n const memoMs = config.sessionCookieCacheSeconds === false ? 0\n : Math.min(SESSION_MEMO_MAX_MS, (config.sessionCookieCacheSeconds ?? DEFAULT_SESSION_COOKIE_CACHE_SECONDS) * 1000)\n const memoScope = `${config.baseURL}\\u0000${cookiePrefix}`\n const getSession = (request: Request): Promise<AppAuthSession | null> =>\n memoMs > 0 ? memoizedSession(memoScope, memoMs, request, readSession) : readSession(request)\n // Sign-out and session revocation drop this isolate's remembered sessions for the app.\n const handler = auth.handler\n auth.handler = async (request: Request) => {\n // In finally: a sign-out that fails after deleting the session must not leave it remembered.\n try { return await handler(request) }\n finally { if (request.method !== 'GET') forgetSessions(memoScope) }\n }\n\n const loginPath = config.loginPath ?? '/login'\n const guard = createAuthGuard<AppAuthSession>({ getSession, loginPath })\n\n let sso: TangleSsoHandlers | null = null\n if (config.sso) {\n const stateSecret = config.sso.stateSecret ?? config.secret\n if (!stateSecret) {\n throw new Error(\n 'createAppAuth: sso requires `secret` (or sso.stateSecret) — the signed-state CSRF cookie needs an HMAC secret',\n )\n }\n const transport = config.sso.protocol === 'oidc'\n ? { protocol: 'oidc' as const, auth: config.sso.client, store: config.sso.store, createPkcePair: config.sso.createPkcePair }\n : config.sso.protocol === 'identity'\n ? { protocol: 'identity' as const, auth: config.sso.client, store: config.sso.store }\n : { protocol: 'legacy' as const, auth: config.sso.client, store: config.sso.store }\n sso = createTangleSsoHandlers({\n ...transport,\n stateSecret,\n callbackUrl: config.sso.callbackUrl,\n stateCookieName: config.sso.stateCookieName ?? DEFAULT_STATE_COOKIE,\n setSessionCookie: createBetterAuthSessionCookieMinter(auth),\n secureCookies: config.sso.secureCookies ?? config.baseURL.startsWith('https:'),\n ...(config.sso.sessionTtlSeconds !== undefined ? { sessionTtlSeconds: config.sso.sessionTtlSeconds } : {}),\n ...(config.sso.stateTtlSeconds !== undefined ? { stateTtlSeconds: config.sso.stateTtlSeconds } : {}),\n ...(config.sso.defaultRedirectPath ? { defaultRedirectPath: config.sso.defaultRedirectPath } : {}),\n loginPath: config.sso.loginPath ?? loginPath,\n ...(config.sso.log ? { log: config.sso.log } : {}),\n })\n }\n\n return { auth, getSession, ...guard, sso }\n}\n"],"mappings":"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","names":["session"]}
1
+ {"version":3,"sources":["../../src/app-auth/index.ts"],"sourcesContent":["/**\n * better-auth config factory for agent products (#188 Phase 1). Every product\n * hand-rolls the same ~70–150 line setup — drizzle adapter over the standard\n * users/sessions/accounts/verifications tables, email+password with branded\n * reset/verification mail (`./email`), env-gated GitHub/Google social providers, session\n * cookie cache, and a per-app cookie prefix — and tax additionally\n * re-implemented better-auth's cookie signing for its Tangle SSO callback.\n * `createAppAuth` owns that mechanism once and returns the configured\n * better-auth instance plus the request guards products actually use.\n *\n * Domain stays a parameter: the drizzle db + schema, email client, provider\n * credentials, and SSO store/client all come from the product. No product\n * import, no engine re-implementation — the SSO path composes the existing\n * `platform/sso` cookie minter (`createBetterAuthSessionCookieMinter`), which\n * is byte-compatible with better-auth's own `makeSignature` contract.\n *\n * better-auth is an OPTIONAL peer: only this subpath imports it, so it is\n * deliberately NOT re-exported from the root barrel.\n */\n\nimport { betterAuth, type Auth, type BetterAuthOptions, type Session, type User } from 'better-auth'\nimport { drizzleAdapter } from 'better-auth/adapters/drizzle'\nimport { noticeEmail, type EmailAttachment, type EmailMessage, type EmailProduct } from '../email'\nimport { createAuthGuard, type AuthGuard } from '../platform/guards'\nimport {\n createBetterAuthSessionCookieMinter,\n createTangleSsoHandlers,\n type TangleSsoAccountStore,\n type TangleSsoAuthClient,\n type TangleSsoHandlers,\n type TangleIdentitySsoAccountStore,\n type TangleIdentitySsoAuthClient,\n type TangleOidcSsoAccountStore,\n type TangleOidcSsoAuthClient,\n} from '../platform/sso'\n\nconst DEFAULT_STATE_COOKIE = 'tangle_sso_state'\nconst DEFAULT_SESSION_COOKIE_CACHE_SECONDS = 5 * 60\n\n/**\n * A full session read costs two serial database round trips (session, then\n * user), and a request often resolves it more than once. A resolved session is\n * reused within a request, and for at most SESSION_MEMO_MAX_MS across requests\n * in one isolate, keyed by the exact credentials sent. A reuse across requests\n * is never trusted on its own: every one re-reads the session row by its token\n * (one round trip instead of two), so a session revoked anywhere (sign-out,\n * revocation, a password change deleting the other sessions) is refused on the\n * next request in every isolate. Only when that row read fails is the\n * remembered session served, so a database error does not sign everyone out.\n * Any non-GET auth call in this isolate also clears the app's entries.\n */\nconst SESSION_MEMO_MAX_MS = 30_000\nconst SESSION_MEMO_LIMIT = 500\nconst requestSessions = new WeakMap<Request, Promise<AppAuthSession | null>>()\nconst isolateSessions = new Map<string, { until: number; session: AppAuthSession }>()\n// Bumped by forgetSessions, so a read that began before a sign-out cannot remember its result.\nconst scopeGenerations = new Map<string, number>()\n\n/** Whether a session row still exists and has not expired. Throws when the store cannot answer. */\ntype SessionRowCheck = (token: string) => Promise<boolean>\n\nfunction memoizedSession(scope: string, ttlMs: number, request: Request,\n read: (request: Request) => Promise<AppAuthSession | null>,\n isLive: SessionRowCheck,\n log: (message: string, meta: Record<string, unknown>) => void): Promise<AppAuthSession | null> {\n const known = requestSessions.get(request)\n if (known) return known\n const cookie = request.headers.get('cookie') ?? ''\n const authorization = request.headers.get('authorization') ?? ''\n if (!cookie && !authorization) return read(request)\n const key = `${scope}\\u0000${authorization}\\u0000${cookie}`\n const startedAt = Date.now()\n const hit = isolateSessions.get(key)\n if (hit && hit.until > startedAt) {\n const remembered = hit.session\n const checked = (async () => {\n const token = remembered.session.token\n if (!token) {\n // Nothing to check it against: never reuse it.\n if (isolateSessions.get(key) === hit) isolateSessions.delete(key)\n return read(request)\n }\n try {\n if (await isLive(token)) return remembered\n } catch (error) {\n // The store cannot answer: keep the session it verified moments ago.\n log('[app-auth] session row check failed; serving the remembered session', {\n error: error instanceof Error ? error.message : String(error),\n })\n return remembered\n }\n // Revoked or expired since it was remembered.\n if (isolateSessions.get(key) === hit) isolateSessions.delete(key)\n return null\n })()\n requestSessions.set(request, checked)\n return checked\n }\n // Only a resolved session is shared across requests, never a read in flight.\n // Workers ties a read's I/O to the request that started it: when that request\n // is cancelled (a navigation), the read never settles, and every other request\n // awaiting it hung until its client gave up (Hospitality production, 20.4-21.0 s,\n // 2026-10-11).\n const generation = scopeGenerations.get(scope) ?? 0\n const session = read(request).then((value) => {\n const expiresAt = value ? new Date(value.session.expiresAt).getTime() : 0\n isolateSessions.delete(key)\n // Only a live session is reused; a miss or an expired session is read again next time.\n if (value && expiresAt > Date.now() && (scopeGenerations.get(scope) ?? 0) === generation) {\n isolateSessions.set(key, { until: Math.min(startedAt + ttlMs, expiresAt), session: value })\n if (isolateSessions.size > SESSION_MEMO_LIMIT) isolateSessions.delete(isolateSessions.keys().next().value!)\n }\n return value\n })\n requestSessions.set(request, session)\n return session\n}\n\nfunction forgetSessions(scope: string): void {\n scopeGenerations.set(scope, (scopeGenerations.get(scope) ?? 0) + 1)\n for (const key of isolateSessions.keys()) if (key.startsWith(`${scope}\\u0000`)) isolateSessions.delete(key)\n}\n\n/** Structural slice of a Resend-style client — no `resend` import. */\nexport interface AppAuthEmailClient {\n emails: {\n send(message: {\n from: string\n to: string\n subject: string\n html: string\n text?: string\n /** Inline images the HTML references by `cid:`, such as the Tangle mark. */\n attachments?: EmailAttachment[]\n }): Promise<unknown>\n }\n}\n\n/** Define email configuration for app authentication including client, sender, verification, and warning options */\nexport interface AppAuthEmailConfig {\n /** A Resend-style client, or a lazy getter returning null when the API key\n * is absent (the products' dev default — mail is skipped with a warning,\n * sign-up itself must not crash). */\n resend: AppAuthEmailClient | (() => AppAuthEmailClient | null)\n /** RFC 5322 From; build it with `emailSender` from `./email`, e.g. `Legal Agent · Tangle <noreply@tangle.tools>`. */\n from: string\n /** Send a verification email on sign-up and auto-sign-in after verifying\n * (the tax/gtm behavior). Default false (the legal behavior). */\n verifyOnSignUp?: boolean\n /** Receives the \"email client unavailable\" warning. Default console.warn. */\n warn?: (message: string) => void\n}\n\n/** Env-shaped: pass the env vars straight through; the provider is registered\n * only when BOTH values are non-empty, so unset env disables it. */\nexport interface AppAuthSocialProviderConfig {\n clientId?: string\n clientSecret?: string\n}\n\n/** Define social authentication configuration options for GitHub and Google providers */\nexport interface AppAuthSocialConfig {\n github?: AppAuthSocialProviderConfig\n google?: AppAuthSocialProviderConfig\n}\n\n/** Cross-site Tangle SSO wiring. The factory supplies the better-auth side —\n * `setSessionCookie` via `createBetterAuthSessionCookieMinter(auth)` (signed\n * `__Secure-`/prefixed cookie that `auth.api.getSession` accepts) — so the\n * product no longer touches `better-auth/crypto` itself. */\nexport interface AppAuthSsoConfig {\n protocol?: 'legacy'\n /** Platform wire client (authorizeUrl + exchange). */\n client: TangleSsoAuthClient\n /** Product persistence: user upsert, session row, platform link. */\n store: TangleSsoAccountStore\n /** Absolute callback URL registered with the platform. */\n callbackUrl: string\n /** Default 'tangle_sso_state'. */\n stateCookieName?: string\n /** HMAC secret for the CSRF state cookie. Default: the auth `secret`. */\n stateSecret?: string\n /** Default: `baseURL` is https. Must match better-auth's own\n * secure-cookie decision or the cookie name lookup diverges. */\n secureCookies?: boolean\n sessionTtlSeconds?: number\n stateTtlSeconds?: number\n /** Post-login redirect fallback. Default '/app'. */\n defaultRedirectPath?: string\n /** Default: the top-level `loginPath`. */\n loginPath?: string\n /** Failure log hook (e.g. console.error). Default no-op. */\n log?: (message: string, error?: unknown) => void\n}\n\n/** Registered OIDC client configuration. The caller supplies Runtime PKCE; login does not mint an API key. */\nexport interface AppAuthOidcSsoConfig extends Omit<AppAuthSsoConfig, 'protocol' | 'client' | 'store'> {\n protocol: 'oidc'\n client: TangleOidcSsoAuthClient\n store: TangleOidcSsoAccountStore\n /** Pass the Runtime's `createPkcePair` helper from the OIDC-only caller. */\n createPkcePair: () => Promise<{ verifier: string; challenge: string }>\n}\n\n/** First-party identity login; no Platform API key is issued or stored. */\nexport interface AppAuthIdentitySsoConfig extends Omit<AppAuthSsoConfig, 'protocol' | 'client' | 'store'> {\n protocol: 'identity'\n client: TangleIdentitySsoAuthClient\n store: TangleIdentitySsoAccountStore\n}\n\n/** Define the structure for application authentication data including users, sessions, accounts, and verifications */\nexport interface AppAuthSchema {\n users: unknown\n sessions: unknown\n accounts: unknown\n verifications: unknown\n}\n\n/** Define configuration settings for app authentication including app name, base URL, secrets, and trusted origins */\nexport interface AppAuthConfig {\n /** Product name — used in email subjects and as the cookie-prefix default. */\n appName: string\n /** Absolute origin better-auth serves from (`BETTER_AUTH_URL`). */\n baseURL: string\n /** better-auth HMAC secret. Optional only because better-auth falls back to\n * the BETTER_AUTH_SECRET env var; `sso` needs it explicitly (or\n * `sso.stateSecret`). */\n secret?: string\n trustedOrigins?: string[]\n /**\n * Session-cookie prefix. Default: slugified `appName`. A per-app prefix is\n * load-bearing, not cosmetic: the platform (id.tangle.tools) mints its own\n * better-auth cookie `Domain=.tangle.tools`-wide under the DEFAULT name, and\n * the platform's (older) cookie wins the Cookie-header order — an app on the\n * default prefix reads the platform's token, fails its own signature check,\n * and every fresh login lands back on /login.\n */\n cookiePrefix?: string\n /** Drizzle database instance; wired through better-auth's drizzle adapter\n * together with `schema`. */\n db?: unknown\n /** The product's users/sessions/accounts/verifications tables (mapped to\n * better-auth's user/session/account/verification models). */\n schema?: AppAuthSchema\n /** Drizzle dialect. Default 'sqlite' (D1). */\n provider?: 'sqlite' | 'pg' | 'mysql'\n /** Escape hatch: a pre-built better-auth database adapter (e.g.\n * `memoryAdapter` in tests). Wins over `db`/`schema`. */\n database?: BetterAuthOptions['database']\n /** Email+password sign-in. Default true (all current products enable it). */\n emailAndPassword?: boolean\n /** Reset/verification mail. Omit to disable password reset entirely. */\n email?: AppAuthEmailConfig\n social?: AppAuthSocialConfig\n /** How long, in seconds, one isolate reuses a verified session across\n * requests (capped at 30). Each reuse still re-reads the session row by its\n * token, so a revoked session is refused on the next request everywhere.\n * `false` reads the full session on every request. Default 300 (so 30). */\n sessionCookieCacheSeconds?: number | false\n /** Tangle cross-site SSO (start/callback handlers). */\n sso?: AppAuthSsoConfig | AppAuthOidcSsoConfig | AppAuthIdentitySsoConfig\n /** Where guards redirect unauthenticated page requests. Default '/login'. */\n loginPath?: string\n /** Merged over the factory's `advanced` block (cookiePrefix stays unless\n * overridden here). */\n advanced?: BetterAuthOptions['advanced']\n}\n\n/** The configured better-auth instance, typed at better-auth's base surface\n * (`handler`, `api`, `$context`, `$Infer`). */\nexport type AppAuthInstance = Auth\n\n/** What `getSession`/guards resolve: better-auth's base session + user rows. */\nexport interface AppAuthSession {\n session: Session\n user: User\n}\n\n/** Define authentication guard with session retrieval and optional SSO handlers for app requests */\nexport interface AppAuth extends AuthGuard<AppAuthSession> {\n auth: AppAuthInstance\n /** `auth.api.getSession` over a `Request` — the seam the guards consume. */\n getSession(request: Request): Promise<AppAuthSession | null>\n /** Tangle SSO start/callback handlers; null unless `sso` was configured. */\n sso: TangleSsoHandlers | null\n}\n\n/** Lowercased, non-alphanumerics collapsed to '-': `'Legal Agent'` → `'legal-agent'`. */\nfunction slugify(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n\nfunction emailProduct(config: AppAuthConfig): EmailProduct {\n return { name: config.appName, url: config.baseURL }\n}\n\nfunction resolveEmailClient(email: AppAuthEmailConfig): AppAuthEmailClient | null {\n return typeof email.resend === 'function' ? email.resend() : email.resend\n}\n\ntype DrizzleAuthAdapter = ReturnType<ReturnType<typeof drizzleAdapter>>\n\n/** Better Auth reads adapter identity during startup, before Workers inject D1.\n * Build the Drizzle adapter on its first database operation. */\nfunction deferDrizzleAdapter(\n db: Record<string, unknown>,\n config: Parameters<typeof drizzleAdapter>[1],\n): ReturnType<typeof drizzleAdapter> {\n return (options) => {\n let adapter: DrizzleAuthAdapter | undefined\n const resolve = () => (adapter ??= drizzleAdapter(db, config)(options))\n\n return {\n id: 'drizzle',\n create: (data) => resolve().create(data),\n findOne: (data) => resolve().findOne(data),\n findMany: (data) => resolve().findMany(data),\n count: (data) => resolve().count(data),\n update: (data) => resolve().update(data),\n updateMany: (data) => resolve().updateMany(data),\n delete: (data) => resolve().delete(data),\n deleteMany: (data) => resolve().deleteMany(data),\n consumeOne: (data) => resolve().consumeOne(data),\n incrementOne: (data) => resolve().incrementOne(data),\n transaction: (callback) => resolve().transaction(callback),\n }\n }\n}\n\n/** Resend reports failures in the resolved value, not by rejecting — surface\n * them loud so better-auth's flow (and the caller's logs) see the failure. */\nasync function sendEmail(client: AppAuthEmailClient, from: string, to: string, message: EmailMessage): Promise<void> {\n const { subject, html, text, attachments } = message\n const result = (await client.emails.send({ from, to, subject, html, text, attachments })) as\n | { error?: { message?: string } | null }\n | null\n | undefined\n if (result && typeof result === 'object' && result.error) {\n throw new Error(`[app-auth] email send failed: ${result.error.message ?? 'unknown error'}`)\n }\n}\n\nfunction resolveDatabase(config: AppAuthConfig): NonNullable<BetterAuthOptions['database']> {\n if (config.database) return config.database\n if (config.db && config.schema) {\n return deferDrizzleAdapter(config.db as Record<string, unknown>, {\n provider: config.provider ?? 'sqlite',\n schema: {\n user: config.schema.users,\n session: config.schema.sessions,\n account: config.schema.accounts,\n verification: config.schema.verifications,\n } as Record<string, unknown>,\n })\n }\n throw new Error(\n 'createAppAuth requires a database: pass `db` + `schema` (drizzle) or `database` (a better-auth adapter)',\n )\n}\n\nfunction resolveSocialProviders(social: AppAuthSocialConfig | undefined): BetterAuthOptions['socialProviders'] {\n const providers: NonNullable<BetterAuthOptions['socialProviders']> = {}\n if (social?.github?.clientId && social.github.clientSecret) {\n providers.github = { clientId: social.github.clientId, clientSecret: social.github.clientSecret }\n }\n if (social?.google?.clientId && social.google.clientSecret) {\n providers.google = { clientId: social.google.clientId, clientSecret: social.google.clientSecret }\n }\n return Object.keys(providers).length > 0 ? providers : undefined\n}\n\nfunction emailAndPasswordOptions(config: AppAuthConfig): BetterAuthOptions['emailAndPassword'] {\n const email = config.email\n if (!email) return { enabled: true }\n const warn = email.warn ?? ((message: string) => console.warn(message))\n return {\n enabled: true,\n sendResetPassword: async ({ user, url }) => {\n const client = resolveEmailClient(email)\n if (!client) {\n warn('[app-auth] email client unavailable — password reset email not sent')\n return\n }\n await sendEmail(client, email.from, user.email, noticeEmail(emailProduct(config), {\n subject: 'Reset your password',\n title: 'Reset your password',\n body: `Someone asked to reset the password for ${user.email}. If it was not you, ignore this email.`,\n action: { label: 'Reset password', url },\n showLink: true,\n note: 'This link expires in 1 hour.',\n footer: { reason: 'You received this because a password reset was requested for this address.' },\n }))\n },\n }\n}\n\nfunction emailVerificationOptions(config: AppAuthConfig): BetterAuthOptions['emailVerification'] {\n const email = config.email\n if (!email?.verifyOnSignUp) return undefined\n const warn = email.warn ?? ((message: string) => console.warn(message))\n return {\n sendOnSignUp: true,\n autoSignInAfterVerification: true,\n sendVerificationEmail: async ({ user, url }) => {\n const client = resolveEmailClient(email)\n if (!client) {\n warn('[app-auth] email client unavailable — verification email not sent')\n return\n }\n await sendEmail(client, email.from, user.email, noticeEmail(emailProduct(config), {\n subject: 'Verify your email',\n title: 'Verify your email',\n body: `Confirm ${user.email} to finish signing in to ${config.appName}.`,\n action: { label: 'Verify email', url },\n showLink: true,\n note: 'This link expires in 1 hour.',\n footer: { reason: `You received this because this address was used to sign in to ${config.appName}.` },\n }))\n },\n }\n}\n\n/**\n * Build the product's better-auth instance plus the request-boundary helpers:\n * `getSession` (Request → session|null), the `createAuthGuard` quartet\n * (`requireUser` 302, `requireApiUser` JSON 401, `requireSession`,\n * `getOptionalSession`), and — when `sso` is configured — the Tangle SSO\n * start/callback handlers with the session cookie minted through better-auth's\n * own name/attributes/signing (no `better-auth/crypto` in product code).\n */\nexport function createAppAuth(config: AppAuthConfig): AppAuth {\n if (!config.appName) throw new Error('createAppAuth: appName is required')\n if (!config.baseURL) throw new Error('createAppAuth: baseURL is required')\n\n const cookiePrefix = config.cookiePrefix ?? slugify(config.appName)\n if (!cookiePrefix) throw new Error('createAppAuth: cookiePrefix (or a slugifiable appName) is required')\n\n const socialProviders = resolveSocialProviders(config.social)\n const options: BetterAuthOptions = {\n appName: config.appName,\n baseURL: config.baseURL,\n ...(config.secret ? { secret: config.secret } : {}),\n ...(config.trustedOrigins ? { trustedOrigins: config.trustedOrigins } : {}),\n database: resolveDatabase(config),\n ...(config.emailAndPassword === false ? {} : { emailAndPassword: emailAndPasswordOptions(config) }),\n ...(config.email?.verifyOnSignUp ? { emailVerification: emailVerificationOptions(config) } : {}),\n ...(socialProviders ? { socialProviders } : {}),\n // better-auth's own session cookie cache answers from a signed cookie for\n // its whole maxAge, so a revoked session kept working for up to five\n // minutes on any client holding the cookie. It stays off; the per-isolate\n // reuse above re-checks the session row on every request instead.\n advanced: { cookiePrefix, ...config.advanced },\n }\n\n const auth: AppAuthInstance = betterAuth(options)\n // One indexed read of the session row by token: the revocation check for a\n // remembered session.\n const sessionRowIsLive: SessionRowCheck = async (token) => {\n const ctx = await (auth.$context as unknown as PromiseLike<{\n adapter: { findOne(args: { model: string; where: Array<{ field: string; value: unknown }> }): Promise<Record<string, unknown> | null> }\n }>)\n const row = await ctx.adapter.findOne({ model: 'session', where: [{ field: 'token', value: token }] })\n const expiresAt = row ? new Date(row.expiresAt as string | number | Date).getTime() : 0\n return expiresAt > Date.now()\n }\n\n const readSession = async (request: Request): Promise<AppAuthSession | null> => {\n const session = await auth.api.getSession({ headers: request.headers })\n return (session as AppAuthSession | null) ?? null\n }\n const memoMs = config.sessionCookieCacheSeconds === false ? 0\n : Math.min(SESSION_MEMO_MAX_MS, (config.sessionCookieCacheSeconds ?? DEFAULT_SESSION_COOKIE_CACHE_SECONDS) * 1000)\n const memoScope = `${config.baseURL}\\u0000${cookiePrefix}`\n const logSession = (message: string, meta: Record<string, unknown>) => console.warn(message, meta)\n const getSession = (request: Request): Promise<AppAuthSession | null> =>\n memoMs > 0 ? memoizedSession(memoScope, memoMs, request, readSession, sessionRowIsLive, logSession) : readSession(request)\n // Sign-out and session revocation drop this isolate's remembered sessions for the app.\n const handler = auth.handler\n auth.handler = async (request: Request) => {\n // In finally: a sign-out that fails after deleting the session must not leave it remembered.\n try { return await handler(request) }\n finally { if (request.method !== 'GET') forgetSessions(memoScope) }\n }\n\n const loginPath = config.loginPath ?? '/login'\n const guard = createAuthGuard<AppAuthSession>({ getSession, loginPath })\n\n let sso: TangleSsoHandlers | null = null\n if (config.sso) {\n const stateSecret = config.sso.stateSecret ?? config.secret\n if (!stateSecret) {\n throw new Error(\n 'createAppAuth: sso requires `secret` (or sso.stateSecret) — the signed-state CSRF cookie needs an HMAC secret',\n )\n }\n const transport = config.sso.protocol === 'oidc'\n ? { protocol: 'oidc' as const, auth: config.sso.client, store: config.sso.store, createPkcePair: config.sso.createPkcePair }\n : config.sso.protocol === 'identity'\n ? { protocol: 'identity' as const, auth: config.sso.client, store: config.sso.store }\n : { protocol: 'legacy' as const, auth: config.sso.client, store: config.sso.store }\n sso = createTangleSsoHandlers({\n ...transport,\n stateSecret,\n callbackUrl: config.sso.callbackUrl,\n stateCookieName: config.sso.stateCookieName ?? DEFAULT_STATE_COOKIE,\n setSessionCookie: createBetterAuthSessionCookieMinter(auth),\n secureCookies: config.sso.secureCookies ?? config.baseURL.startsWith('https:'),\n ...(config.sso.sessionTtlSeconds !== undefined ? { sessionTtlSeconds: config.sso.sessionTtlSeconds } : {}),\n ...(config.sso.stateTtlSeconds !== undefined ? { stateTtlSeconds: config.sso.stateTtlSeconds } : {}),\n ...(config.sso.defaultRedirectPath ? { defaultRedirectPath: config.sso.defaultRedirectPath } : {}),\n loginPath: config.sso.loginPath ?? loginPath,\n ...(config.sso.log ? { log: config.sso.log } : {}),\n })\n }\n\n return { auth, getSession, ...guard, sso }\n}\n"],"mappings":"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","names":[]}
@@ -91,6 +91,32 @@ export declare function OptionPill({ label, value, choices, onSelect, bandRef, c
91
91
  }) => ReactNode;
92
92
  };
93
93
  }): JSX.Element;
94
+ export interface GenerationSetting {
95
+ key: string;
96
+ label: string;
97
+ summary: string;
98
+ value: ModelOptionValue | undefined;
99
+ choices: readonly OptionChoice[];
100
+ onSelect: (value: ModelOptionValue) => void;
101
+ icon?: LucideIcon;
102
+ /** A binary setting acts immediately instead of opening a two-row menu. */
103
+ onToggle?: () => void;
104
+ custom?: {
105
+ label: string;
106
+ render: (args: {
107
+ close: () => void;
108
+ }) => ReactNode;
109
+ };
110
+ }
111
+ /** The model's current defaults and optional parameters in one compact menu.
112
+ * The value list opens inside the panel, so no nested portal can be clipped or
113
+ * mistaken for an outside click. A long duration or voice list scrolls within
114
+ * 16rem while the rest of the settings stay in place. */
115
+ export declare function GenerationSettings({ settings, bandRef, resetKey, }: {
116
+ settings: readonly GenerationSetting[];
117
+ bandRef: RefObject<HTMLDivElement | null>;
118
+ resetKey: string;
119
+ }): JSX.Element;
94
120
  /**
95
121
  * The custom-size form the Size menu swaps to.
96
122
  *
@@ -127,7 +127,7 @@ import {
127
127
  Monitor,
128
128
  Ratio,
129
129
  Scaling,
130
- SlidersHorizontal,
130
+ SlidersHorizontal as SlidersHorizontal2,
131
131
  Sparkles,
132
132
  TriangleAlert,
133
133
  Video
@@ -140,7 +140,7 @@ import {
140
140
  useId,
141
141
  useState as useState2
142
142
  } from "react";
143
- import { ImagePlus, Volume2, VolumeX, X } from "lucide-react";
143
+ import { ImagePlus, SlidersHorizontal, Volume2, VolumeX, X } from "lucide-react";
144
144
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
145
145
  var PILL = "inline-flex h-7 flex-none items-center gap-1.5 whitespace-nowrap rounded-full border border-border bg-card px-2.5 text-[14px] font-medium text-foreground transition hover:bg-accent";
146
146
  var PILL_LABEL = "[text-box:trim-both_cap_alphabetic]";
@@ -403,6 +403,166 @@ function OptionPill({
403
403
  )
404
404
  ] });
405
405
  }
406
+ function GenerationSettings({
407
+ settings,
408
+ bandRef,
409
+ resetKey
410
+ }) {
411
+ const [open, setOpen] = useState2(false);
412
+ const [more, setMore] = useState2(false);
413
+ const [active, setActive] = useState2(null);
414
+ const [customOpen, setCustomOpen] = useState2(false);
415
+ const [focusAfterSelect, setFocusAfterSelect] = useState2(null);
416
+ const { containerRef, triggerRef, panelRef, triggerProps } = usePopover(open, setOpen);
417
+ const panelId = useId();
418
+ useCloseWhenScrolledOut(open, setOpen, triggerRef, bandRef);
419
+ useEffect2(() => {
420
+ setOpen(false);
421
+ setMore(false);
422
+ setActive(null);
423
+ setCustomOpen(false);
424
+ setFocusAfterSelect(null);
425
+ }, [resetKey]);
426
+ useEffect2(() => {
427
+ if (open) panelRef.current?.querySelector("[data-setting-key]")?.focus();
428
+ }, [open, panelRef]);
429
+ useEffect2(() => {
430
+ if (!focusAfterSelect) return;
431
+ const rows = panelRef.current?.querySelectorAll("[data-setting-key]");
432
+ Array.from(rows ?? []).find((row) => row.dataset.settingKey === focusAfterSelect)?.focus();
433
+ setFocusAfterSelect(null);
434
+ }, [focusAfterSelect, panelRef]);
435
+ const primary = settings.slice(0, 3);
436
+ const visible = more ? settings : primary;
437
+ const summary = primary.map((setting) => setting.summary).join(" \xB7 ");
438
+ const fullSummary = settings.map((setting) => `${setting.label}: ${setting.summary}`).join(", ");
439
+ return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: "relative inline-flex flex-none", children: [
440
+ /* @__PURE__ */ jsxs(
441
+ "button",
442
+ {
443
+ type: "button",
444
+ ...triggerProps,
445
+ "aria-haspopup": "dialog",
446
+ "aria-controls": open ? panelId : void 0,
447
+ "aria-label": `Generation settings: ${fullSummary}`,
448
+ title: "Generation settings",
449
+ onClick: () => setOpen(!open),
450
+ className: PILL,
451
+ children: [
452
+ /* @__PURE__ */ jsx(SlidersHorizontal, { "aria-hidden": true, className: "h-3.5 w-3.5 shrink-0 text-muted-foreground", strokeWidth: 1.5 }),
453
+ /* @__PURE__ */ jsx("span", { className: PILL_LABEL, children: "Settings" }),
454
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "hidden max-w-[150px] truncate text-muted-foreground @[640px]/studio-composer:inline", children: summary }),
455
+ /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": true, className: "h-3 w-3 shrink-0 text-muted-foreground" })
456
+ ]
457
+ }
458
+ ),
459
+ /* @__PURE__ */ jsxs(
460
+ PopoverSurface,
461
+ {
462
+ open,
463
+ id: panelId,
464
+ role: "dialog",
465
+ "aria-label": "Generation settings",
466
+ triggerRef,
467
+ panelRef,
468
+ contentKey: `${active}:${more}:${customOpen}`,
469
+ className: `${MENU_PANEL} w-[min(320px,calc(100vw-24px))] gap-0.5`,
470
+ children: [
471
+ /* @__PURE__ */ jsx("div", { className: MENU_HEADER, children: "Generation settings" }),
472
+ visible.map((setting) => {
473
+ const Icon = setting.icon;
474
+ const expanded = active === setting.key;
475
+ return /* @__PURE__ */ jsx("div", { children: setting.onToggle ? /* @__PURE__ */ jsxs(
476
+ "button",
477
+ {
478
+ type: "button",
479
+ role: "switch",
480
+ "data-setting-key": setting.key,
481
+ "aria-checked": setting.value === true,
482
+ "aria-label": setting.label,
483
+ onClick: setting.onToggle,
484
+ className: menuRowClass(false),
485
+ children: [
486
+ Icon && /* @__PURE__ */ jsx(Icon, { "aria-hidden": true, className: "h-3.5 w-3.5 shrink-0", strokeWidth: 1.5 }),
487
+ /* @__PURE__ */ jsx("span", { children: setting.label }),
488
+ /* @__PURE__ */ jsx("span", { className: "ml-auto text-muted-foreground", children: setting.summary })
489
+ ]
490
+ }
491
+ ) : /* @__PURE__ */ jsxs(Fragment, { children: [
492
+ /* @__PURE__ */ jsxs(
493
+ "button",
494
+ {
495
+ type: "button",
496
+ "data-setting-key": setting.key,
497
+ "aria-expanded": expanded,
498
+ "aria-label": `${setting.label}: ${setting.summary}`,
499
+ onClick: () => {
500
+ setActive(expanded ? null : setting.key);
501
+ setCustomOpen(false);
502
+ },
503
+ className: menuRowClass(expanded),
504
+ children: [
505
+ Icon && /* @__PURE__ */ jsx(Icon, { "aria-hidden": true, className: "h-3.5 w-3.5 shrink-0", strokeWidth: 1.5 }),
506
+ /* @__PURE__ */ jsx("span", { children: setting.label }),
507
+ /* @__PURE__ */ jsx("span", { className: "ml-auto truncate text-muted-foreground", children: setting.summary }),
508
+ /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": true, className: "h-3 w-3 shrink-0 text-muted-foreground" })
509
+ ]
510
+ }
511
+ ),
512
+ expanded && /* @__PURE__ */ jsx("div", { role: "group", "aria-label": `${setting.label} choices`, className: "ml-5 max-h-64 overflow-y-auto overscroll-contain border-l border-border pl-1", children: customOpen && setting.custom ? setting.custom.render({ close: () => {
513
+ setActive(null);
514
+ setCustomOpen(false);
515
+ setFocusAfterSelect(setting.key);
516
+ } }) : /* @__PURE__ */ jsxs(Fragment, { children: [
517
+ setting.choices.map((choice, index) => /* @__PURE__ */ jsxs(
518
+ "label",
519
+ {
520
+ className: `${menuRowClass(choice.value === setting.value)} relative focus-within:outline focus-within:outline-2 focus-within:outline-ring`,
521
+ children: [
522
+ /* @__PURE__ */ jsx(
523
+ "input",
524
+ {
525
+ type: "radio",
526
+ name: `${panelId}-${setting.key}`,
527
+ value: String(choice.value),
528
+ checked: choice.value === setting.value,
529
+ onChange: () => {
530
+ setting.onSelect(choice.value);
531
+ setActive(null);
532
+ setFocusAfterSelect(setting.key);
533
+ },
534
+ className: "absolute inset-0 h-full w-full cursor-pointer opacity-0"
535
+ }
536
+ ),
537
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: choice.label }),
538
+ choice.value === setting.value && /* @__PURE__ */ jsx(CheckGlyph, { "aria-hidden": true, className: "ml-auto h-3.5 w-3.5 shrink-0 text-primary" })
539
+ ]
540
+ },
541
+ `${String(choice.value)}-${index}`
542
+ )),
543
+ setting.custom && /* @__PURE__ */ jsx("button", { type: "button", onClick: () => setCustomOpen(true), className: `${menuRowClass(false)} text-muted-foreground`, children: setting.custom.label })
544
+ ] }) })
545
+ ] }) }, setting.key);
546
+ }),
547
+ settings.length > 3 && /* @__PURE__ */ jsx(
548
+ "button",
549
+ {
550
+ type: "button",
551
+ "aria-expanded": more,
552
+ onClick: () => {
553
+ setMore(!more);
554
+ setActive(null);
555
+ setCustomOpen(false);
556
+ },
557
+ className: `${menuRowClass(false)} text-primary`,
558
+ children: more ? "Less" : `More settings (${settings.length - 3})`
559
+ }
560
+ )
561
+ ]
562
+ }
563
+ )
564
+ ] });
565
+ }
406
566
  function CustomSizeForm({
407
567
  initial,
408
568
  onApply,
@@ -681,7 +841,7 @@ var SEGMENTS = [
681
841
  { type: "video", label: "Video", icon: Video },
682
842
  { type: "speech", label: "Audio", icon: AudioLines }
683
843
  ];
684
- var PILL_ORDER = {
844
+ var PARAM_ORDER = {
685
845
  image: ["size", "quality", "n"],
686
846
  video: ["duration", "resolution", "aspect_ratio", "mode"],
687
847
  speech: ["voice", "speed"]
@@ -704,7 +864,7 @@ var PARAM_ICONS = {
704
864
  duration: Clock,
705
865
  resolution: Monitor,
706
866
  aspect_ratio: Ratio,
707
- mode: SlidersHorizontal,
867
+ mode: SlidersHorizontal2,
708
868
  voice: Mic,
709
869
  speed: Gauge
710
870
  };
@@ -743,7 +903,7 @@ function pickerModel(model, type) {
743
903
  }
744
904
  function visibleParams(type, options) {
745
905
  if (!options) return [];
746
- return PILL_ORDER[type].filter((param) => {
906
+ return PARAM_ORDER[type].filter((param) => {
747
907
  const meta = options[param];
748
908
  return Boolean(meta) && meta?.supported !== false;
749
909
  });
@@ -1019,6 +1179,50 @@ function StudioComposer({
1019
1179
  const modelDisplayName = laneDown ? "Unavailable" : modelOption?.name || modelId || "Select a model";
1020
1180
  const modelUnavailable = laneDown || modelOption?.status === "unavailable";
1021
1181
  const canReloadModels = Boolean(workspaceId) && !loadingModels && Boolean(catalogError || catalogReady && curatedModels.length === 0);
1182
+ const settingParams = type === "video" ? ["aspect_ratio", "duration", ...params.filter((param) => param !== "aspect_ratio" && param !== "duration")].filter((param) => params.includes(param)) : params;
1183
+ const settings = settingParams.map((param) => {
1184
+ const value = values[param] ?? options?.[param]?.default;
1185
+ const choices = choicesForSetting(param, options?.[param], value);
1186
+ const label = PARAM_LABELS[param] ?? param;
1187
+ return {
1188
+ key: param,
1189
+ label,
1190
+ summary: choices.find((choice) => choice.value === value)?.label ?? "\u2014",
1191
+ value,
1192
+ choices,
1193
+ onSelect: (next) => setOption(param, next),
1194
+ icon: PARAM_ICONS[param] ?? SlidersHorizontal2,
1195
+ ...param === "size" && supportsCustomImageSize(modelId) ? {
1196
+ custom: {
1197
+ label: "Custom\u2026",
1198
+ render: ({ close }) => /* @__PURE__ */ jsx2(
1199
+ CustomSizeForm,
1200
+ {
1201
+ initial: asText(value),
1202
+ onApply: (size) => {
1203
+ setOption("size", size);
1204
+ close();
1205
+ },
1206
+ onCancel: close
1207
+ }
1208
+ )
1209
+ }
1210
+ } : {}
1211
+ };
1212
+ });
1213
+ if (audioSupported) {
1214
+ const audioOn = (values.audio ?? audioMeta?.default) === true;
1215
+ settings.splice(Math.min(2, settings.length), 0, {
1216
+ key: "audio",
1217
+ label: "Audio",
1218
+ summary: audioOn ? "Audio on" : "Audio off",
1219
+ value: audioOn,
1220
+ choices: [],
1221
+ onSelect: (next) => setOption("audio", next),
1222
+ onToggle: () => setOption("audio", !audioOn),
1223
+ icon: AudioLines
1224
+ });
1225
+ }
1022
1226
  function reloadModels() {
1023
1227
  if (!workspaceId || catalogLoading || catalogPendingRef.current) return;
1024
1228
  catalogPendingRef.current = true;
@@ -1073,38 +1277,7 @@ function StudioComposer({
1073
1277
  ] })
1074
1278
  }
1075
1279
  ) }),
1076
- params.map((param) => /* @__PURE__ */ jsx2("div", { className: "studio-pill-in flex-none", children: /* @__PURE__ */ jsx2(
1077
- OptionPill,
1078
- {
1079
- label: PARAM_LABELS[param] ?? param,
1080
- icon: PARAM_ICONS[param] ?? SlidersHorizontal,
1081
- value: values[param],
1082
- choices: choicesForPill(param, options?.[param], values[param]),
1083
- onSelect: (value) => setOption(param, value),
1084
- bandRef,
1085
- custom: param === "size" && supportsCustomImageSize(modelId) ? {
1086
- label: "Custom\u2026",
1087
- render: ({ close }) => /* @__PURE__ */ jsx2(
1088
- CustomSizeForm,
1089
- {
1090
- initial: asText(values.size),
1091
- onApply: (size) => {
1092
- setOption("size", size);
1093
- close();
1094
- },
1095
- onCancel: close
1096
- }
1097
- )
1098
- } : void 0
1099
- }
1100
- ) }, `${type}-${param}`)),
1101
- audioSupported && /* @__PURE__ */ jsx2("div", { className: "studio-pill-in flex-none", children: /* @__PURE__ */ jsx2(
1102
- AudioTogglePill,
1103
- {
1104
- on: values.audio === true,
1105
- onToggle: (on) => setOption("audio", on)
1106
- }
1107
- ) }, `${type}-audio`),
1280
+ settings.length > 0 && /* @__PURE__ */ jsx2("div", { className: "studio-pill-in flex-none", children: /* @__PURE__ */ jsx2(GenerationSettings, { settings, bandRef, resetKey: `${type}:${modelId}` }) }),
1108
1281
  referenceSupported && /* @__PURE__ */ jsx2("div", { className: "studio-pill-in flex-none", children: /* @__PURE__ */ jsx2(
1109
1282
  ReferencePill,
1110
1283
  {
@@ -1140,7 +1313,7 @@ function asText(value) {
1140
1313
  function asWireScalar(value) {
1141
1314
  return typeof value === "string" || typeof value === "number" ? value : void 0;
1142
1315
  }
1143
- function choicesForPill(param, meta, value) {
1316
+ function choicesForSetting(param, meta, value) {
1144
1317
  if (!meta) return [];
1145
1318
  const choices = choicesFor(param, meta).map((choice) => ({
1146
1319
  value: choice,
@@ -3334,6 +3507,7 @@ export {
3334
3507
  ComposerBand,
3335
3508
  CustomSizeForm,
3336
3509
  GenerationNoticeChip,
3510
+ GenerationSettings,
3337
3511
  MediaTile,
3338
3512
  MediaTypeSegments,
3339
3513
  MediaViewerModal,