payload-better-auth 3.1.0 → 3.2.0

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.
package/README.md CHANGED
@@ -138,6 +138,10 @@ PAYLOAD_SECRET=your-payload-secret
138
138
  DATABASE_URI=file:./payload.db
139
139
  ```
140
140
 
141
+ ## Process Architecture
142
+
143
+ **Run Better Auth and Payload as separate processes.** When both run in the same process (e.g. Better Auth and Payload in one Next.js app), this plugin can trigger interaction loops between the two (reconcile → Payload hooks → sync → Better Auth → reconcile), which can cause noticeable performance issues. Running Better Auth and Payload in separate processes avoids these loops and is the recommended setup for both development and production.
144
+
141
145
  ## Access Control
142
146
 
143
147
  Your access rules are preserved and combined with Better Auth's internal access. BA sync operations (signed with `BA_TO_PAYLOAD_SECRET`) always pass.
@@ -1,6 +1,5 @@
1
1
  // src/plugins/reconcile-queue-plugin.ts
2
- import { APIError } from 'better-auth/api';
3
- import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins';
2
+ import { APIError, createAuthEndpoint, createAuthMiddleware } from 'better-auth/api';
4
3
  import { createDeduplicatedLogger } from '../shared/deduplicatedLogger';
5
4
  import { SESSION_COOKIE_NAME_KEY, TIMESTAMP_PREFIX } from '../storage/keys';
6
5
  import { Queue } from './reconcile-queue';
@@ -15,9 +14,9 @@ const defaultLog = (msg, extra)=>{
15
14
  return {
16
15
  user: {
17
16
  create: {
18
- after: (user)=>{
17
+ after: async (user)=>{
19
18
  queue.enqueueEnsure(user, true, 'user-operation');
20
- return Promise.resolve();
19
+ await queue.runEnsureNow(user);
21
20
  }
22
21
  },
23
22
  delete: {
@@ -27,9 +26,9 @@ const defaultLog = (msg, extra)=>{
27
26
  }
28
27
  },
29
28
  update: {
30
- after: (user)=>{
29
+ after: async (user)=>{
31
30
  queue.enqueueEnsure(user, true, 'user-operation');
32
- return Promise.resolve();
31
+ await queue.runEnsureNow(user);
33
32
  }
34
33
  }
35
34
  }
@@ -54,7 +53,7 @@ export const payloadBetterAuthPlugin = (opts)=>{
54
53
  id: 'reconcile-queue-plugin',
55
54
  endpoints: {
56
55
  // convenience for tests/admin tools (optional)
57
- authMethods: createAuthEndpoint('/auth/methods', {
56
+ authMethods: createAuthEndpoint('/methods', {
58
57
  method: 'GET'
59
58
  }, async ({ context, json })=>{
60
59
  const authMethods = [];
@@ -191,7 +190,8 @@ export const payloadBetterAuthPlugin = (opts)=>{
191
190
  // 1. Explicitly set via useSecureCookies option
192
191
  // 2. NODE_ENV is 'production'
193
192
  // 3. baseURL starts with 'https://'
194
- const isHttps = options.baseURL?.startsWith('https://') ?? false;
193
+ const baseUrlStr = typeof options.baseURL === 'string' ? options.baseURL : undefined;
194
+ const isHttps = baseUrlStr?.startsWith('https://') ?? false;
195
195
  const useSecureCookies = options.advanced?.useSecureCookies ?? (process.env.NODE_ENV === 'production' || isHttps);
196
196
  let sessionCookieName;
197
197
  if (customCookieName) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/better-auth/plugin.ts"],"sourcesContent":["// src/plugins/reconcile-queue-plugin.ts\nimport type { AuthContext, BetterAuthPlugin, DeepPartial } from 'better-auth'\nimport type { SanitizedConfig } from 'payload'\n\nimport { APIError } from 'better-auth/api'\nimport { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins'\n\nimport type { EventBus } from '../eventBus/types'\nimport type { SecondaryStorage } from '../storage/types'\nimport type { AuthMethod } from './helpers'\n\nimport { createDeduplicatedLogger } from '../shared/deduplicatedLogger'\nimport { SESSION_COOKIE_NAME_KEY, TIMESTAMP_PREFIX } from '../storage/keys'\nimport { type InitOptions, Queue } from './reconcile-queue'\nimport {\n type BAUser,\n type BetterAuthUser,\n createDeleteUserFromPayload,\n createListPayloadUsersPage,\n createSyncUserToPayload,\n} from './sources'\n\ntype PayloadSyncPluginContext = { payloadSyncPlugin: { queue: Queue } } & AuthContext\n\nconst defaultLog = (msg: string, extra?: unknown) => {\n console.log(`[reconcile] ${msg}`, extra ? JSON.stringify(extra, null, 2) : '')\n}\n\n/**\n * Type for the user data that will be written to Payload.\n * Excludes auto-generated fields.\n */\nexport type PayloadUserData<TUser extends object> = Omit<\n TUser,\n 'baUserId' | 'betterAuthAccounts' | 'createdAt' | 'id' | 'updatedAt'\n>\n\nexport interface PayloadBetterAuthPluginOptions<\n TUser extends object = Record<string, unknown>,\n TCollectionSlug extends string = string,\n> extends InitOptions {\n /**\n * Prefix for Better Auth collections in Payload (default: '__better_auth').\n * The collections will be named: {prefix}_email_password, {prefix}_magic_link\n */\n collectionPrefix?: string\n enableLogging?: boolean\n /**\n * EventBus for timestamp-based coordination between plugins.\n * Both plugins MUST share the same eventBus instance.\n *\n * Available implementations:\n * - `createSqlitePollingEventBus()` - Uses SQLite for cross-process coordination\n *\n * @example\n * // Create shared eventBus (e.g., in a separate file)\n * import { createSqlitePollingEventBus } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.event-bus.db')\n * export const eventBus = createSqlitePollingEventBus({ db })\n */\n eventBus: EventBus\n /**\n * Map Better Auth user data to Payload user fields.\n * Called on create AND update - allows filling defaults for schema changes.\n *\n * @example\n * mapUserToPayload: (baUser) => ({\n * email: baUser.email ?? '',\n * name: baUser.name ?? 'New User',\n * role: 'user', // default for new required fields\n * })\n */\n mapUserToPayload: (baUser: BetterAuthUser) => PayloadUserData<TUser>\n payloadConfig: Promise<SanitizedConfig>\n /**\n * Secondary storage for state coordination between Better Auth and Payload.\n * Both plugins MUST share the same storage instance.\n *\n * This storage is automatically passed to Better Auth as `secondaryStorage`,\n * enabling session caching - Payload validates sessions directly from storage\n * without HTTP calls to Better Auth.\n *\n * Available storage adapters:\n * - `createSqliteStorage()` - Uses Node.js 22+ native SQLite (no external dependencies, recommended for dev)\n * - `createRedisStorage(redis)` - Redis-backed, for distributed/multi-server production\n *\n * @example\n * // Create shared storage (e.g., in a separate file)\n * import { createSqliteStorage } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.sync-state.db')\n * export const storage = createSqliteStorage({ db })\n */\n storage: SecondaryStorage\n token: string // simple header token for admin endpoints\n /**\n * Slug for the Payload users collection (default: 'users').\n * Must match the collection slug defined in your Payload config.\n */\n usersSlug?: TCollectionSlug\n}\n\n/**\n * Create database hooks that enqueue user changes to the reconciliation queue.\n * All sync operations go through the queue for consistent handling with retries.\n */\nfunction createQueueBasedHooks(queue: Queue) {\n return {\n user: {\n create: {\n after: (user: BAUser): Promise<void> => {\n queue.enqueueEnsure(user, true, 'user-operation')\n return Promise.resolve()\n },\n },\n delete: {\n after: (user: BAUser): Promise<void> => {\n queue.enqueueDelete(user.id, true, 'user-operation')\n return Promise.resolve()\n },\n },\n update: {\n after: (user: BAUser): Promise<void> => {\n queue.enqueueEnsure(user, true, 'user-operation')\n return Promise.resolve()\n },\n },\n },\n }\n}\n\nexport const payloadBetterAuthPlugin = <\n TUser extends object = Record<string, unknown>,\n TCollectionSlug extends string = string,\n>(\n opts: PayloadBetterAuthPluginOptions<TUser, TCollectionSlug>,\n): BetterAuthPlugin => {\n const {\n collectionPrefix = '__better_auth',\n eventBus,\n mapUserToPayload,\n storage,\n usersSlug = 'users' as TCollectionSlug,\n } = opts\n\n // Compute derived collection slugs\n const emailPasswordSlug = `${collectionPrefix}_email_password` as TCollectionSlug\n const magicLinkSlug = `${collectionPrefix}_magic_link` as TCollectionSlug\n\n // Create deduplicated logger\n const logger = createDeduplicatedLogger({\n enabled: opts.enableLogging ?? false,\n prefix: '[better-auth]',\n storage,\n })\n\n // Keep the simple log for queue operations (they handle their own deduplication)\n const queueLog = opts.enableLogging ? defaultLog : undefined\n\n // Track subscription for cleanup\n let unsubscribeFromPayload: (() => void) | null = null\n\n return {\n id: 'reconcile-queue-plugin',\n endpoints: {\n // convenience for tests/admin tools (optional)\n authMethods: createAuthEndpoint(\n '/auth/methods',\n { method: 'GET' },\n async ({ context, json }) => {\n const authMethods: AuthMethod[] = []\n // Check if emailAndPassword is enabled, or if present at all (not present defaults to false)\n if (context.options.emailAndPassword?.enabled) {\n authMethods.push({\n method: 'emailAndPassword',\n options: {\n minPasswordLength: context.options.emailAndPassword.minPasswordLength ?? 0,\n },\n })\n }\n if (context.options.plugins?.some((p) => p.id === 'magic-link')) {\n authMethods.push({ method: 'magicLink' })\n }\n\n return await json(authMethods)\n },\n ),\n deleteNow: createAuthEndpoint(\n '/reconcile/delete',\n { method: 'POST' },\n async ({ context, json, request }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n throw new APIError('UNAUTHORIZED', { message: 'invalid token' })\n }\n const body = (await request?.json().catch(() => ({}))) as { baId?: string } | undefined\n const baId = body?.baId\n if (!baId) {\n throw new APIError('BAD_REQUEST', { message: 'missing baId' })\n }\n ;(context as PayloadSyncPluginContext).payloadSyncPlugin.queue.enqueueDelete(\n baId,\n true,\n 'user-operation',\n )\n return json({ ok: true })\n },\n ),\n ensureNow: createAuthEndpoint(\n '/reconcile/ensure',\n { method: 'POST' },\n async ({ context, json, request }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n throw new APIError('UNAUTHORIZED', { message: 'invalid token' })\n }\n const body = (await request?.json().catch(() => ({}))) as { user?: BAUser } | undefined\n const user = body?.user\n if (!user?.id) {\n throw new APIError('BAD_REQUEST', { message: 'missing user' })\n }\n ;(context as PayloadSyncPluginContext).payloadSyncPlugin.queue.enqueueEnsure(\n user,\n true,\n 'user-operation',\n )\n return json({ ok: true })\n },\n ),\n run: createAuthEndpoint(\n '/reconcile/run',\n { method: 'POST' },\n async ({ context, json, request }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n throw new APIError('UNAUTHORIZED', { message: 'invalid token' })\n }\n await (context as PayloadSyncPluginContext).payloadSyncPlugin.queue.seedFullReconcile()\n return json({ ok: true })\n },\n ),\n status: createAuthEndpoint(\n '/reconcile/status',\n { method: 'GET' },\n async ({ context, json, request }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n return Promise.reject(\n new APIError('UNAUTHORIZED', { message: 'invalid token' }) as Error,\n )\n }\n return json((context as PayloadSyncPluginContext).payloadSyncPlugin.queue.status())\n },\n ),\n // Warmup endpoint - triggers plugin initialization without auth\n // Returns basic instance info\n warmup: createAuthEndpoint('/warmup', { method: 'GET' }, async ({ context, json }) => {\n const authMethods: string[] = []\n if (context.options.emailAndPassword?.enabled) {\n authMethods.push('emailAndPassword')\n }\n if (context.options.plugins?.some((p) => p.id === 'magic-link')) {\n authMethods.push('magicLink')\n }\n\n return json({\n authMethods,\n initialized: true,\n pluginId: 'reconcile-queue-plugin',\n timestamp: new Date().toISOString(),\n })\n }),\n },\n hooks: {\n before: [\n {\n handler: createAuthMiddleware(async (ctx) => {\n const locale = ctx.getHeader('User-Locale')\n return Promise.resolve({\n context: { ...ctx, body: { ...ctx.body, locale: locale ?? undefined } },\n })\n }),\n matcher: (context) => {\n return context.path === '/sign-up/email'\n },\n },\n ],\n },\n async init({ internalAdapter, options }) {\n // Always log init start for debugging\n logger.always('Plugin init started')\n\n // Compute and store the session cookie name for Payload to read\n // This accounts for cookiePrefix, custom cookie names, and __Secure- prefix\n const cookiePrefix = options.advanced?.cookiePrefix ?? 'better-auth'\n const customCookieName = options.advanced?.cookies?.session_token?.name\n // Better Auth uses secure cookies when:\n // 1. Explicitly set via useSecureCookies option\n // 2. NODE_ENV is 'production'\n // 3. baseURL starts with 'https://'\n const isHttps = options.baseURL?.startsWith('https://') ?? false\n const useSecureCookies =\n options.advanced?.useSecureCookies ?? (process.env.NODE_ENV === 'production' || isHttps)\n\n let sessionCookieName: string\n if (customCookieName) {\n // Custom cookie name takes precedence\n sessionCookieName = useSecureCookies ? `__Secure-${customCookieName}` : customCookieName\n } else {\n // Default format: {prefix}.session_token\n const baseName = `${cookiePrefix}.session_token`\n sessionCookieName = useSecureCookies ? `__Secure-${baseName}` : baseName\n }\n\n // Store session cookie name in KV for Payload plugin to read\n await storage.set(SESSION_COOKIE_NAME_KEY, sessionCookieName)\n await logger.log('cookie-config', `Session cookie name: ${sessionCookieName}`)\n\n // Create the reconciliation queue\n const queue = new Queue(\n {\n collectionPrefix,\n deleteUserFromPayload: createDeleteUserFromPayload(\n opts.payloadConfig,\n emailPasswordSlug,\n magicLinkSlug,\n usersSlug,\n ),\n internalAdapter,\n listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig, usersSlug),\n log: queueLog,\n mapUserToPayload,\n syncUserToPayload: createSyncUserToPayload(\n opts.payloadConfig,\n emailPasswordSlug,\n magicLinkSlug,\n usersSlug,\n mapUserToPayload,\n ),\n },\n {\n ...opts,\n // Don't run reconcile on boot - we use timestamp-based coordination instead\n runOnBoot: false,\n },\n )\n\n // Log init (deduplicated)\n await logger.log('init', 'Initialized')\n\n // Timestamp-based reconciliation coordination\n async function attemptReconciliation(): Promise<void> {\n logger.always('Syncing users to Payload...')\n await storage.set(TIMESTAMP_PREFIX + 'better-auth', String(Date.now()))\n try {\n await queue.seedFullReconcile()\n logger.always('Sync completed successfully')\n // Success - unsubscribe if we were watching\n if (unsubscribeFromPayload) {\n unsubscribeFromPayload()\n unsubscribeFromPayload = null\n }\n } catch (error) {\n logger.always('Sync failed, will retry when Payload restarts', error)\n // Subscribe to Payload timestamp changes if not already\n if (!unsubscribeFromPayload) {\n unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', () => {\n attemptReconciliation().catch((err) => {\n logger.always('Sync attempt failed', err)\n })\n })\n }\n }\n }\n\n // Check if Payload is online and started more recently than our last reconcile\n const payloadTsStr = await storage.get(TIMESTAMP_PREFIX + 'payload')\n const baTsStr = await storage.get(TIMESTAMP_PREFIX + 'better-auth')\n const payloadTs = payloadTsStr ? parseInt(payloadTsStr, 10) : null\n const baTs = baTsStr ? parseInt(baTsStr, 10) : null\n\n // Determine reconciliation state\n logger.always('Checking reconciliation state', {\n baTs: baTs ? new Date(baTs).toISOString() : null,\n payloadTs: payloadTs ? new Date(payloadTs).toISOString() : null,\n })\n\n if (payloadTs === null) {\n // Payload hasn't started yet\n logger.always('Waiting for Payload to start...')\n unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', () => {\n attemptReconciliation().catch((err) => {\n logger.always('Sync attempt failed', err)\n })\n })\n } else if (baTs === null) {\n // First run - always sync\n logger.always('First run - triggering initial sync')\n attemptReconciliation().catch((err) => {\n logger.always('Initial sync failed', err)\n })\n } else if (payloadTs > baTs) {\n // Payload restarted since last reconcile - sync needed\n logger.always('Payload restarted - triggering sync')\n attemptReconciliation().catch((err) => {\n logger.always('Sync failed', err)\n })\n } else {\n // Already reconciled and up-to-date\n logger.always('Already synchronized', {\n lastSync: new Date(baTs).toISOString(),\n })\n unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', () => {\n attemptReconciliation().catch((err) => {\n logger.always('Sync attempt failed', err)\n })\n })\n }\n\n // Create queue-based database hooks - all user sync goes through the queue\n const queueBasedHooks = createQueueBasedHooks(queue)\n\n return {\n context: { payloadSyncPlugin: { queue } } as DeepPartial<Omit<AuthContext, 'options'>>,\n options: {\n databaseHooks: queueBasedHooks,\n // Pass storage to Better Auth as secondaryStorage - this makes BA write sessions\n // to the shared storage, allowing Payload to validate sessions directly from cache\n secondaryStorage: storage,\n user: { deleteUser: { enabled: true } },\n },\n }\n },\n schema: {\n user: {\n fields: {\n locale: {\n type: 'string',\n required: false,\n },\n },\n },\n },\n }\n}\n"],"names":["APIError","createAuthEndpoint","createAuthMiddleware","createDeduplicatedLogger","SESSION_COOKIE_NAME_KEY","TIMESTAMP_PREFIX","Queue","createDeleteUserFromPayload","createListPayloadUsersPage","createSyncUserToPayload","defaultLog","msg","extra","console","log","JSON","stringify","createQueueBasedHooks","queue","user","create","after","enqueueEnsure","Promise","resolve","delete","enqueueDelete","id","update","payloadBetterAuthPlugin","opts","collectionPrefix","eventBus","mapUserToPayload","storage","usersSlug","emailPasswordSlug","magicLinkSlug","logger","enabled","enableLogging","prefix","queueLog","undefined","unsubscribeFromPayload","endpoints","authMethods","method","context","json","options","emailAndPassword","push","minPasswordLength","plugins","some","p","deleteNow","request","token","headers","get","message","body","catch","baId","payloadSyncPlugin","ok","ensureNow","run","seedFullReconcile","status","reject","warmup","initialized","pluginId","timestamp","Date","toISOString","hooks","before","handler","ctx","locale","getHeader","matcher","path","init","internalAdapter","always","cookiePrefix","advanced","customCookieName","cookies","session_token","name","isHttps","baseURL","startsWith","useSecureCookies","process","env","NODE_ENV","sessionCookieName","baseName","set","deleteUserFromPayload","payloadConfig","listPayloadUsersPage","syncUserToPayload","runOnBoot","attemptReconciliation","String","now","error","subscribeToTimestamp","err","payloadTsStr","baTsStr","payloadTs","parseInt","baTs","lastSync","queueBasedHooks","databaseHooks","secondaryStorage","deleteUser","schema","fields","type","required"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/better-auth/plugin.ts"],"sourcesContent":["// src/plugins/reconcile-queue-plugin.ts\nimport type { AuthContext, BetterAuthPlugin } from 'better-auth'\nimport type { SanitizedConfig } from 'payload'\n\nimport { APIError, createAuthEndpoint, createAuthMiddleware } from 'better-auth/api'\n\nimport type { EventBus } from '../eventBus/types'\nimport type { SecondaryStorage } from '../storage/types'\nimport type { AuthMethod } from './helpers'\n\nimport { createDeduplicatedLogger } from '../shared/deduplicatedLogger'\nimport { SESSION_COOKIE_NAME_KEY, TIMESTAMP_PREFIX } from '../storage/keys'\nimport { type InitOptions, Queue } from './reconcile-queue'\nimport {\n type BAUser,\n type BetterAuthUser,\n createDeleteUserFromPayload,\n createListPayloadUsersPage,\n createSyncUserToPayload,\n} from './sources'\n\ntype PayloadSyncPluginContext = { payloadSyncPlugin: { queue: Queue } } & AuthContext\n\nconst defaultLog = (msg: string, extra?: unknown) => {\n console.log(`[reconcile] ${msg}`, extra ? JSON.stringify(extra, null, 2) : '')\n}\n\n/**\n * Type for the user data that will be written to Payload.\n * Excludes auto-generated fields.\n */\nexport type PayloadUserData<TUser extends object> = Omit<\n TUser,\n 'baUserId' | 'betterAuthAccounts' | 'createdAt' | 'id' | 'updatedAt'\n>\n\nexport interface PayloadBetterAuthPluginOptions<\n TUser extends object = Record<string, unknown>,\n TCollectionSlug extends string = string,\n> extends InitOptions {\n /**\n * Prefix for Better Auth collections in Payload (default: '__better_auth').\n * The collections will be named: {prefix}_email_password, {prefix}_magic_link\n */\n collectionPrefix?: string\n enableLogging?: boolean\n /**\n * EventBus for timestamp-based coordination between plugins.\n * Both plugins MUST share the same eventBus instance.\n *\n * Available implementations:\n * - `createSqlitePollingEventBus()` - Uses SQLite for cross-process coordination\n *\n * @example\n * // Create shared eventBus (e.g., in a separate file)\n * import { createSqlitePollingEventBus } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.event-bus.db')\n * export const eventBus = createSqlitePollingEventBus({ db })\n */\n eventBus: EventBus\n /**\n * Map Better Auth user data to Payload user fields.\n * Called on create AND update - allows filling defaults for schema changes.\n *\n * @example\n * mapUserToPayload: (baUser) => ({\n * email: baUser.email ?? '',\n * name: baUser.name ?? 'New User',\n * role: 'user', // default for new required fields\n * })\n */\n mapUserToPayload: (baUser: BetterAuthUser) => PayloadUserData<TUser>\n payloadConfig: Promise<SanitizedConfig>\n /**\n * Secondary storage for state coordination between Better Auth and Payload.\n * Both plugins MUST share the same storage instance.\n *\n * This storage is automatically passed to Better Auth as `secondaryStorage`,\n * enabling session caching - Payload validates sessions directly from storage\n * without HTTP calls to Better Auth.\n *\n * Available storage adapters:\n * - `createSqliteStorage()` - Uses Node.js 22+ native SQLite (no external dependencies, recommended for dev)\n * - `createRedisStorage(redis)` - Redis-backed, for distributed/multi-server production\n *\n * @example\n * // Create shared storage (e.g., in a separate file)\n * import { createSqliteStorage } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.sync-state.db')\n * export const storage = createSqliteStorage({ db })\n */\n storage: SecondaryStorage\n token: string // simple header token for admin endpoints\n /**\n * Slug for the Payload users collection (default: 'users').\n * Must match the collection slug defined in your Payload config.\n */\n usersSlug?: TCollectionSlug\n}\n\n/**\n * Create database hooks that enqueue user changes to the reconciliation queue.\n * All sync operations go through the queue for consistent handling with retries.\n */\nfunction createQueueBasedHooks(queue: Queue) {\n return {\n user: {\n create: {\n after: async (user: BAUser): Promise<void> => {\n queue.enqueueEnsure(user, true, 'user-operation')\n await queue.runEnsureNow(user)\n },\n },\n delete: {\n after: (user: BAUser): Promise<void> => {\n queue.enqueueDelete(user.id, true, 'user-operation')\n return Promise.resolve()\n },\n },\n update: {\n after: async (user: BAUser): Promise<void> => {\n queue.enqueueEnsure(user, true, 'user-operation')\n await queue.runEnsureNow(user)\n },\n },\n },\n }\n}\n\nexport const payloadBetterAuthPlugin = <\n TUser extends object = Record<string, unknown>,\n TCollectionSlug extends string = string,\n>(\n opts: PayloadBetterAuthPluginOptions<TUser, TCollectionSlug>,\n): BetterAuthPlugin => {\n const {\n collectionPrefix = '__better_auth',\n eventBus,\n mapUserToPayload,\n storage,\n usersSlug = 'users' as TCollectionSlug,\n } = opts\n\n // Compute derived collection slugs\n const emailPasswordSlug = `${collectionPrefix}_email_password` as TCollectionSlug\n const magicLinkSlug = `${collectionPrefix}_magic_link` as TCollectionSlug\n\n // Create deduplicated logger\n const logger = createDeduplicatedLogger({\n enabled: opts.enableLogging ?? false,\n prefix: '[better-auth]',\n storage,\n })\n\n // Keep the simple log for queue operations (they handle their own deduplication)\n const queueLog = opts.enableLogging ? defaultLog : undefined\n\n // Track subscription for cleanup\n let unsubscribeFromPayload: (() => void) | null = null\n\n return {\n id: 'reconcile-queue-plugin',\n endpoints: {\n // convenience for tests/admin tools (optional)\n authMethods: createAuthEndpoint(\n '/methods',\n { method: 'GET' },\n async ({\n context,\n json,\n }) => {\n const authMethods: AuthMethod[] = []\n // Check if emailAndPassword is enabled, or if present at all (not present defaults to false)\n if (context.options.emailAndPassword?.enabled) {\n authMethods.push({\n method: 'emailAndPassword',\n options: {\n minPasswordLength: context.options.emailAndPassword.minPasswordLength ?? 0,\n },\n })\n }\n if (context.options.plugins?.some((p) => p.id === 'magic-link')) {\n authMethods.push({ method: 'magicLink' })\n }\n\n return await json(authMethods)\n },\n ),\n deleteNow: createAuthEndpoint(\n '/reconcile/delete',\n { method: 'POST' },\n async ({\n context,\n json,\n request,\n }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n throw new APIError('UNAUTHORIZED', { message: 'invalid token' })\n }\n const body = (await request?.json().catch(() => ({}))) as { baId?: string } | undefined\n const baId = body?.baId\n if (!baId) {\n throw new APIError('BAD_REQUEST', { message: 'missing baId' })\n }\n ;(context as PayloadSyncPluginContext).payloadSyncPlugin.queue.enqueueDelete(\n baId,\n true,\n 'user-operation',\n )\n return json({ ok: true })\n },\n ),\n ensureNow: createAuthEndpoint(\n '/reconcile/ensure',\n { method: 'POST' },\n async ({\n context,\n json,\n request,\n }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n throw new APIError('UNAUTHORIZED', { message: 'invalid token' })\n }\n const body = (await request?.json().catch(() => ({}))) as { user?: BAUser } | undefined\n const user = body?.user\n if (!user?.id) {\n throw new APIError('BAD_REQUEST', { message: 'missing user' })\n }\n ;(context as PayloadSyncPluginContext).payloadSyncPlugin.queue.enqueueEnsure(\n user,\n true,\n 'user-operation',\n )\n return json({ ok: true })\n },\n ),\n run: createAuthEndpoint(\n '/reconcile/run',\n { method: 'POST' },\n async ({\n context,\n json,\n request,\n }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n throw new APIError('UNAUTHORIZED', { message: 'invalid token' })\n }\n await (context as PayloadSyncPluginContext).payloadSyncPlugin.queue.seedFullReconcile()\n return json({ ok: true })\n },\n ),\n status: createAuthEndpoint(\n '/reconcile/status',\n { method: 'GET' },\n async ({\n context,\n json,\n request,\n }) => {\n if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {\n return Promise.reject(\n new APIError('UNAUTHORIZED', { message: 'invalid token' }) as Error,\n )\n }\n return json((context as PayloadSyncPluginContext).payloadSyncPlugin.queue.status())\n },\n ),\n // Warmup endpoint - triggers plugin initialization without auth\n // Returns basic instance info\n warmup: createAuthEndpoint(\n '/warmup',\n { method: 'GET' },\n async ({\n context,\n json,\n }) => {\n const authMethods: string[] = []\n if (context.options.emailAndPassword?.enabled) {\n authMethods.push('emailAndPassword')\n }\n if (context.options.plugins?.some((p) => p.id === 'magic-link')) {\n authMethods.push('magicLink')\n }\n\n return json({\n authMethods,\n initialized: true,\n pluginId: 'reconcile-queue-plugin',\n timestamp: new Date().toISOString(),\n })\n },\n ),\n },\n hooks: {\n before: [\n {\n handler: createAuthMiddleware(async (ctx) => {\n const locale = ctx.getHeader('User-Locale')\n return Promise.resolve({\n context: { ...ctx, body: { ...ctx.body, locale: locale ?? undefined } },\n })\n }),\n matcher: (context) => {\n return context.path === '/sign-up/email'\n },\n },\n ],\n },\n async init({ internalAdapter, options }) {\n // Always log init start for debugging\n logger.always('Plugin init started')\n\n // Compute and store the session cookie name for Payload to read\n // This accounts for cookiePrefix, custom cookie names, and __Secure- prefix\n const cookiePrefix = options.advanced?.cookiePrefix ?? 'better-auth'\n const customCookieName = options.advanced?.cookies?.session_token?.name\n // Better Auth uses secure cookies when:\n // 1. Explicitly set via useSecureCookies option\n // 2. NODE_ENV is 'production'\n // 3. baseURL starts with 'https://'\n const baseUrlStr = typeof options.baseURL === 'string' ? options.baseURL : undefined\n const isHttps = baseUrlStr?.startsWith('https://') ?? false\n const useSecureCookies =\n options.advanced?.useSecureCookies ?? (process.env.NODE_ENV === 'production' || isHttps)\n\n let sessionCookieName: string\n if (customCookieName) {\n // Custom cookie name takes precedence\n sessionCookieName = useSecureCookies ? `__Secure-${customCookieName}` : customCookieName\n } else {\n // Default format: {prefix}.session_token\n const baseName = `${cookiePrefix}.session_token`\n sessionCookieName = useSecureCookies ? `__Secure-${baseName}` : baseName\n }\n\n // Store session cookie name in KV for Payload plugin to read\n await storage.set(SESSION_COOKIE_NAME_KEY, sessionCookieName)\n await logger.log('cookie-config', `Session cookie name: ${sessionCookieName}`)\n\n // Create the reconciliation queue\n const queue = new Queue(\n {\n collectionPrefix,\n deleteUserFromPayload: createDeleteUserFromPayload(\n opts.payloadConfig,\n emailPasswordSlug,\n magicLinkSlug,\n usersSlug,\n ),\n internalAdapter,\n listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig, usersSlug),\n log: queueLog,\n mapUserToPayload,\n syncUserToPayload: createSyncUserToPayload(\n opts.payloadConfig,\n emailPasswordSlug,\n magicLinkSlug,\n usersSlug,\n mapUserToPayload,\n ),\n },\n {\n ...opts,\n // Don't run reconcile on boot - we use timestamp-based coordination instead\n runOnBoot: false,\n },\n )\n\n // Log init (deduplicated)\n await logger.log('init', 'Initialized')\n\n // Timestamp-based reconciliation coordination\n async function attemptReconciliation(): Promise<void> {\n logger.always('Syncing users to Payload...')\n await storage.set(TIMESTAMP_PREFIX + 'better-auth', String(Date.now()))\n try {\n await queue.seedFullReconcile()\n logger.always('Sync completed successfully')\n // Success - unsubscribe if we were watching\n if (unsubscribeFromPayload) {\n unsubscribeFromPayload()\n unsubscribeFromPayload = null\n }\n } catch (error) {\n logger.always('Sync failed, will retry when Payload restarts', error)\n // Subscribe to Payload timestamp changes if not already\n if (!unsubscribeFromPayload) {\n unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', () => {\n attemptReconciliation().catch((err) => {\n logger.always('Sync attempt failed', err)\n })\n })\n }\n }\n }\n\n // Check if Payload is online and started more recently than our last reconcile\n const payloadTsStr = await storage.get(TIMESTAMP_PREFIX + 'payload')\n const baTsStr = await storage.get(TIMESTAMP_PREFIX + 'better-auth')\n const payloadTs = payloadTsStr ? parseInt(payloadTsStr, 10) : null\n const baTs = baTsStr ? parseInt(baTsStr, 10) : null\n\n // Determine reconciliation state\n logger.always('Checking reconciliation state', {\n baTs: baTs ? new Date(baTs).toISOString() : null,\n payloadTs: payloadTs ? new Date(payloadTs).toISOString() : null,\n })\n\n if (payloadTs === null) {\n // Payload hasn't started yet\n logger.always('Waiting for Payload to start...')\n unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', () => {\n attemptReconciliation().catch((err) => {\n logger.always('Sync attempt failed', err)\n })\n })\n } else if (baTs === null) {\n // First run - always sync\n logger.always('First run - triggering initial sync')\n attemptReconciliation().catch((err) => {\n logger.always('Initial sync failed', err)\n })\n } else if (payloadTs > baTs) {\n // Payload restarted since last reconcile - sync needed\n logger.always('Payload restarted - triggering sync')\n attemptReconciliation().catch((err) => {\n logger.always('Sync failed', err)\n })\n } else {\n // Already reconciled and up-to-date\n logger.always('Already synchronized', {\n lastSync: new Date(baTs).toISOString(),\n })\n unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', () => {\n attemptReconciliation().catch((err) => {\n logger.always('Sync attempt failed', err)\n })\n })\n }\n\n // Create queue-based database hooks - all user sync goes through the queue\n const queueBasedHooks = createQueueBasedHooks(queue)\n\n return {\n context: { payloadSyncPlugin: { queue } },\n options: {\n databaseHooks: queueBasedHooks,\n // Pass storage to Better Auth as secondaryStorage - this makes BA write sessions\n // to the shared storage, allowing Payload to validate sessions directly from cache\n secondaryStorage: storage,\n user: { deleteUser: { enabled: true } },\n },\n }\n },\n schema: {\n user: {\n fields: {\n locale: {\n type: 'string',\n required: false,\n },\n },\n },\n },\n } satisfies BetterAuthPlugin\n}\n"],"names":["APIError","createAuthEndpoint","createAuthMiddleware","createDeduplicatedLogger","SESSION_COOKIE_NAME_KEY","TIMESTAMP_PREFIX","Queue","createDeleteUserFromPayload","createListPayloadUsersPage","createSyncUserToPayload","defaultLog","msg","extra","console","log","JSON","stringify","createQueueBasedHooks","queue","user","create","after","enqueueEnsure","runEnsureNow","delete","enqueueDelete","id","Promise","resolve","update","payloadBetterAuthPlugin","opts","collectionPrefix","eventBus","mapUserToPayload","storage","usersSlug","emailPasswordSlug","magicLinkSlug","logger","enabled","enableLogging","prefix","queueLog","undefined","unsubscribeFromPayload","endpoints","authMethods","method","context","json","options","emailAndPassword","push","minPasswordLength","plugins","some","p","deleteNow","request","token","headers","get","message","body","catch","baId","payloadSyncPlugin","ok","ensureNow","run","seedFullReconcile","status","reject","warmup","initialized","pluginId","timestamp","Date","toISOString","hooks","before","handler","ctx","locale","getHeader","matcher","path","init","internalAdapter","always","cookiePrefix","advanced","customCookieName","cookies","session_token","name","baseUrlStr","baseURL","isHttps","startsWith","useSecureCookies","process","env","NODE_ENV","sessionCookieName","baseName","set","deleteUserFromPayload","payloadConfig","listPayloadUsersPage","syncUserToPayload","runOnBoot","attemptReconciliation","String","now","error","subscribeToTimestamp","err","payloadTsStr","baTsStr","payloadTs","parseInt","baTs","lastSync","queueBasedHooks","databaseHooks","secondaryStorage","deleteUser","schema","fields","type","required"],"mappings":"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"}
@@ -45,6 +45,7 @@ export declare class Queue {
45
45
  private clearFullReconcileTasks;
46
46
  private enqueue;
47
47
  private listBAUsersPage;
48
+ private runEnsure;
48
49
  private runTask;
49
50
  private scheduleNextReconcile;
50
51
  /** Paginated approach: process users page by page to reduce memory usage */
@@ -52,6 +53,12 @@ export declare class Queue {
52
53
  private tick;
53
54
  enqueueDelete(baId: string, priority?: boolean, source?: TaskSource, reconcileId?: string): void;
54
55
  enqueueEnsure(user: BAUser, priority?: boolean, source?: TaskSource, reconcileId?: string): void;
56
+ /**
57
+ * Run ensure (sync user to Payload) immediately without waiting for the queue tick.
58
+ * Used from database hooks so that e.g. magic-link redirect sees the user in Payload
59
+ * before the response is sent. The task remains enqueued for idempotent retry.
60
+ */
61
+ runEnsureNow(user: BAUser): Promise<void>;
55
62
  /** Seed tasks by comparing users page by page (Better-Auth → Payload). */
56
63
  seedFullReconcile(): Promise<void>;
57
64
  start({ reconcileEveryMs, tickMs }?: {
@@ -88,6 +88,21 @@ export class Queue {
88
88
  users
89
89
  };
90
90
  }
91
+ async runEnsure(baUser) {
92
+ const log = this.deps?.log ?? (()=>{});
93
+ const baId = baUser.id;
94
+ // Fetch accounts from Better Auth for this user
95
+ const accounts = await this.deps.internalAdapter.findAccounts(baId);
96
+ log('queue.ensure.accounts', {
97
+ accountCount: accounts?.length ?? 0,
98
+ accounts: accounts?.map((a)=>({
99
+ id: a.id,
100
+ providerId: a.providerId
101
+ })),
102
+ baId
103
+ });
104
+ await this.deps.syncUserToPayload(baUser, accounts);
105
+ }
91
106
  async runTask(t) {
92
107
  const log = this.deps?.log ?? (()=>{});
93
108
  if (t.kind === 'ensure') {
@@ -95,23 +110,10 @@ export class Queue {
95
110
  attempts: t.attempts,
96
111
  baId: t.baId
97
112
  });
98
- // Get user data (either from task or fetch from BA)
99
113
  const baUser = t.baUser ?? {
100
114
  id: t.baId
101
115
  };
102
- // Fetch accounts from Better Auth for this user
103
- const accounts = await this.deps.internalAdapter.findAccounts(t.baId);
104
- // Debug: log what accounts were found
105
- log('queue.ensure.accounts', {
106
- accountCount: accounts?.length ?? 0,
107
- accounts: accounts?.map((a)=>({
108
- id: a.id,
109
- providerId: a.providerId
110
- })),
111
- baId: t.baId
112
- });
113
- // Sync user with accounts to Payload
114
- await this.deps.syncUserToPayload(baUser, accounts);
116
+ await this.runEnsure(baUser);
115
117
  return;
116
118
  }
117
119
  // delete
@@ -264,6 +266,13 @@ export class Queue {
264
266
  source
265
267
  }, priority);
266
268
  }
269
+ /**
270
+ * Run ensure (sync user to Payload) immediately without waiting for the queue tick.
271
+ * Used from database hooks so that e.g. magic-link redirect sees the user in Payload
272
+ * before the response is sent. The task remains enqueued for idempotent retry.
273
+ */ async runEnsureNow(user) {
274
+ await this.runEnsure(user);
275
+ }
267
276
  /** Seed tasks by comparing users page by page (Better-Auth → Payload). */ async seedFullReconcile() {
268
277
  const log = this.deps?.log ?? (()=>{});
269
278
  this.lastSeedAt = new Date().toISOString();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/better-auth/reconcile-queue.ts"],"sourcesContent":["import type { AuthContext } from 'better-auth'\n\n// src/reconcile-queue.ts\nimport type { BAUser, BetterAuthAccount, BetterAuthUser, PayloadUser } from './sources'\n\nexport interface QueueDeps {\n /** Prefix for Better Auth collections */\n collectionPrefix: string\n\n /** Delete user and associated BA collection entries from Payload */\n deleteUserFromPayload: (baId: string) => Promise<void>\n\n /** Better Auth internal adapter for fetching users and accounts */\n internalAdapter: AuthContext['internalAdapter']\n\n // Paginated loaders (efficient processing)\n listPayloadUsersPage: (\n limit: number,\n page: number,\n ) => Promise<{ hasNextPage: boolean; total: number; users: PayloadUser[] }>\n\n // Logging\n log?: (msg: string, extra?: unknown) => void\n\n /** Map BA user to Payload user data */\n mapUserToPayload: (baUser: BetterAuthUser) => Record<string, unknown>\n\n // Policy\n prunePayloadOrphans?: boolean // default: false\n\n /** Sync user and BA collection entries to Payload */\n syncUserToPayload: (baUser: BAUser, accounts?: BetterAuthAccount[]) => Promise<void>\n}\n\nexport type TaskSource = 'full-reconcile' | 'user-operation'\n\n// Bootstrap options interface\nexport interface InitOptions {\n forceReset?: boolean\n reconcileEveryMs?: number\n runOnBoot?: boolean\n tickMs?: number\n}\n\ntype Task =\n | {\n attempts: number\n baId: string\n baUser?: BAUser\n kind: 'ensure'\n nextAt: number\n reconcileId?: string\n source: TaskSource\n }\n | {\n attempts: number\n baId: string\n kind: 'delete'\n nextAt: number\n reconcileId?: string\n source: TaskSource\n }\n\nconst KEY = (t: Task) => `${t.kind}:${t.baId}`\n\nexport class Queue {\n private deps!: QueueDeps\n private failed = 0\n private keys = new Map<string, Task>()\n private lastError: null | string = null\n private lastSeedAt: null | string = null\n private processed = 0\n\n private processing = false\n private q: Task[] = []\n private reconcileEveryMs = 30 * 60_000 // default 30 minutes\n private reconcileTimeout: NodeJS.Timeout | null = null\n private reconciling = false\n\n private tickTimer: NodeJS.Timeout | null = null\n\n constructor(deps: QueueDeps, opts: InitOptions = {}) {\n this.deps = deps\n\n // Start timers but don't run reconcile immediately\n this.start({\n reconcileEveryMs: opts?.reconcileEveryMs ?? 30 * 60_000,\n tickMs: opts?.tickMs ?? 1000,\n })\n\n // Defer the initial reconcile to avoid circular dependency issues\n if (opts?.runOnBoot ?? true) {\n // Use setTimeout instead of queueMicrotask to give more time for initialization\n setTimeout(() => {\n this.seedFullReconcile().catch(\n (err) => this.deps.log && this.deps.log('[reconcile] seed failed', err),\n )\n }, 2000) // 2 second delay to allow Better Auth and Payload to fully initialize\n }\n }\n\n private bumpFront(task: Task) {\n this.q = [task, ...this.q.filter((t) => t !== task)]\n }\n\n /** Clear all full-reconcile tasks from the queue, preserving user-operation tasks */\n private clearFullReconcileTasks() {\n const log = this.deps?.log ?? (() => {})\n const beforeCount = this.q.length\n const fullReconcileCount = this.q.filter((t) => t.source === 'full-reconcile').length\n\n // Remove full-reconcile tasks from queue and keys map\n this.q = this.q.filter((task) => {\n if (task.source === 'full-reconcile') {\n this.keys.delete(KEY(task))\n return false\n }\n return true\n })\n\n const afterCount = this.q.length\n log('reconcile.clear-previous', {\n afterCount,\n beforeCount,\n clearedFullReconcile: fullReconcileCount,\n preservedUserOps: afterCount,\n })\n }\n\n // ——— Internals ———\n private enqueue(task: Task, priority: boolean) {\n const k = KEY(task)\n const existing = this.keys.get(k)\n if (existing) {\n if (task.kind === 'ensure' && existing.kind === 'ensure' && !existing.baUser && task.baUser) {\n existing.baUser = task.baUser\n }\n if (priority) {\n this.bumpFront(existing)\n }\n return\n }\n if (priority) {\n this.q.unshift(task)\n } else {\n this.q.push(task)\n }\n this.keys.set(k, task)\n }\n\n private async listBAUsersPage({ limit, offset }: { limit: number; offset: number }) {\n // sort by newest (used) first\n // when a delete is happening in the meantime, this will lead to some users not being listed (as the index changes)\n // TODO: fix this by maintaining a delete list.\n const total = await this.deps.internalAdapter.countTotalUsers()\n const users = await this.deps.internalAdapter.listUsers(limit, offset, {\n direction: 'desc',\n field: 'updatedAt',\n })\n return { total, users }\n }\n\n private async runTask(t: Task) {\n const log = this.deps?.log ?? (() => {})\n if (t.kind === 'ensure') {\n log('queue.ensure', { attempts: t.attempts, baId: t.baId })\n\n // Get user data (either from task or fetch from BA)\n const baUser = t.baUser ?? { id: t.baId }\n\n // Fetch accounts from Better Auth for this user\n const accounts = await this.deps.internalAdapter.findAccounts(t.baId)\n\n // Debug: log what accounts were found\n log('queue.ensure.accounts', {\n accountCount: accounts?.length ?? 0,\n accounts: accounts?.map((a) => ({ id: a.id, providerId: a.providerId })),\n baId: t.baId,\n })\n\n // Sync user with accounts to Payload\n await this.deps.syncUserToPayload(baUser, accounts as BetterAuthAccount[])\n return\n }\n // delete\n log('queue.delete', { attempts: t.attempts, baId: t.baId })\n await this.deps.deleteUserFromPayload(t.baId)\n }\n private scheduleNextReconcile() {\n if (this.reconcileTimeout) {\n clearTimeout(this.reconcileTimeout)\n }\n\n this.reconcileTimeout = setTimeout(async () => {\n if (!this.reconciling) {\n this.reconciling = true\n try {\n await this.seedFullReconcile()\n } catch (_error) {\n // Error is already logged in seedFullReconcile\n } finally {\n this.reconciling = false\n // Schedule the next reconcile after this one completes\n this.scheduleNextReconcile()\n }\n }\n }, this.reconcileEveryMs)\n\n // Optional unref for Node.js environments to prevent keeping process alive\n if ('unref' in this.reconcileTimeout && typeof this.reconcileTimeout.unref === 'function') {\n this.reconcileTimeout.unref()\n }\n }\n\n /** Paginated approach: process users page by page to reduce memory usage */\n private async seedFullReconcilePaginated(reconcileId: string) {\n const log = this.deps?.log ?? (() => {})\n const pageSize = 500\n let baIdSet: null | Set<string> = null\n\n // If we need to prune orphans, we need to collect all BA user IDs\n if (this.deps.prunePayloadOrphans) {\n baIdSet = new Set<string>()\n let baOffset = 0\n let baTotal = 0\n\n do {\n const { total, users: baUsers } = await this.listBAUsersPage({\n limit: pageSize,\n offset: baOffset,\n })\n baTotal = total\n\n // Enqueue ensure tasks for this page with full-reconcile source\n for (const u of baUsers) {\n this.enqueueEnsure(u, false, 'full-reconcile', reconcileId)\n baIdSet.add(u.id)\n }\n\n baOffset += baUsers.length\n log('reconcile.seed.ba-page', { processed: baOffset, reconcileId, total: baTotal })\n } while (baOffset < baTotal)\n } else {\n // If not pruning, we can process BA users page by page without storing IDs\n let baOffset = 0\n let baTotal = 0\n\n do {\n // TODO: make sure that we dont go past the window through deletes happening\n // (As a user deletes, the total window size becomes smaller)\n const { total, users: baUsers } = await this.listBAUsersPage({\n limit: pageSize,\n offset: baOffset,\n })\n baTotal = total\n\n // Enqueue ensure tasks for this page with full-reconcile source\n for (const u of baUsers) {\n this.enqueueEnsure(u, false, 'full-reconcile', reconcileId)\n }\n\n baOffset += baUsers.length\n log('reconcile.seed.ba-page', { processed: baOffset, reconcileId, total: baTotal })\n } while (baOffset < baTotal)\n }\n\n // Process Payload users page by page for orphan pruning\n if (this.deps.prunePayloadOrphans && baIdSet) {\n let payloadPage = 1\n let hasNextPage = true\n\n while (hasNextPage) {\n const { hasNextPage: nextPage, users: pUsers } = await this.deps.listPayloadUsersPage(\n pageSize,\n payloadPage,\n )\n hasNextPage = nextPage\n\n for (const pu of pUsers) {\n const baId = pu.baUserId?.toString()\n if (baId && !baIdSet.has(baId)) {\n this.enqueueDelete(baId, false, 'full-reconcile', reconcileId)\n }\n }\n\n payloadPage++\n log('reconcile.seed.payload-page', { page: payloadPage - 1, reconcileId })\n }\n }\n }\n\n private async tick() {\n if (this.processing) {\n return\n }\n const now = Date.now()\n const idx = this.q.findIndex((t) => t.nextAt <= now)\n if (idx === -1) {\n return\n }\n const task = this.q[idx]\n this.processing = true\n try {\n await this.runTask(task)\n this.q.splice(idx, 1)\n this.keys.delete(KEY(task))\n this.processed++\n } catch (e: unknown) {\n this.failed++\n this.lastError = e instanceof Error ? e.message : String(e)\n task.attempts += 1\n const delay =\n Math.min(60_000, Math.pow(2, task.attempts) * 1000) + Math.floor(Math.random() * 500)\n task.nextAt = now + delay\n } finally {\n this.processing = false\n }\n }\n\n enqueueDelete(\n baId: string,\n priority = false,\n source: TaskSource = 'user-operation',\n reconcileId?: string,\n ) {\n this.enqueue(\n { attempts: 0, baId, kind: 'delete', nextAt: Date.now(), reconcileId, source },\n priority,\n )\n }\n\n // ——— Public enqueue API ———\n enqueueEnsure(\n user: BAUser,\n priority = false,\n source: TaskSource = 'user-operation',\n reconcileId?: string,\n ) {\n this.enqueue(\n {\n attempts: 0,\n baId: user.id,\n baUser: user,\n kind: 'ensure',\n nextAt: Date.now(),\n reconcileId,\n source,\n },\n priority,\n )\n }\n\n /** Seed tasks by comparing users page by page (Better-Auth → Payload). */\n async seedFullReconcile() {\n const log = this.deps?.log ?? (() => {})\n this.lastSeedAt = new Date().toISOString()\n const reconcileId = `reconcile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`\n\n log('reconcile.seed.start', { reconcileId })\n\n // Clear all previous full-reconcile tasks, but preserve user-operation tasks\n this.clearFullReconcileTasks()\n\n await this.seedFullReconcilePaginated(reconcileId)\n\n log('reconcile.seed.done', this.status())\n }\n\n start({ reconcileEveryMs = 30 * 60_000, tickMs = 1000 } = {}) {\n this.reconcileEveryMs = reconcileEveryMs\n\n if (!this.tickTimer) {\n this.tickTimer = setInterval(() => this.tick(), tickMs)\n // Optional unref for Node.js environments to prevent keeping process alive\n if ('unref' in this.tickTimer && typeof this.tickTimer.unref === 'function') {\n this.tickTimer.unref()\n }\n }\n\n // Schedule the first reconcile\n this.scheduleNextReconcile()\n }\n\n status() {\n const userOpCount = this.q.filter((t) => t.source === 'user-operation').length\n const fullReconcileCount = this.q.filter((t) => t.source === 'full-reconcile').length\n\n return {\n failed: this.failed,\n fullReconcileTasks: fullReconcileCount,\n lastError: this.lastError,\n lastSeedAt: this.lastSeedAt,\n processed: this.processed,\n processing: this.processing,\n queueSize: this.q.length,\n reconciling: this.reconciling,\n sampleKeys: Array.from(this.keys.keys()).slice(0, 50),\n userOperationTasks: userOpCount,\n }\n }\n}\n"],"names":["KEY","t","kind","baId","Queue","deps","failed","keys","Map","lastError","lastSeedAt","processed","processing","q","reconcileEveryMs","reconcileTimeout","reconciling","tickTimer","opts","start","tickMs","runOnBoot","setTimeout","seedFullReconcile","catch","err","log","bumpFront","task","filter","clearFullReconcileTasks","beforeCount","length","fullReconcileCount","source","delete","afterCount","clearedFullReconcile","preservedUserOps","enqueue","priority","k","existing","get","baUser","unshift","push","set","listBAUsersPage","limit","offset","total","internalAdapter","countTotalUsers","users","listUsers","direction","field","runTask","attempts","id","accounts","findAccounts","accountCount","map","a","providerId","syncUserToPayload","deleteUserFromPayload","scheduleNextReconcile","clearTimeout","_error","unref","seedFullReconcilePaginated","reconcileId","pageSize","baIdSet","prunePayloadOrphans","Set","baOffset","baTotal","baUsers","u","enqueueEnsure","add","payloadPage","hasNextPage","nextPage","pUsers","listPayloadUsersPage","pu","baUserId","toString","has","enqueueDelete","page","tick","now","Date","idx","findIndex","nextAt","splice","e","Error","message","String","delay","Math","min","pow","floor","random","user","toISOString","substr","status","setInterval","userOpCount","fullReconcileTasks","queueSize","sampleKeys","Array","from","slice","userOperationTasks"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/better-auth/reconcile-queue.ts"],"sourcesContent":["import type { AuthContext } from 'better-auth'\n\n// src/reconcile-queue.ts\nimport type { BAUser, BetterAuthAccount, BetterAuthUser, PayloadUser } from './sources'\n\nexport interface QueueDeps {\n /** Prefix for Better Auth collections */\n collectionPrefix: string\n\n /** Delete user and associated BA collection entries from Payload */\n deleteUserFromPayload: (baId: string) => Promise<void>\n\n /** Better Auth internal adapter for fetching users and accounts */\n internalAdapter: AuthContext['internalAdapter']\n\n // Paginated loaders (efficient processing)\n listPayloadUsersPage: (\n limit: number,\n page: number,\n ) => Promise<{ hasNextPage: boolean; total: number; users: PayloadUser[] }>\n\n // Logging\n log?: (msg: string, extra?: unknown) => void\n\n /** Map BA user to Payload user data */\n mapUserToPayload: (baUser: BetterAuthUser) => Record<string, unknown>\n\n // Policy\n prunePayloadOrphans?: boolean // default: false\n\n /** Sync user and BA collection entries to Payload */\n syncUserToPayload: (baUser: BAUser, accounts?: BetterAuthAccount[]) => Promise<void>\n}\n\nexport type TaskSource = 'full-reconcile' | 'user-operation'\n\n// Bootstrap options interface\nexport interface InitOptions {\n forceReset?: boolean\n reconcileEveryMs?: number\n runOnBoot?: boolean\n tickMs?: number\n}\n\ntype Task =\n | {\n attempts: number\n baId: string\n baUser?: BAUser\n kind: 'ensure'\n nextAt: number\n reconcileId?: string\n source: TaskSource\n }\n | {\n attempts: number\n baId: string\n kind: 'delete'\n nextAt: number\n reconcileId?: string\n source: TaskSource\n }\n\nconst KEY = (t: Task) => `${t.kind}:${t.baId}`\n\nexport class Queue {\n private deps!: QueueDeps\n private failed = 0\n private keys = new Map<string, Task>()\n private lastError: null | string = null\n private lastSeedAt: null | string = null\n private processed = 0\n\n private processing = false\n private q: Task[] = []\n private reconcileEveryMs = 30 * 60_000 // default 30 minutes\n private reconcileTimeout: NodeJS.Timeout | null = null\n private reconciling = false\n\n private tickTimer: NodeJS.Timeout | null = null\n\n constructor(deps: QueueDeps, opts: InitOptions = {}) {\n this.deps = deps\n\n // Start timers but don't run reconcile immediately\n this.start({\n reconcileEveryMs: opts?.reconcileEveryMs ?? 30 * 60_000,\n tickMs: opts?.tickMs ?? 1000,\n })\n\n // Defer the initial reconcile to avoid circular dependency issues\n if (opts?.runOnBoot ?? true) {\n // Use setTimeout instead of queueMicrotask to give more time for initialization\n setTimeout(() => {\n this.seedFullReconcile().catch(\n (err) => this.deps.log && this.deps.log('[reconcile] seed failed', err),\n )\n }, 2000) // 2 second delay to allow Better Auth and Payload to fully initialize\n }\n }\n\n private bumpFront(task: Task) {\n this.q = [task, ...this.q.filter((t) => t !== task)]\n }\n\n /** Clear all full-reconcile tasks from the queue, preserving user-operation tasks */\n private clearFullReconcileTasks() {\n const log = this.deps?.log ?? (() => {})\n const beforeCount = this.q.length\n const fullReconcileCount = this.q.filter((t) => t.source === 'full-reconcile').length\n\n // Remove full-reconcile tasks from queue and keys map\n this.q = this.q.filter((task) => {\n if (task.source === 'full-reconcile') {\n this.keys.delete(KEY(task))\n return false\n }\n return true\n })\n\n const afterCount = this.q.length\n log('reconcile.clear-previous', {\n afterCount,\n beforeCount,\n clearedFullReconcile: fullReconcileCount,\n preservedUserOps: afterCount,\n })\n }\n\n // ——— Internals ———\n private enqueue(task: Task, priority: boolean) {\n const k = KEY(task)\n const existing = this.keys.get(k)\n if (existing) {\n if (task.kind === 'ensure' && existing.kind === 'ensure' && !existing.baUser && task.baUser) {\n existing.baUser = task.baUser\n }\n if (priority) {\n this.bumpFront(existing)\n }\n return\n }\n if (priority) {\n this.q.unshift(task)\n } else {\n this.q.push(task)\n }\n this.keys.set(k, task)\n }\n\n private async listBAUsersPage({ limit, offset }: { limit: number; offset: number }) {\n // sort by newest (used) first\n // when a delete is happening in the meantime, this will lead to some users not being listed (as the index changes)\n // TODO: fix this by maintaining a delete list.\n const total = await this.deps.internalAdapter.countTotalUsers()\n const users = await this.deps.internalAdapter.listUsers(limit, offset, {\n direction: 'desc',\n field: 'updatedAt',\n })\n return { total, users }\n }\n\n private async runEnsure(baUser: BAUser): Promise<void> {\n const log = this.deps?.log ?? (() => {})\n const baId = baUser.id\n\n // Fetch accounts from Better Auth for this user\n const accounts = await this.deps.internalAdapter.findAccounts(baId)\n\n log('queue.ensure.accounts', {\n accountCount: accounts?.length ?? 0,\n accounts: accounts?.map((a) => ({ id: a.id, providerId: a.providerId })),\n baId,\n })\n\n await this.deps.syncUserToPayload(baUser, accounts as BetterAuthAccount[])\n }\n\n private async runTask(t: Task) {\n const log = this.deps?.log ?? (() => {})\n if (t.kind === 'ensure') {\n log('queue.ensure', { attempts: t.attempts, baId: t.baId })\n const baUser = t.baUser ?? { id: t.baId }\n await this.runEnsure(baUser)\n return\n }\n // delete\n log('queue.delete', { attempts: t.attempts, baId: t.baId })\n await this.deps.deleteUserFromPayload(t.baId)\n }\n\n private scheduleNextReconcile() {\n if (this.reconcileTimeout) {\n clearTimeout(this.reconcileTimeout)\n }\n\n this.reconcileTimeout = setTimeout(async () => {\n if (!this.reconciling) {\n this.reconciling = true\n try {\n await this.seedFullReconcile()\n } catch (_error) {\n // Error is already logged in seedFullReconcile\n } finally {\n this.reconciling = false\n // Schedule the next reconcile after this one completes\n this.scheduleNextReconcile()\n }\n }\n }, this.reconcileEveryMs)\n\n // Optional unref for Node.js environments to prevent keeping process alive\n if ('unref' in this.reconcileTimeout && typeof this.reconcileTimeout.unref === 'function') {\n this.reconcileTimeout.unref()\n }\n }\n /** Paginated approach: process users page by page to reduce memory usage */\n private async seedFullReconcilePaginated(reconcileId: string) {\n const log = this.deps?.log ?? (() => {})\n const pageSize = 500\n let baIdSet: null | Set<string> = null\n\n // If we need to prune orphans, we need to collect all BA user IDs\n if (this.deps.prunePayloadOrphans) {\n baIdSet = new Set<string>()\n let baOffset = 0\n let baTotal = 0\n\n do {\n const { total, users: baUsers } = await this.listBAUsersPage({\n limit: pageSize,\n offset: baOffset,\n })\n baTotal = total\n\n // Enqueue ensure tasks for this page with full-reconcile source\n for (const u of baUsers) {\n this.enqueueEnsure(u, false, 'full-reconcile', reconcileId)\n baIdSet.add(u.id)\n }\n\n baOffset += baUsers.length\n log('reconcile.seed.ba-page', { processed: baOffset, reconcileId, total: baTotal })\n } while (baOffset < baTotal)\n } else {\n // If not pruning, we can process BA users page by page without storing IDs\n let baOffset = 0\n let baTotal = 0\n\n do {\n // TODO: make sure that we dont go past the window through deletes happening\n // (As a user deletes, the total window size becomes smaller)\n const { total, users: baUsers } = await this.listBAUsersPage({\n limit: pageSize,\n offset: baOffset,\n })\n baTotal = total\n\n // Enqueue ensure tasks for this page with full-reconcile source\n for (const u of baUsers) {\n this.enqueueEnsure(u, false, 'full-reconcile', reconcileId)\n }\n\n baOffset += baUsers.length\n log('reconcile.seed.ba-page', { processed: baOffset, reconcileId, total: baTotal })\n } while (baOffset < baTotal)\n }\n\n // Process Payload users page by page for orphan pruning\n if (this.deps.prunePayloadOrphans && baIdSet) {\n let payloadPage = 1\n let hasNextPage = true\n\n while (hasNextPage) {\n const { hasNextPage: nextPage, users: pUsers } = await this.deps.listPayloadUsersPage(\n pageSize,\n payloadPage,\n )\n hasNextPage = nextPage\n\n for (const pu of pUsers) {\n const baId = pu.baUserId?.toString()\n if (baId && !baIdSet.has(baId)) {\n this.enqueueDelete(baId, false, 'full-reconcile', reconcileId)\n }\n }\n\n payloadPage++\n log('reconcile.seed.payload-page', { page: payloadPage - 1, reconcileId })\n }\n }\n }\n\n private async tick() {\n if (this.processing) {\n return\n }\n const now = Date.now()\n const idx = this.q.findIndex((t) => t.nextAt <= now)\n if (idx === -1) {\n return\n }\n const task = this.q[idx]\n this.processing = true\n try {\n await this.runTask(task)\n this.q.splice(idx, 1)\n this.keys.delete(KEY(task))\n this.processed++\n } catch (e: unknown) {\n this.failed++\n this.lastError = e instanceof Error ? e.message : String(e)\n task.attempts += 1\n const delay =\n Math.min(60_000, Math.pow(2, task.attempts) * 1000) + Math.floor(Math.random() * 500)\n task.nextAt = now + delay\n } finally {\n this.processing = false\n }\n }\n\n enqueueDelete(\n baId: string,\n priority = false,\n source: TaskSource = 'user-operation',\n reconcileId?: string,\n ) {\n this.enqueue(\n { attempts: 0, baId, kind: 'delete', nextAt: Date.now(), reconcileId, source },\n priority,\n )\n }\n\n // ——— Public enqueue API ———\n enqueueEnsure(\n user: BAUser,\n priority = false,\n source: TaskSource = 'user-operation',\n reconcileId?: string,\n ) {\n this.enqueue(\n {\n attempts: 0,\n baId: user.id,\n baUser: user,\n kind: 'ensure',\n nextAt: Date.now(),\n reconcileId,\n source,\n },\n priority,\n )\n }\n\n /**\n * Run ensure (sync user to Payload) immediately without waiting for the queue tick.\n * Used from database hooks so that e.g. magic-link redirect sees the user in Payload\n * before the response is sent. The task remains enqueued for idempotent retry.\n */\n async runEnsureNow(user: BAUser): Promise<void> {\n await this.runEnsure(user)\n }\n\n /** Seed tasks by comparing users page by page (Better-Auth → Payload). */\n async seedFullReconcile() {\n const log = this.deps?.log ?? (() => {})\n this.lastSeedAt = new Date().toISOString()\n const reconcileId = `reconcile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`\n\n log('reconcile.seed.start', { reconcileId })\n\n // Clear all previous full-reconcile tasks, but preserve user-operation tasks\n this.clearFullReconcileTasks()\n\n await this.seedFullReconcilePaginated(reconcileId)\n\n log('reconcile.seed.done', this.status())\n }\n\n start({ reconcileEveryMs = 30 * 60_000, tickMs = 1000 } = {}) {\n this.reconcileEveryMs = reconcileEveryMs\n\n if (!this.tickTimer) {\n this.tickTimer = setInterval(() => this.tick(), tickMs)\n // Optional unref for Node.js environments to prevent keeping process alive\n if ('unref' in this.tickTimer && typeof this.tickTimer.unref === 'function') {\n this.tickTimer.unref()\n }\n }\n\n // Schedule the first reconcile\n this.scheduleNextReconcile()\n }\n\n status() {\n const userOpCount = this.q.filter((t) => t.source === 'user-operation').length\n const fullReconcileCount = this.q.filter((t) => t.source === 'full-reconcile').length\n\n return {\n failed: this.failed,\n fullReconcileTasks: fullReconcileCount,\n lastError: this.lastError,\n lastSeedAt: this.lastSeedAt,\n processed: this.processed,\n processing: this.processing,\n queueSize: this.q.length,\n reconciling: this.reconciling,\n sampleKeys: Array.from(this.keys.keys()).slice(0, 50),\n userOperationTasks: userOpCount,\n }\n }\n}\n"],"names":["KEY","t","kind","baId","Queue","deps","failed","keys","Map","lastError","lastSeedAt","processed","processing","q","reconcileEveryMs","reconcileTimeout","reconciling","tickTimer","opts","start","tickMs","runOnBoot","setTimeout","seedFullReconcile","catch","err","log","bumpFront","task","filter","clearFullReconcileTasks","beforeCount","length","fullReconcileCount","source","delete","afterCount","clearedFullReconcile","preservedUserOps","enqueue","priority","k","existing","get","baUser","unshift","push","set","listBAUsersPage","limit","offset","total","internalAdapter","countTotalUsers","users","listUsers","direction","field","runEnsure","id","accounts","findAccounts","accountCount","map","a","providerId","syncUserToPayload","runTask","attempts","deleteUserFromPayload","scheduleNextReconcile","clearTimeout","_error","unref","seedFullReconcilePaginated","reconcileId","pageSize","baIdSet","prunePayloadOrphans","Set","baOffset","baTotal","baUsers","u","enqueueEnsure","add","payloadPage","hasNextPage","nextPage","pUsers","listPayloadUsersPage","pu","baUserId","toString","has","enqueueDelete","page","tick","now","Date","idx","findIndex","nextAt","splice","e","Error","message","String","delay","Math","min","pow","floor","random","user","runEnsureNow","toISOString","substr","status","setInterval","userOpCount","fullReconcileTasks","queueSize","sampleKeys","Array","from","slice","userOperationTasks"],"mappings":"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"}
@@ -161,8 +161,8 @@ const NONCE_TTL_SECONDS = 5 * 60 // 5 minutes in seconds
161
161
  if (existing.docs[0]) {
162
162
  return {
163
163
  user: {
164
- collection: 'users',
165
- ...existing.docs[0]
164
+ ...existing.docs[0],
165
+ collection: 'users'
166
166
  }
167
167
  };
168
168
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/collections/Users/index.ts"],"sourcesContent":["import type {\n Access,\n CollectionConfig,\n CollectionSlug,\n Field,\n Payload,\n PayloadRequest,\n} from 'payload'\n\nimport type { SecondaryStorage } from '../../storage/types'\n\nimport { type CryptoSignature, verifyCanonical } from '../../better-auth/crypto-shared'\nimport { NONCE_PREFIX, SESSION_COOKIE_NAME_KEY } from '../../storage/keys'\n\nconst INTERNAL_SECRET = process.env.BA_TO_PAYLOAD_SECRET!\nconst NONCE_TTL_SECONDS = 5 * 60 // 5 minutes in seconds\n\n/**\n * Extract ALL session tokens from cookies that match the expected cookie name.\n * This handles cases where multiple cookies with the same name exist (different paths/domains).\n * Returns tokens in order they appear (first = most recent typically).\n */\nasync function extractAllSessionTokens(\n headers: Headers,\n storage: SecondaryStorage,\n): Promise<string[]> {\n const cookieHeader = headers.get('cookie')\n if (!cookieHeader) {\n return []\n }\n\n // Get cookie name from storage (set by Better Auth plugin)\n const sessionCookieName =\n (await storage.get(SESSION_COOKIE_NAME_KEY)) ?? 'better-auth.session_token'\n\n // Parse ALL cookies, keeping duplicates\n const tokens: string[] = []\n for (const cookie of cookieHeader.split(';')) {\n const trimmed = cookie.trim()\n const eqIndex = trimmed.indexOf('=')\n if (eqIndex === -1) {\n continue\n }\n\n const key = trimmed.slice(0, eqIndex)\n const value = trimmed.slice(eqIndex + 1)\n\n if (key === sessionCookieName && value) {\n try {\n tokens.push(decodeURIComponent(value))\n } catch {\n // Skip malformed cookies\n }\n }\n }\n\n return tokens\n}\n\n/**\n * Better Auth session data format in secondaryStorage.\n * Better Auth stores sessions with this structure when secondaryStorage is configured.\n */\ninterface BetterAuthStoredSession {\n session: {\n expiresAt: Date | string\n id: string\n userId: string\n }\n user: {\n [key: string]: unknown\n id: string\n }\n}\n\n/**\n * Create the signature verification function.\n * Uses storage for nonce checking to prevent replay attacks.\n */\nfunction createSigVerifier(storage: SecondaryStorage) {\n return async function verifySig(\n req: { context: { baBody?: unknown; baSig?: CryptoSignature } } & PayloadRequest,\n ): Promise<boolean> {\n const sig = req.context.baSig\n const body = req.context.baBody\n if (!sig || !body) {\n return false\n }\n\n // Verify HMAC signature (includes timestamp check)\n const ok = verifyCanonical(body, sig, INTERNAL_SECRET)\n if (!ok) {\n return false\n }\n\n // Check nonce for replay protection\n const alreadyUsed = await storage.get(NONCE_PREFIX + sig.nonce)\n if (alreadyUsed !== null) {\n return false // replay detected\n }\n\n return true\n }\n}\n\n/**\n * Mark a nonce as used via secondary storage.\n */\nasync function markNonceUsed(storage: SecondaryStorage, nonce: string): Promise<void> {\n await storage.set(NONCE_PREFIX + nonce, '1', NONCE_TTL_SECONDS)\n}\n\nexport interface ExtendUsersCollectionOptions {\n /**\n * Prefix for Better Auth collection slugs (default: '__better_auth')\n */\n collectionPrefix?: string\n /**\n * Secondary storage for session validation and nonce protection.\n * Sessions are read directly from storage - no HTTP calls to Better Auth.\n *\n * This must be the same storage instance passed to the Better Auth plugin,\n * as Better Auth writes sessions to this storage via secondaryStorage.\n */\n storage: SecondaryStorage\n}\n\n/**\n * Field-level access that only allows BA sync agent to update.\n * Checks for BA signature in request context.\n */\nconst baOnlyFieldAccess = {\n // BA sync agent sets baSig in context - only allow update if present\n update: ({ req }: { req: PayloadRequest }) => Boolean(req.context?.baSig),\n}\n\n/**\n * Better Auth fields to add to the users collection.\n * Includes a polymorphic relationship field to BA collections.\n */\nfunction getBetterAuthFields<TCollectionSlug extends string>(\n emailPasswordSlug: TCollectionSlug,\n magicLinkSlug: TCollectionSlug,\n): Field[] {\n return [\n {\n name: 'baUserId',\n type: 'text',\n access: baOnlyFieldAccess,\n admin: { readOnly: true },\n index: true,\n unique: true,\n },\n {\n // Polymorphic relationship: one field can reference multiple BA collections\n // A user can have multiple auth methods (e.g., email/password AND magic-link)\n name: 'betterAuthAccounts',\n type: 'relationship',\n access: baOnlyFieldAccess,\n admin: { readOnly: true },\n hasMany: true,\n relationTo: [emailPasswordSlug, magicLinkSlug] as CollectionSlug[],\n },\n ]\n}\n\n/**\n * Validate a session token and return the user ID if valid.\n * Returns null if token is invalid or expired.\n */\nasync function validateSessionToken(\n fullToken: string,\n storage: SecondaryStorage,\n): Promise<null | string> {\n // Better Auth cookie format: \"token.signature\" - we need just the token part\n const token = fullToken.split('.')[0]\n if (!token) {\n return null\n }\n\n // Read session directly from storage (written by Better Auth)\n const cached = await storage.get(token)\n if (!cached) {\n return null\n }\n\n try {\n const storedSession = JSON.parse(cached) as BetterAuthStoredSession\n // Check expiration - Better Auth stores expiresAt as ISO string or Date\n const expiresAt =\n typeof storedSession.session.expiresAt === 'string'\n ? new Date(storedSession.session.expiresAt).getTime()\n : new Date(storedSession.session.expiresAt).getTime()\n\n if (expiresAt > Date.now()) {\n return storedSession.session.userId\n }\n } catch {\n // Invalid JSON in storage\n }\n\n return null\n}\n\n/**\n * Create the Better Auth authentication strategy.\n * Tries all session cookies until finding a valid, non-expired session.\n */\nfunction createBetterAuthStrategy(storage: SecondaryStorage, _prefix: string) {\n return {\n name: 'better-auth',\n authenticate: async ({ headers, payload }: { headers: Headers; payload: Payload }) => {\n // Get ALL session tokens from cookies (handles duplicates)\n const tokens = await extractAllSessionTokens(headers, storage)\n if (tokens.length === 0) {\n return { user: null }\n }\n\n // Try each token until we find a valid session\n for (const fullToken of tokens) {\n const baUserId = await validateSessionToken(fullToken, storage)\n if (!baUserId) {\n continue // Try next token\n }\n\n // Find user by baUserId\n const existing = await payload.find({\n collection: 'users',\n limit: 1,\n where: { baUserId: { equals: baUserId } },\n })\n\n if (existing.docs[0]) {\n return { user: { collection: 'users' as const, ...existing.docs[0] } }\n }\n // User not found in Payload, try next token\n }\n\n return { user: null }\n },\n }\n}\n\n/**\n * Create composable access control that OR's BA sync access with developer access.\n * Handles both sync and async access functions from developers.\n */\nfunction createComposableAccess(\n storage: SecondaryStorage,\n developerAccess: Access | undefined,\n operation: 'create' | 'delete' | 'read' | 'update',\n) {\n const verifySig = createSigVerifier(storage)\n\n return async (args: Parameters<Access>[0]) => {\n // BA sync agent always has access\n const sigOk = await verifySig(args.req)\n if (sigOk) {\n return true\n }\n\n // Fall back to developer's access rules (handles both sync and async)\n if (developerAccess) {\n return await Promise.resolve(developerAccess(args))\n }\n\n // Default behavior by operation\n if (operation === 'read') {\n return Boolean(args.req.user) // authenticated users can read\n }\n return false // deny by default for create/update/delete\n }\n}\n\n/**\n * Extends an existing users collection with Better Auth integration.\n * Merges BA fields, auth strategy, access control, and hooks.\n *\n * @param baseCollection - The developer's existing users collection config (or undefined for minimal)\n * @param options - Extension options including storage\n * @returns Extended collection config with BA integration\n */\nexport function extendUsersCollection(\n baseCollection: CollectionConfig | undefined,\n options: ExtendUsersCollectionOptions,\n): CollectionConfig {\n const { collectionPrefix = '__better_auth', storage } = options\n const verifySig = createSigVerifier(storage)\n\n // Compute BA collection slugs\n const emailPasswordSlug = `${collectionPrefix}_email_password`\n const magicLinkSlug = `${collectionPrefix}_magic_link`\n\n // Start with base or minimal collection\n const base: CollectionConfig = baseCollection ?? {\n slug: 'users',\n fields: [],\n }\n\n // Ensure slug is 'users'\n if (base.slug !== 'users') {\n throw new Error('Users collection must have slug \"users\"')\n }\n\n // Get developer's existing access rules\n const developerAccess = base.access ?? {}\n\n // Get developer's existing hooks\n const developerHooks = base.hooks ?? {}\n\n // BA-specific beforeChange hook\n const baBeforeChange = async ({\n data,\n operation,\n originalDoc,\n req,\n }: {\n data: Record<string, unknown>\n operation: 'create' | 'update'\n originalDoc?: Record<string, unknown>\n req: PayloadRequest\n }) => {\n const sig = req.context.baSig as CryptoSignature | undefined\n\n if (operation === 'create' && sig) {\n const expectedBody = { collection: 'users', op: 'create', userId: data.baUserId }\n if (verifyCanonical(expectedBody, sig, INTERNAL_SECRET)) {\n await markNonceUsed(storage, sig.nonce)\n }\n } else if (operation === 'update' && sig) {\n const userId = originalDoc?.baUserId || data.baUserId\n const expectedBody = { collection: 'users', op: 'update', userId }\n if (verifyCanonical(expectedBody, sig, INTERNAL_SECRET)) {\n await markNonceUsed(storage, sig.nonce)\n }\n }\n return data\n }\n\n // BA-specific beforeDelete hook\n const baBeforeDelete = async ({ req }: { id: number | string; req: PayloadRequest }) => {\n const sigOk = await verifySig(req)\n if (sigOk) {\n const sig = req.context.baSig as CryptoSignature | undefined\n if (sig) {\n await markNonceUsed(storage, sig.nonce)\n }\n }\n }\n\n return {\n ...base,\n access: {\n admin: developerAccess.admin ?? (({ req: { user } }) => Boolean(user)),\n create: createComposableAccess(storage, developerAccess.create as Access, 'create'),\n delete: createComposableAccess(storage, developerAccess.delete as Access, 'delete'),\n read: createComposableAccess(storage, developerAccess.read as Access, 'read'),\n update: createComposableAccess(storage, developerAccess.update as Access, 'update'),\n },\n admin: {\n ...base.admin,\n defaultColumns: (() => {\n const cols = base.admin?.defaultColumns ?? ['email', 'createdAt']\n // Add BA accounts column if not already present\n if (!cols.includes('betterAuthAccounts')) {\n return [...cols, 'betterAuthAccounts']\n }\n return cols\n })(),\n useAsTitle: base.admin?.useAsTitle ?? 'email',\n },\n auth: {\n ...(typeof base.auth === 'object' ? base.auth : {}),\n disableLocalStrategy: true,\n strategies: [\n createBetterAuthStrategy(storage, collectionPrefix),\n // Preserve any existing strategies (except local)\n ...((typeof base.auth === 'object' && base.auth.strategies) || []),\n ],\n },\n fields: [\n // Developer's fields first\n ...(base.fields ?? []),\n // BA fields\n ...getBetterAuthFields(emailPasswordSlug, magicLinkSlug),\n ],\n hooks: {\n ...developerHooks,\n beforeChange: [\n // BA hook first\n baBeforeChange,\n // Then developer hooks\n ...(developerHooks.beforeChange ?? []),\n ],\n beforeDelete: [\n // BA hook first\n baBeforeDelete,\n // Then developer hooks\n ...(developerHooks.beforeDelete ?? []),\n ],\n },\n timestamps: base.timestamps ?? true,\n }\n}\n\n/**\n * Creates a minimal users collection with Better Auth integration.\n * Use this when no custom users collection is defined.\n */\nexport function createMinimalUsersCollection(\n options: ExtendUsersCollectionOptions,\n): CollectionConfig {\n return extendUsersCollection(undefined, options)\n}\n"],"names":["verifyCanonical","NONCE_PREFIX","SESSION_COOKIE_NAME_KEY","INTERNAL_SECRET","process","env","BA_TO_PAYLOAD_SECRET","NONCE_TTL_SECONDS","extractAllSessionTokens","headers","storage","cookieHeader","get","sessionCookieName","tokens","cookie","split","trimmed","trim","eqIndex","indexOf","key","slice","value","push","decodeURIComponent","createSigVerifier","verifySig","req","sig","context","baSig","body","baBody","ok","alreadyUsed","nonce","markNonceUsed","set","baOnlyFieldAccess","update","Boolean","getBetterAuthFields","emailPasswordSlug","magicLinkSlug","name","type","access","admin","readOnly","index","unique","hasMany","relationTo","validateSessionToken","fullToken","token","cached","storedSession","JSON","parse","expiresAt","session","Date","getTime","now","userId","createBetterAuthStrategy","_prefix","authenticate","payload","length","user","baUserId","existing","find","collection","limit","where","equals","docs","createComposableAccess","developerAccess","operation","args","sigOk","Promise","resolve","extendUsersCollection","baseCollection","options","collectionPrefix","base","slug","fields","Error","developerHooks","hooks","baBeforeChange","data","originalDoc","expectedBody","op","baBeforeDelete","create","delete","read","defaultColumns","cols","includes","useAsTitle","auth","disableLocalStrategy","strategies","beforeChange","beforeDelete","timestamps","createMinimalUsersCollection","undefined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/collections/Users/index.ts"],"sourcesContent":["import type {\n Access,\n CollectionConfig,\n CollectionSlug,\n Field,\n Payload,\n PayloadRequest,\n} from 'payload'\n\nimport type { SecondaryStorage } from '../../storage/types'\n\nimport { type CryptoSignature, verifyCanonical } from '../../better-auth/crypto-shared'\nimport { NONCE_PREFIX, SESSION_COOKIE_NAME_KEY } from '../../storage/keys'\n\nconst INTERNAL_SECRET = process.env.BA_TO_PAYLOAD_SECRET!\nconst NONCE_TTL_SECONDS = 5 * 60 // 5 minutes in seconds\n\n/**\n * Extract ALL session tokens from cookies that match the expected cookie name.\n * This handles cases where multiple cookies with the same name exist (different paths/domains).\n * Returns tokens in order they appear (first = most recent typically).\n */\nasync function extractAllSessionTokens(\n headers: Headers,\n storage: SecondaryStorage,\n): Promise<string[]> {\n const cookieHeader = headers.get('cookie')\n if (!cookieHeader) {\n return []\n }\n\n // Get cookie name from storage (set by Better Auth plugin)\n const sessionCookieName =\n (await storage.get(SESSION_COOKIE_NAME_KEY)) ?? 'better-auth.session_token'\n\n // Parse ALL cookies, keeping duplicates\n const tokens: string[] = []\n for (const cookie of cookieHeader.split(';')) {\n const trimmed = cookie.trim()\n const eqIndex = trimmed.indexOf('=')\n if (eqIndex === -1) {\n continue\n }\n\n const key = trimmed.slice(0, eqIndex)\n const value = trimmed.slice(eqIndex + 1)\n\n if (key === sessionCookieName && value) {\n try {\n tokens.push(decodeURIComponent(value))\n } catch {\n // Skip malformed cookies\n }\n }\n }\n\n return tokens\n}\n\n/**\n * Better Auth session data format in secondaryStorage.\n * Better Auth stores sessions with this structure when secondaryStorage is configured.\n */\ninterface BetterAuthStoredSession {\n session: {\n expiresAt: Date | string\n id: string\n userId: string\n }\n user: {\n [key: string]: unknown\n id: string\n }\n}\n\n/**\n * Create the signature verification function.\n * Uses storage for nonce checking to prevent replay attacks.\n */\nfunction createSigVerifier(storage: SecondaryStorage) {\n return async function verifySig(\n req: { context: { baBody?: unknown; baSig?: CryptoSignature } } & PayloadRequest,\n ): Promise<boolean> {\n const sig = req.context.baSig\n const body = req.context.baBody\n if (!sig || !body) {\n return false\n }\n\n // Verify HMAC signature (includes timestamp check)\n const ok = verifyCanonical(body, sig, INTERNAL_SECRET)\n if (!ok) {\n return false\n }\n\n // Check nonce for replay protection\n const alreadyUsed = await storage.get(NONCE_PREFIX + sig.nonce)\n if (alreadyUsed !== null) {\n return false // replay detected\n }\n\n return true\n }\n}\n\n/**\n * Mark a nonce as used via secondary storage.\n */\nasync function markNonceUsed(storage: SecondaryStorage, nonce: string): Promise<void> {\n await storage.set(NONCE_PREFIX + nonce, '1', NONCE_TTL_SECONDS)\n}\n\nexport interface ExtendUsersCollectionOptions {\n /**\n * Prefix for Better Auth collection slugs (default: '__better_auth')\n */\n collectionPrefix?: string\n /**\n * Secondary storage for session validation and nonce protection.\n * Sessions are read directly from storage - no HTTP calls to Better Auth.\n *\n * This must be the same storage instance passed to the Better Auth plugin,\n * as Better Auth writes sessions to this storage via secondaryStorage.\n */\n storage: SecondaryStorage\n}\n\n/**\n * Field-level access that only allows BA sync agent to update.\n * Checks for BA signature in request context.\n */\nconst baOnlyFieldAccess = {\n // BA sync agent sets baSig in context - only allow update if present\n update: ({ req }: { req: PayloadRequest }) => Boolean(req.context?.baSig),\n}\n\n/**\n * Better Auth fields to add to the users collection.\n * Includes a polymorphic relationship field to BA collections.\n */\nfunction getBetterAuthFields<TCollectionSlug extends string>(\n emailPasswordSlug: TCollectionSlug,\n magicLinkSlug: TCollectionSlug,\n): Field[] {\n return [\n {\n name: 'baUserId',\n type: 'text',\n access: baOnlyFieldAccess,\n admin: { readOnly: true },\n index: true,\n unique: true,\n },\n {\n // Polymorphic relationship: one field can reference multiple BA collections\n // A user can have multiple auth methods (e.g., email/password AND magic-link)\n name: 'betterAuthAccounts',\n type: 'relationship',\n access: baOnlyFieldAccess,\n admin: { readOnly: true },\n hasMany: true,\n relationTo: [emailPasswordSlug, magicLinkSlug] as CollectionSlug[],\n },\n ]\n}\n\n/**\n * Validate a session token and return the user ID if valid.\n * Returns null if token is invalid or expired.\n */\nasync function validateSessionToken(\n fullToken: string,\n storage: SecondaryStorage,\n): Promise<null | string> {\n // Better Auth cookie format: \"token.signature\" - we need just the token part\n const token = fullToken.split('.')[0]\n if (!token) {\n return null\n }\n\n // Read session directly from storage (written by Better Auth)\n const cached = await storage.get(token)\n if (!cached) {\n return null\n }\n\n try {\n const storedSession = JSON.parse(cached) as BetterAuthStoredSession\n // Check expiration - Better Auth stores expiresAt as ISO string or Date\n const expiresAt =\n typeof storedSession.session.expiresAt === 'string'\n ? new Date(storedSession.session.expiresAt).getTime()\n : new Date(storedSession.session.expiresAt).getTime()\n\n if (expiresAt > Date.now()) {\n return storedSession.session.userId\n }\n } catch {\n // Invalid JSON in storage\n }\n\n return null\n}\n\n/**\n * Create the Better Auth authentication strategy.\n * Tries all session cookies until finding a valid, non-expired session.\n */\nfunction createBetterAuthStrategy(storage: SecondaryStorage, _prefix: string) {\n return {\n name: 'better-auth',\n authenticate: async ({ headers, payload }: { headers: Headers; payload: Payload }) => {\n // Get ALL session tokens from cookies (handles duplicates)\n const tokens = await extractAllSessionTokens(headers, storage)\n if (tokens.length === 0) {\n return { user: null }\n }\n\n // Try each token until we find a valid session\n for (const fullToken of tokens) {\n const baUserId = await validateSessionToken(fullToken, storage)\n if (!baUserId) {\n continue // Try next token\n }\n\n // Find user by baUserId\n const existing = await payload.find({\n collection: 'users',\n limit: 1,\n where: { baUserId: { equals: baUserId } },\n })\n\n if (existing.docs[0]) {\n return { user: { ...existing.docs[0], collection: 'users' as const } }\n }\n // User not found in Payload, try next token\n }\n\n return { user: null }\n },\n }\n}\n\n/**\n * Create composable access control that OR's BA sync access with developer access.\n * Handles both sync and async access functions from developers.\n */\nfunction createComposableAccess(\n storage: SecondaryStorage,\n developerAccess: Access | undefined,\n operation: 'create' | 'delete' | 'read' | 'update',\n) {\n const verifySig = createSigVerifier(storage)\n\n return async (args: Parameters<Access>[0]) => {\n // BA sync agent always has access\n const sigOk = await verifySig(args.req)\n if (sigOk) {\n return true\n }\n\n // Fall back to developer's access rules (handles both sync and async)\n if (developerAccess) {\n return await Promise.resolve(developerAccess(args))\n }\n\n // Default behavior by operation\n if (operation === 'read') {\n return Boolean(args.req.user) // authenticated users can read\n }\n return false // deny by default for create/update/delete\n }\n}\n\n/**\n * Extends an existing users collection with Better Auth integration.\n * Merges BA fields, auth strategy, access control, and hooks.\n *\n * @param baseCollection - The developer's existing users collection config (or undefined for minimal)\n * @param options - Extension options including storage\n * @returns Extended collection config with BA integration\n */\nexport function extendUsersCollection(\n baseCollection: CollectionConfig | undefined,\n options: ExtendUsersCollectionOptions,\n): CollectionConfig {\n const { collectionPrefix = '__better_auth', storage } = options\n const verifySig = createSigVerifier(storage)\n\n // Compute BA collection slugs\n const emailPasswordSlug = `${collectionPrefix}_email_password`\n const magicLinkSlug = `${collectionPrefix}_magic_link`\n\n // Start with base or minimal collection\n const base: CollectionConfig = baseCollection ?? {\n slug: 'users',\n fields: [],\n }\n\n // Ensure slug is 'users'\n if (base.slug !== 'users') {\n throw new Error('Users collection must have slug \"users\"')\n }\n\n // Get developer's existing access rules\n const developerAccess = base.access ?? {}\n\n // Get developer's existing hooks\n const developerHooks = base.hooks ?? {}\n\n // BA-specific beforeChange hook\n const baBeforeChange = async ({\n data,\n operation,\n originalDoc,\n req,\n }: {\n data: Record<string, unknown>\n operation: 'create' | 'update'\n originalDoc?: Record<string, unknown>\n req: PayloadRequest\n }) => {\n const sig = req.context.baSig as CryptoSignature | undefined\n\n if (operation === 'create' && sig) {\n const expectedBody = { collection: 'users', op: 'create', userId: data.baUserId }\n if (verifyCanonical(expectedBody, sig, INTERNAL_SECRET)) {\n await markNonceUsed(storage, sig.nonce)\n }\n } else if (operation === 'update' && sig) {\n const userId = originalDoc?.baUserId || data.baUserId\n const expectedBody = { collection: 'users', op: 'update', userId }\n if (verifyCanonical(expectedBody, sig, INTERNAL_SECRET)) {\n await markNonceUsed(storage, sig.nonce)\n }\n }\n return data\n }\n\n // BA-specific beforeDelete hook\n const baBeforeDelete = async ({ req }: { id: number | string; req: PayloadRequest }) => {\n const sigOk = await verifySig(req)\n if (sigOk) {\n const sig = req.context.baSig as CryptoSignature | undefined\n if (sig) {\n await markNonceUsed(storage, sig.nonce)\n }\n }\n }\n\n return {\n ...base,\n access: {\n admin: developerAccess.admin ?? (({ req: { user } }) => Boolean(user)),\n create: createComposableAccess(storage, developerAccess.create as Access, 'create'),\n delete: createComposableAccess(storage, developerAccess.delete as Access, 'delete'),\n read: createComposableAccess(storage, developerAccess.read as Access, 'read'),\n update: createComposableAccess(storage, developerAccess.update as Access, 'update'),\n },\n admin: {\n ...base.admin,\n defaultColumns: (() => {\n const cols = base.admin?.defaultColumns ?? ['email', 'createdAt']\n // Add BA accounts column if not already present\n if (!cols.includes('betterAuthAccounts')) {\n return [...cols, 'betterAuthAccounts']\n }\n return cols\n })(),\n useAsTitle: base.admin?.useAsTitle ?? 'email',\n },\n auth: {\n ...(typeof base.auth === 'object' ? base.auth : {}),\n disableLocalStrategy: true,\n strategies: [\n createBetterAuthStrategy(storage, collectionPrefix),\n // Preserve any existing strategies (except local)\n ...((typeof base.auth === 'object' && base.auth.strategies) || []),\n ],\n },\n fields: [\n // Developer's fields first\n ...(base.fields ?? []),\n // BA fields\n ...getBetterAuthFields(emailPasswordSlug, magicLinkSlug),\n ],\n hooks: {\n ...developerHooks,\n beforeChange: [\n // BA hook first\n baBeforeChange,\n // Then developer hooks\n ...(developerHooks.beforeChange ?? []),\n ],\n beforeDelete: [\n // BA hook first\n baBeforeDelete,\n // Then developer hooks\n ...(developerHooks.beforeDelete ?? []),\n ],\n },\n timestamps: base.timestamps ?? true,\n }\n}\n\n/**\n * Creates a minimal users collection with Better Auth integration.\n * Use this when no custom users collection is defined.\n */\nexport function createMinimalUsersCollection(\n options: ExtendUsersCollectionOptions,\n): CollectionConfig {\n return extendUsersCollection(undefined, options)\n}\n"],"names":["verifyCanonical","NONCE_PREFIX","SESSION_COOKIE_NAME_KEY","INTERNAL_SECRET","process","env","BA_TO_PAYLOAD_SECRET","NONCE_TTL_SECONDS","extractAllSessionTokens","headers","storage","cookieHeader","get","sessionCookieName","tokens","cookie","split","trimmed","trim","eqIndex","indexOf","key","slice","value","push","decodeURIComponent","createSigVerifier","verifySig","req","sig","context","baSig","body","baBody","ok","alreadyUsed","nonce","markNonceUsed","set","baOnlyFieldAccess","update","Boolean","getBetterAuthFields","emailPasswordSlug","magicLinkSlug","name","type","access","admin","readOnly","index","unique","hasMany","relationTo","validateSessionToken","fullToken","token","cached","storedSession","JSON","parse","expiresAt","session","Date","getTime","now","userId","createBetterAuthStrategy","_prefix","authenticate","payload","length","user","baUserId","existing","find","collection","limit","where","equals","docs","createComposableAccess","developerAccess","operation","args","sigOk","Promise","resolve","extendUsersCollection","baseCollection","options","collectionPrefix","base","slug","fields","Error","developerHooks","hooks","baBeforeChange","data","originalDoc","expectedBody","op","baBeforeDelete","create","delete","read","defaultColumns","cols","includes","useAsTitle","auth","disableLocalStrategy","strategies","beforeChange","beforeDelete","timestamps","createMinimalUsersCollection","undefined"],"mappings":"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"}
@@ -1,9 +1,9 @@
1
- import type { ClientOptions } from 'better-auth';
1
+ import type { BetterAuthClientOptions } from 'better-auth';
2
2
  import type React from 'react';
3
3
  import type { AuthMethod } from '../better-auth/helpers';
4
4
  export type AuthClientOptions = {
5
5
  baseURL: string;
6
- } & Omit<ClientOptions, 'baseURL'>;
6
+ } & Omit<BetterAuthClientOptions, 'baseURL'>;
7
7
  export declare function fetchAuthMethods({ additionalHeaders, betterAuthBaseUrl, debug, }: {
8
8
  additionalHeaders?: HeadersInit;
9
9
  betterAuthBaseUrl: string;
@@ -3,7 +3,7 @@ import { EmailPasswordFormClient } from './EmailPasswordFormClient';
3
3
  export async function fetchAuthMethods({ additionalHeaders, betterAuthBaseUrl, debug = false }) {
4
4
  const headers = new Headers(additionalHeaders);
5
5
  headers.append('Content-Type', 'application/json');
6
- const url = `${betterAuthBaseUrl}/api/auth/auth/methods`;
6
+ const url = `${betterAuthBaseUrl}/api/auth/methods`;
7
7
  if (debug) {
8
8
  console.log('[payload-better-auth] fetchAuthMethods: Attempting to fetch auth methods');
9
9
  console.log('[payload-better-auth] fetchAuthMethods: - URL:', url);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/BetterAuthLoginServer.tsx"],"sourcesContent":["import type { ClientOptions } from 'better-auth'\nimport type React from 'react'\n\nimport type { AuthMethod } from '../better-auth/helpers'\n\nimport { EmailPasswordFormClient } from './EmailPasswordFormClient'\n\nexport type AuthClientOptions = { baseURL: string } & Omit<ClientOptions, 'baseURL'>\n\nexport async function fetchAuthMethods({\n additionalHeaders,\n betterAuthBaseUrl,\n debug = false,\n}: {\n additionalHeaders?: HeadersInit\n betterAuthBaseUrl: string\n debug?: boolean\n}): Promise<{ data: AuthMethod[]; error: null } | { data: null; error: Error }> {\n const headers = new Headers(additionalHeaders)\n headers.append('Content-Type', 'application/json')\n const url = `${betterAuthBaseUrl}/api/auth/auth/methods`\n\n if (debug) {\n console.log('[payload-better-auth] fetchAuthMethods: Attempting to fetch auth methods')\n console.log('[payload-better-auth] fetchAuthMethods: - URL:', url)\n console.log('[payload-better-auth] fetchAuthMethods: - betterAuthBaseUrl:', betterAuthBaseUrl)\n }\n\n try {\n const response = await fetch(url, {\n headers,\n method: 'GET',\n })\n\n if (debug) {\n console.log('[payload-better-auth] fetchAuthMethods: Response received')\n console.log('[payload-better-auth] fetchAuthMethods: - status:', response.status)\n console.log('[payload-better-auth] fetchAuthMethods: - statusText:', response.statusText)\n }\n\n if (!response.ok) {\n throw new Error(`Failed to fetch auth methods: ${response.status} ${response.statusText}`)\n }\n\n const data = await response.json()\n\n if (debug) {\n console.log('[payload-better-auth] fetchAuthMethods: Successfully fetched auth methods')\n console.log('[payload-better-auth] fetchAuthMethods: - methods count:', data?.length ?? 0)\n }\n\n return { data, error: null } as { data: AuthMethod[]; error: null }\n } catch (error) {\n console.error('Error fetching auth methods:', error)\n return { data: null, error: error as Error }\n }\n}\n\nexport type BetterAuthLoginServerProps = {\n /**\n * Enable debug logging for troubleshooting connection issues.\n */\n debug?: boolean\n /**\n * Auth client options for client-side requests (uses external/public URL).\n */\n externalAuthClientOptions: AuthClientOptions\n /**\n * Auth client options for server-side requests (uses internal URL).\n */\n internalAuthClientOptions: AuthClientOptions\n}\n\nexport async function BetterAuthLoginServer({\n debug = false,\n externalAuthClientOptions,\n internalAuthClientOptions,\n}: BetterAuthLoginServerProps) {\n const authMethods = await fetchAuthMethods({\n additionalHeaders: internalAuthClientOptions.fetchOptions?.headers,\n betterAuthBaseUrl: internalAuthClientOptions.baseURL,\n debug,\n })\n\n return (\n <div\n style={{\n alignItems: 'center',\n display: 'flex',\n justifyContent: 'center',\n }}\n >\n <div\n style={{\n background: 'white',\n borderRadius: '8px',\n boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',\n maxWidth: '400px',\n padding: '2rem',\n width: '100%',\n }}\n >\n <h2\n style={{\n color: '#333',\n fontSize: '1.5rem',\n fontWeight: '600',\n marginBottom: '2rem',\n textAlign: 'center',\n }}\n >\n Sign In to Admin\n </h2>\n\n {authMethods.data?.some(\n (m) => m.method === 'emailAndPassword' || m.method === 'magicLink',\n ) && (\n <EmailPasswordFormClient\n authClientOptions={externalAuthClientOptions}\n authMethods={authMethods.data}\n />\n )}\n {authMethods.data?.length === 0 && (\n <div\n style={{\n color: '#666',\n padding: '2rem',\n textAlign: 'center',\n }}\n >\n <p>No authentication methods are currently available.</p>\n <p style={{ fontSize: '0.875rem', marginTop: '1rem' }}>\n Please contact your administrator.\n </p>\n </div>\n )}\n {authMethods.error && (\n <div\n style={{\n color: '#666',\n padding: '2rem',\n textAlign: 'center',\n }}\n >\n <p>Couldn't fetch authentication methods from better-auth</p>\n <p style={{ fontSize: '0.875rem', marginTop: '1rem' }}>\n Please contact your administrator.\n </p>\n </div>\n )}\n </div>\n </div>\n )\n}\n"],"names":["EmailPasswordFormClient","fetchAuthMethods","additionalHeaders","betterAuthBaseUrl","debug","headers","Headers","append","url","console","log","response","fetch","method","status","statusText","ok","Error","data","json","length","error","BetterAuthLoginServer","externalAuthClientOptions","internalAuthClientOptions","authMethods","fetchOptions","baseURL","div","style","alignItems","display","justifyContent","background","borderRadius","boxShadow","maxWidth","padding","width","h2","color","fontSize","fontWeight","marginBottom","textAlign","some","m","authClientOptions","p","marginTop"],"mappings":";AAKA,SAASA,uBAAuB,QAAQ,4BAA2B;AAInE,OAAO,eAAeC,iBAAiB,EACrCC,iBAAiB,EACjBC,iBAAiB,EACjBC,QAAQ,KAAK,EAKd;IACC,MAAMC,UAAU,IAAIC,QAAQJ;IAC5BG,QAAQE,MAAM,CAAC,gBAAgB;IAC/B,MAAMC,MAAM,GAAGL,kBAAkB,sBAAsB,CAAC;IAExD,IAAIC,OAAO;QACTK,QAAQC,GAAG,CAAC;QACZD,QAAQC,GAAG,CAAC,oDAAoDF;QAChEC,QAAQC,GAAG,CAAC,kEAAkEP;IAChF;IAEA,IAAI;QACF,MAAMQ,WAAW,MAAMC,MAAMJ,KAAK;YAChCH;YACAQ,QAAQ;QACV;QAEA,IAAIT,OAAO;YACTK,QAAQC,GAAG,CAAC;YACZD,QAAQC,GAAG,CAAC,uDAAuDC,SAASG,MAAM;YAClFL,QAAQC,GAAG,CAAC,2DAA2DC,SAASI,UAAU;QAC5F;QAEA,IAAI,CAACJ,SAASK,EAAE,EAAE;YAChB,MAAM,IAAIC,MAAM,CAAC,8BAA8B,EAAEN,SAASG,MAAM,CAAC,CAAC,EAAEH,SAASI,UAAU,EAAE;QAC3F;QAEA,MAAMG,OAAO,MAAMP,SAASQ,IAAI;QAEhC,IAAIf,OAAO;YACTK,QAAQC,GAAG,CAAC;YACZD,QAAQC,GAAG,CAAC,8DAA8DQ,MAAME,UAAU;QAC5F;QAEA,OAAO;YAAEF;YAAMG,OAAO;QAAK;IAC7B,EAAE,OAAOA,OAAO;QACdZ,QAAQY,KAAK,CAAC,gCAAgCA;QAC9C,OAAO;YAAEH,MAAM;YAAMG,OAAOA;QAAe;IAC7C;AACF;AAiBA,OAAO,eAAeC,sBAAsB,EAC1ClB,QAAQ,KAAK,EACbmB,yBAAyB,EACzBC,yBAAyB,EACE;IAC3B,MAAMC,cAAc,MAAMxB,iBAAiB;QACzCC,mBAAmBsB,0BAA0BE,YAAY,EAAErB;QAC3DF,mBAAmBqB,0BAA0BG,OAAO;QACpDvB;IACF;IAEA,qBACE,KAACwB;QACCC,OAAO;YACLC,YAAY;YACZC,SAAS;YACTC,gBAAgB;QAClB;kBAEA,cAAA,MAACJ;YACCC,OAAO;gBACLI,YAAY;gBACZC,cAAc;gBACdC,WAAW;gBACXC,UAAU;gBACVC,SAAS;gBACTC,OAAO;YACT;;8BAEA,KAACC;oBACCV,OAAO;wBACLW,OAAO;wBACPC,UAAU;wBACVC,YAAY;wBACZC,cAAc;wBACdC,WAAW;oBACb;8BACD;;gBAIAnB,YAAYP,IAAI,EAAE2B,KACjB,CAACC,IAAMA,EAAEjC,MAAM,KAAK,sBAAsBiC,EAAEjC,MAAM,KAAK,8BAEvD,KAACb;oBACC+C,mBAAmBxB;oBACnBE,aAAaA,YAAYP,IAAI;;gBAGhCO,YAAYP,IAAI,EAAEE,WAAW,mBAC5B,MAACQ;oBACCC,OAAO;wBACLW,OAAO;wBACPH,SAAS;wBACTO,WAAW;oBACb;;sCAEA,KAACI;sCAAE;;sCACH,KAACA;4BAAEnB,OAAO;gCAAEY,UAAU;gCAAYQ,WAAW;4BAAO;sCAAG;;;;gBAK1DxB,YAAYJ,KAAK,kBAChB,MAACO;oBACCC,OAAO;wBACLW,OAAO;wBACPH,SAAS;wBACTO,WAAW;oBACb;;sCAEA,KAACI;sCAAE;;sCACH,KAACA;4BAAEnB,OAAO;gCAAEY,UAAU;gCAAYQ,WAAW;4BAAO;sCAAG;;;;;;;AAQnE"}
1
+ {"version":3,"sources":["../../src/components/BetterAuthLoginServer.tsx"],"sourcesContent":["import type { BetterAuthClientOptions } from 'better-auth'\nimport type React from 'react'\n\nimport type { AuthMethod } from '../better-auth/helpers'\n\nimport { EmailPasswordFormClient } from './EmailPasswordFormClient'\n\nexport type AuthClientOptions = { baseURL: string } & Omit<BetterAuthClientOptions, 'baseURL'>\n\nexport async function fetchAuthMethods({\n additionalHeaders,\n betterAuthBaseUrl,\n debug = false,\n}: {\n additionalHeaders?: HeadersInit\n betterAuthBaseUrl: string\n debug?: boolean\n}): Promise<{ data: AuthMethod[]; error: null } | { data: null; error: Error }> {\n const headers = new Headers(additionalHeaders)\n headers.append('Content-Type', 'application/json')\n const url = `${betterAuthBaseUrl}/api/auth/methods`\n\n if (debug) {\n console.log('[payload-better-auth] fetchAuthMethods: Attempting to fetch auth methods')\n console.log('[payload-better-auth] fetchAuthMethods: - URL:', url)\n console.log('[payload-better-auth] fetchAuthMethods: - betterAuthBaseUrl:', betterAuthBaseUrl)\n }\n\n try {\n const response = await fetch(url, {\n headers,\n method: 'GET',\n })\n\n if (debug) {\n console.log('[payload-better-auth] fetchAuthMethods: Response received')\n console.log('[payload-better-auth] fetchAuthMethods: - status:', response.status)\n console.log('[payload-better-auth] fetchAuthMethods: - statusText:', response.statusText)\n }\n\n if (!response.ok) {\n throw new Error(`Failed to fetch auth methods: ${response.status} ${response.statusText}`)\n }\n\n const data = await response.json()\n\n if (debug) {\n console.log('[payload-better-auth] fetchAuthMethods: Successfully fetched auth methods')\n console.log('[payload-better-auth] fetchAuthMethods: - methods count:', data?.length ?? 0)\n }\n\n return { data, error: null } as { data: AuthMethod[]; error: null }\n } catch (error) {\n console.error('Error fetching auth methods:', error)\n return { data: null, error: error as Error }\n }\n}\n\nexport type BetterAuthLoginServerProps = {\n /**\n * Enable debug logging for troubleshooting connection issues.\n */\n debug?: boolean\n /**\n * Auth client options for client-side requests (uses external/public URL).\n */\n externalAuthClientOptions: AuthClientOptions\n /**\n * Auth client options for server-side requests (uses internal URL).\n */\n internalAuthClientOptions: AuthClientOptions\n}\n\nexport async function BetterAuthLoginServer({\n debug = false,\n externalAuthClientOptions,\n internalAuthClientOptions,\n}: BetterAuthLoginServerProps) {\n const authMethods = await fetchAuthMethods({\n additionalHeaders: internalAuthClientOptions.fetchOptions?.headers,\n betterAuthBaseUrl: internalAuthClientOptions.baseURL,\n debug,\n })\n\n return (\n <div\n style={{\n alignItems: 'center',\n display: 'flex',\n justifyContent: 'center',\n }}\n >\n <div\n style={{\n background: 'white',\n borderRadius: '8px',\n boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',\n maxWidth: '400px',\n padding: '2rem',\n width: '100%',\n }}\n >\n <h2\n style={{\n color: '#333',\n fontSize: '1.5rem',\n fontWeight: '600',\n marginBottom: '2rem',\n textAlign: 'center',\n }}\n >\n Sign In to Admin\n </h2>\n\n {authMethods.data?.some(\n (m) => m.method === 'emailAndPassword' || m.method === 'magicLink',\n ) && (\n <EmailPasswordFormClient\n authClientOptions={externalAuthClientOptions}\n authMethods={authMethods.data}\n />\n )}\n {authMethods.data?.length === 0 && (\n <div\n style={{\n color: '#666',\n padding: '2rem',\n textAlign: 'center',\n }}\n >\n <p>No authentication methods are currently available.</p>\n <p style={{ fontSize: '0.875rem', marginTop: '1rem' }}>\n Please contact your administrator.\n </p>\n </div>\n )}\n {authMethods.error && (\n <div\n style={{\n color: '#666',\n padding: '2rem',\n textAlign: 'center',\n }}\n >\n <p>Couldn't fetch authentication methods from better-auth</p>\n <p style={{ fontSize: '0.875rem', marginTop: '1rem' }}>\n Please contact your administrator.\n </p>\n </div>\n )}\n </div>\n </div>\n )\n}\n"],"names":["EmailPasswordFormClient","fetchAuthMethods","additionalHeaders","betterAuthBaseUrl","debug","headers","Headers","append","url","console","log","response","fetch","method","status","statusText","ok","Error","data","json","length","error","BetterAuthLoginServer","externalAuthClientOptions","internalAuthClientOptions","authMethods","fetchOptions","baseURL","div","style","alignItems","display","justifyContent","background","borderRadius","boxShadow","maxWidth","padding","width","h2","color","fontSize","fontWeight","marginBottom","textAlign","some","m","authClientOptions","p","marginTop"],"mappings":";AAKA,SAASA,uBAAuB,QAAQ,4BAA2B;AAInE,OAAO,eAAeC,iBAAiB,EACrCC,iBAAiB,EACjBC,iBAAiB,EACjBC,QAAQ,KAAK,EAKd;IACC,MAAMC,UAAU,IAAIC,QAAQJ;IAC5BG,QAAQE,MAAM,CAAC,gBAAgB;IAC/B,MAAMC,MAAM,GAAGL,kBAAkB,iBAAiB,CAAC;IAEnD,IAAIC,OAAO;QACTK,QAAQC,GAAG,CAAC;QACZD,QAAQC,GAAG,CAAC,oDAAoDF;QAChEC,QAAQC,GAAG,CAAC,kEAAkEP;IAChF;IAEA,IAAI;QACF,MAAMQ,WAAW,MAAMC,MAAMJ,KAAK;YAChCH;YACAQ,QAAQ;QACV;QAEA,IAAIT,OAAO;YACTK,QAAQC,GAAG,CAAC;YACZD,QAAQC,GAAG,CAAC,uDAAuDC,SAASG,MAAM;YAClFL,QAAQC,GAAG,CAAC,2DAA2DC,SAASI,UAAU;QAC5F;QAEA,IAAI,CAACJ,SAASK,EAAE,EAAE;YAChB,MAAM,IAAIC,MAAM,CAAC,8BAA8B,EAAEN,SAASG,MAAM,CAAC,CAAC,EAAEH,SAASI,UAAU,EAAE;QAC3F;QAEA,MAAMG,OAAO,MAAMP,SAASQ,IAAI;QAEhC,IAAIf,OAAO;YACTK,QAAQC,GAAG,CAAC;YACZD,QAAQC,GAAG,CAAC,8DAA8DQ,MAAME,UAAU;QAC5F;QAEA,OAAO;YAAEF;YAAMG,OAAO;QAAK;IAC7B,EAAE,OAAOA,OAAO;QACdZ,QAAQY,KAAK,CAAC,gCAAgCA;QAC9C,OAAO;YAAEH,MAAM;YAAMG,OAAOA;QAAe;IAC7C;AACF;AAiBA,OAAO,eAAeC,sBAAsB,EAC1ClB,QAAQ,KAAK,EACbmB,yBAAyB,EACzBC,yBAAyB,EACE;IAC3B,MAAMC,cAAc,MAAMxB,iBAAiB;QACzCC,mBAAmBsB,0BAA0BE,YAAY,EAAErB;QAC3DF,mBAAmBqB,0BAA0BG,OAAO;QACpDvB;IACF;IAEA,qBACE,KAACwB;QACCC,OAAO;YACLC,YAAY;YACZC,SAAS;YACTC,gBAAgB;QAClB;kBAEA,cAAA,MAACJ;YACCC,OAAO;gBACLI,YAAY;gBACZC,cAAc;gBACdC,WAAW;gBACXC,UAAU;gBACVC,SAAS;gBACTC,OAAO;YACT;;8BAEA,KAACC;oBACCV,OAAO;wBACLW,OAAO;wBACPC,UAAU;wBACVC,YAAY;wBACZC,cAAc;wBACdC,WAAW;oBACb;8BACD;;gBAIAnB,YAAYP,IAAI,EAAE2B,KACjB,CAACC,IAAMA,EAAEjC,MAAM,KAAK,sBAAsBiC,EAAEjC,MAAM,KAAK,8BAEvD,KAACb;oBACC+C,mBAAmBxB;oBACnBE,aAAaA,YAAYP,IAAI;;gBAGhCO,YAAYP,IAAI,EAAEE,WAAW,mBAC5B,MAACQ;oBACCC,OAAO;wBACLW,OAAO;wBACPH,SAAS;wBACTO,WAAW;oBACb;;sCAEA,KAACI;sCAAE;;sCACH,KAACA;4BAAEnB,OAAO;gCAAEY,UAAU;gCAAYQ,WAAW;4BAAO;sCAAG;;;;gBAK1DxB,YAAYJ,KAAK,kBAChB,MAACO;oBACCC,OAAO;wBACLW,OAAO;wBACPH,SAAS;wBACTO,WAAW;oBACb;;sCAEA,KAACI;sCAAE;;sCACH,KAACA;4BAAEnB,OAAO;gCAAEY,UAAU;gCAAYQ,WAAW;4BAAO;sCAAG;;;;;;;AAQnE"}
@@ -1,4 +1,4 @@
1
- import type { ClientOptions } from 'better-auth';
1
+ import type { BetterAuthClientOptions as AuthClientOptions } from 'better-auth';
2
2
  import type { Access, Config } from 'payload';
3
3
  import type { EventBus } from '../eventBus/types';
4
4
  import type { SecondaryStorage } from '../storage/types';
@@ -15,7 +15,7 @@ export type BetterAuthClientOptions = {
15
15
  * @example 'http://auth-service:3000'
16
16
  */
17
17
  internalBaseURL: string;
18
- } & Omit<ClientOptions, 'baseURL'>;
18
+ } & Omit<AuthClientOptions, 'baseURL'>;
19
19
  export type BetterAuthPayloadPluginOptions = {
20
20
  /**
21
21
  * Custom access rules for Better Auth collections (email_password, magic_link).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/payload/plugin.ts"],"sourcesContent":["import type { ClientOptions } from 'better-auth'\nimport type { Access, CollectionConfig, Config } from 'payload'\n\nimport type { EventBus } from '../eventBus/types'\nimport type { SecondaryStorage } from '../storage/types'\n\nimport { createEmailPasswordCollection } from '../collections/BetterAuth/emailPassword'\nimport { createMagicLinkCollection } from '../collections/BetterAuth/magicLink'\nimport { extendUsersCollection } from '../collections/Users/index'\nimport { createDeduplicatedLogger } from '../shared/deduplicatedLogger'\nimport { TIMESTAMP_PREFIX } from '../storage/keys'\n\nexport type BetterAuthClientOptions = {\n /**\n * The external base URL for better-auth, used for client-side requests (from the browser).\n * This should be the publicly accessible URL.\n * @example 'https://auth.example.com'\n */\n externalBaseURL: string\n /**\n * The internal base URL for better-auth, used for server-side requests.\n * This is used when the server needs to reach better-auth internally (e.g., within a container network).\n * @example 'http://auth-service:3000'\n */\n internalBaseURL: string\n} & Omit<ClientOptions, 'baseURL'>\n\nexport type BetterAuthPayloadPluginOptions = {\n /**\n * Custom access rules for Better Auth collections (email_password, magic_link).\n * These override the default debug-mode access (which allows read for authenticated users).\n *\n * @example\n * baCollectionsAccess: {\n * read: ({ req }) => req.user?.role === 'admin',\n * delete: ({ req }) => req.user?.role === 'admin',\n * }\n */\n baCollectionsAccess?: {\n delete?: Access\n read?: Access\n }\n betterAuthClientOptions: BetterAuthClientOptions\n /**\n * Prefix for Better Auth collections (default: '__better_auth').\n * The collections will be named: {prefix}_email_password, {prefix}_magic_link\n */\n collectionPrefix?: string\n /**\n * Enable debug logging and make BA collections visible in admin.\n * When enabled:\n * - Detailed error information will be logged\n * - BA collections are visible under \"Better Auth (DEBUG)\" group\n * - Authenticated users can read BA collections (unless baCollectionsAccess overrides)\n */\n debug?: boolean\n disabled?: boolean\n /**\n * EventBus for timestamp-based coordination between plugins.\n * Both plugins MUST share the same eventBus instance.\n *\n * Available implementations:\n * - `createSqlitePollingEventBus()` - Uses SQLite for cross-process coordination\n *\n * @example\n * // Create shared eventBus (e.g., in a separate file)\n * import { createSqlitePollingEventBus } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.event-bus.db')\n * export const eventBus = createSqlitePollingEventBus({ db })\n */\n eventBus: EventBus\n /**\n * Secondary storage for state coordination between Better Auth and Payload.\n * Both plugins MUST share the same storage instance.\n *\n * Available storage adapters:\n * - `createSqliteStorage()` - Uses Node.js 22+ native SQLite (no external dependencies)\n * - `createRedisStorage(redis)` - Redis-backed, for distributed/multi-server production\n *\n * @example\n * // Development (Node.js 22+)\n * import { createSqliteStorage } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.sync-state.db')\n * const storage = createSqliteStorage({ db })\n *\n * @example\n * // Production (distributed)\n * import { createRedisStorage } from 'payload-better-auth'\n * import Redis from 'ioredis'\n * const storage = createRedisStorage({ redis: new Redis() })\n */\n storage: SecondaryStorage\n}\n\nexport const betterAuthPayloadPlugin =\n (pluginOptions: BetterAuthPayloadPluginOptions) =>\n (config: Config): Config => {\n const { externalBaseURL, internalBaseURL, ...restClientOptions } =\n pluginOptions.betterAuthClientOptions\n const debug = pluginOptions.debug ?? false\n const collectionPrefix = pluginOptions.collectionPrefix ?? '__better_auth'\n const { baCollectionsAccess, eventBus, storage } = pluginOptions\n\n // Create deduplicated logger\n const logger = createDeduplicatedLogger({\n enabled: debug,\n prefix: '[payload]',\n storage,\n })\n\n // Build internal and external auth client options\n const internalAuthClientOptions = { ...restClientOptions, baseURL: internalBaseURL }\n const externalAuthClientOptions = { ...restClientOptions, baseURL: externalBaseURL }\n\n // Log plugin configuration at startup (deduplicated)\n void logger.log('init', `Initialized (baseURL: ${internalBaseURL})`)\n\n // Determine BA collection access:\n // 1. If baCollectionsAccess is provided, use it (overrides debug defaults)\n // 2. If debug is enabled, allow authenticated users to read\n // 3. Otherwise, no custom access (only BA sync agent)\n const effectiveBaAccess: { delete?: Access; read?: Access } | undefined = baCollectionsAccess\n ? baCollectionsAccess\n : debug\n ? { read: ({ req }) => Boolean(req.user) }\n : undefined\n\n // Initialize collections array if not present\n if (!config.collections) {\n config.collections = []\n }\n\n // Create BA collections\n const emailPasswordCollection = createEmailPasswordCollection({\n access: effectiveBaAccess,\n isVisible: debug,\n prefix: collectionPrefix,\n storage,\n })\n const magicLinkCollection = createMagicLinkCollection({\n access: effectiveBaAccess,\n isVisible: debug,\n prefix: collectionPrefix,\n storage,\n })\n\n // Find and extend existing users collection, or create minimal one\n const existingUsersIndex = config.collections.findIndex((col) => col.slug === 'users')\n const existingUsersCollection: CollectionConfig | undefined =\n existingUsersIndex >= 0 ? config.collections[existingUsersIndex] : undefined\n\n const extendedUsersCollection = extendUsersCollection(existingUsersCollection, {\n collectionPrefix,\n storage,\n })\n\n // Replace or add the users collection\n if (existingUsersIndex >= 0) {\n config.collections[existingUsersIndex] = extendedUsersCollection\n } else {\n config.collections.push(extendedUsersCollection)\n }\n\n // Add BA collections\n config.collections.push(emailPasswordCollection)\n config.collections.push(magicLinkCollection)\n\n /**\n * If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations.\n * If your plugin heavily modifies the database schema, you may want to remove this property.\n */\n if (pluginOptions.disabled) {\n return config\n }\n\n if (!config.endpoints) {\n config.endpoints = []\n }\n\n if (!config.admin) {\n config.admin = {}\n }\n\n if (!config.admin.user) {\n config.admin.user = extendedUsersCollection.slug\n } else if (config.admin.user !== extendedUsersCollection.slug) {\n throw new Error(\n 'Payload-better-auth plugin: admin.user property already set with conflicting value.',\n )\n }\n\n if (!config.admin.components) {\n config.admin.components = {}\n }\n\n if (!config.admin.components.views) {\n config.admin.components.views = {}\n }\n\n if (!config.admin.components.views.login) {\n config.admin.components.views.login = {\n Component: {\n path: 'payload-better-auth/rsc#BetterAuthLoginServer',\n serverProps: {\n debug,\n externalAuthClientOptions,\n internalAuthClientOptions,\n },\n },\n exact: true,\n path: '/auth',\n }\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.components.views.login property in config already set.',\n )\n }\n\n if (!config.admin.components.views.verifyEmail) {\n config.admin.components.views.verifyEmail = {\n Component: 'payload-better-auth/client#VerifyEmailInfoViewClient', // RSC or 'use client' component\n exact: true,\n path: '/auth/verify-email',\n }\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.components.views.verifyEmail property in config already set.',\n )\n }\n\n // Configure custom logout button that signs out from Better Auth\n if (!config.admin.components.logout) {\n config.admin.components.logout = {}\n }\n\n if (!config.admin.components.logout.Button) {\n config.admin.components.logout.Button = {\n clientProps: {\n authClientOptions: externalAuthClientOptions,\n },\n path: 'payload-better-auth/client#LogoutButtonClient',\n }\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.components.logout.Button property in config already set.',\n )\n }\n\n if (!config.admin.routes) {\n config.admin.routes = {}\n }\n\n if (!config.admin.routes.login) {\n config.admin.routes.login = '/auth'\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.routes.login property in config already set.',\n )\n }\n\n const incomingOnInit = config.onInit\n\n config.onInit = async (payload) => {\n // Ensure we are executing any existing onInit functions before running our own.\n if (incomingOnInit) {\n await incomingOnInit(payload)\n }\n\n // Set Payload timestamp in storage - Better Auth will see this and trigger reconciliation\n const timestamp = Date.now()\n await storage.set(TIMESTAMP_PREFIX + 'payload', String(timestamp))\n // Also notify via event bus for same-process subscribers\n eventBus.notifyTimestampChange('payload', timestamp)\n await logger.log('ready', 'Ready, triggering Better Auth initialization')\n\n // Trigger Better Auth initialization by calling the warmup endpoint\n // Better Auth plugins are lazy-initialized on first request\n try {\n const warmupUrl = `${internalBaseURL}/api/auth/warmup`\n const response = await fetch(warmupUrl, {\n headers: { 'User-Agent': 'Payload-Better-Auth-Warmup' },\n method: 'GET',\n })\n if (response.ok) {\n const info = (await response.json()) as {\n authMethods: string[]\n initialized: boolean\n timestamp: string\n }\n await logger.log('warmup', 'Better Auth initialized', {\n authMethods: info.authMethods,\n })\n } else {\n await logger.log('warmup-error', 'Better Auth warmup returned error', {\n status: response.status,\n })\n }\n } catch (error) {\n // Log but don't fail - Better Auth will initialize on first real request\n await logger.log(\n 'warmup-error',\n 'Failed to warm up Better Auth (will init on first request)',\n {\n error: error instanceof Error ? error.message : String(error),\n },\n )\n }\n\n // Note: User sync is now handled entirely by the reconcile queue on the Better Auth side.\n // The queue enqueues ensure/delete tasks when users change, and processes them with retries.\n }\n\n return config\n }\n"],"names":["createEmailPasswordCollection","createMagicLinkCollection","extendUsersCollection","createDeduplicatedLogger","TIMESTAMP_PREFIX","betterAuthPayloadPlugin","pluginOptions","config","externalBaseURL","internalBaseURL","restClientOptions","betterAuthClientOptions","debug","collectionPrefix","baCollectionsAccess","eventBus","storage","logger","enabled","prefix","internalAuthClientOptions","baseURL","externalAuthClientOptions","log","effectiveBaAccess","read","req","Boolean","user","undefined","collections","emailPasswordCollection","access","isVisible","magicLinkCollection","existingUsersIndex","findIndex","col","slug","existingUsersCollection","extendedUsersCollection","push","disabled","endpoints","admin","Error","components","views","login","Component","path","serverProps","exact","verifyEmail","logout","Button","clientProps","authClientOptions","routes","incomingOnInit","onInit","payload","timestamp","Date","now","set","String","notifyTimestampChange","warmupUrl","response","fetch","headers","method","ok","info","json","authMethods","status","error","message"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/payload/plugin.ts"],"sourcesContent":["import type { BetterAuthClientOptions as AuthClientOptions } from 'better-auth'\nimport type { Access, CollectionConfig, Config } from 'payload'\n\nimport type { EventBus } from '../eventBus/types'\nimport type { SecondaryStorage } from '../storage/types'\n\nimport { createEmailPasswordCollection } from '../collections/BetterAuth/emailPassword'\nimport { createMagicLinkCollection } from '../collections/BetterAuth/magicLink'\nimport { extendUsersCollection } from '../collections/Users/index'\nimport { createDeduplicatedLogger } from '../shared/deduplicatedLogger'\nimport { TIMESTAMP_PREFIX } from '../storage/keys'\n\nexport type BetterAuthClientOptions = {\n /**\n * The external base URL for better-auth, used for client-side requests (from the browser).\n * This should be the publicly accessible URL.\n * @example 'https://auth.example.com'\n */\n externalBaseURL: string\n /**\n * The internal base URL for better-auth, used for server-side requests.\n * This is used when the server needs to reach better-auth internally (e.g., within a container network).\n * @example 'http://auth-service:3000'\n */\n internalBaseURL: string\n} & Omit<AuthClientOptions, 'baseURL'>\n\nexport type BetterAuthPayloadPluginOptions = {\n /**\n * Custom access rules for Better Auth collections (email_password, magic_link).\n * These override the default debug-mode access (which allows read for authenticated users).\n *\n * @example\n * baCollectionsAccess: {\n * read: ({ req }) => req.user?.role === 'admin',\n * delete: ({ req }) => req.user?.role === 'admin',\n * }\n */\n baCollectionsAccess?: {\n delete?: Access\n read?: Access\n }\n betterAuthClientOptions: BetterAuthClientOptions\n /**\n * Prefix for Better Auth collections (default: '__better_auth').\n * The collections will be named: {prefix}_email_password, {prefix}_magic_link\n */\n collectionPrefix?: string\n /**\n * Enable debug logging and make BA collections visible in admin.\n * When enabled:\n * - Detailed error information will be logged\n * - BA collections are visible under \"Better Auth (DEBUG)\" group\n * - Authenticated users can read BA collections (unless baCollectionsAccess overrides)\n */\n debug?: boolean\n disabled?: boolean\n /**\n * EventBus for timestamp-based coordination between plugins.\n * Both plugins MUST share the same eventBus instance.\n *\n * Available implementations:\n * - `createSqlitePollingEventBus()` - Uses SQLite for cross-process coordination\n *\n * @example\n * // Create shared eventBus (e.g., in a separate file)\n * import { createSqlitePollingEventBus } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.event-bus.db')\n * export const eventBus = createSqlitePollingEventBus({ db })\n */\n eventBus: EventBus\n /**\n * Secondary storage for state coordination between Better Auth and Payload.\n * Both plugins MUST share the same storage instance.\n *\n * Available storage adapters:\n * - `createSqliteStorage()` - Uses Node.js 22+ native SQLite (no external dependencies)\n * - `createRedisStorage(redis)` - Redis-backed, for distributed/multi-server production\n *\n * @example\n * // Development (Node.js 22+)\n * import { createSqliteStorage } from 'payload-better-auth'\n * import { DatabaseSync } from 'node:sqlite'\n * const db = new DatabaseSync('.sync-state.db')\n * const storage = createSqliteStorage({ db })\n *\n * @example\n * // Production (distributed)\n * import { createRedisStorage } from 'payload-better-auth'\n * import Redis from 'ioredis'\n * const storage = createRedisStorage({ redis: new Redis() })\n */\n storage: SecondaryStorage\n}\n\nexport const betterAuthPayloadPlugin =\n (pluginOptions: BetterAuthPayloadPluginOptions) =>\n (config: Config): Config => {\n const { externalBaseURL, internalBaseURL, ...restClientOptions } =\n pluginOptions.betterAuthClientOptions\n const debug = pluginOptions.debug ?? false\n const collectionPrefix = pluginOptions.collectionPrefix ?? '__better_auth'\n const { baCollectionsAccess, eventBus, storage } = pluginOptions\n\n // Create deduplicated logger\n const logger = createDeduplicatedLogger({\n enabled: debug,\n prefix: '[payload]',\n storage,\n })\n\n // Build internal and external auth client options\n const internalAuthClientOptions = { ...restClientOptions, baseURL: internalBaseURL }\n const externalAuthClientOptions = { ...restClientOptions, baseURL: externalBaseURL }\n\n // Log plugin configuration at startup (deduplicated)\n void logger.log('init', `Initialized (baseURL: ${internalBaseURL})`)\n\n // Determine BA collection access:\n // 1. If baCollectionsAccess is provided, use it (overrides debug defaults)\n // 2. If debug is enabled, allow authenticated users to read\n // 3. Otherwise, no custom access (only BA sync agent)\n const effectiveBaAccess: { delete?: Access; read?: Access } | undefined = baCollectionsAccess\n ? baCollectionsAccess\n : debug\n ? { read: ({ req }) => Boolean(req.user) }\n : undefined\n\n // Initialize collections array if not present\n if (!config.collections) {\n config.collections = []\n }\n\n // Create BA collections\n const emailPasswordCollection = createEmailPasswordCollection({\n access: effectiveBaAccess,\n isVisible: debug,\n prefix: collectionPrefix,\n storage,\n })\n const magicLinkCollection = createMagicLinkCollection({\n access: effectiveBaAccess,\n isVisible: debug,\n prefix: collectionPrefix,\n storage,\n })\n\n // Find and extend existing users collection, or create minimal one\n const existingUsersIndex = config.collections.findIndex((col) => col.slug === 'users')\n const existingUsersCollection: CollectionConfig | undefined =\n existingUsersIndex >= 0 ? config.collections[existingUsersIndex] : undefined\n\n const extendedUsersCollection = extendUsersCollection(existingUsersCollection, {\n collectionPrefix,\n storage,\n })\n\n // Replace or add the users collection\n if (existingUsersIndex >= 0) {\n config.collections[existingUsersIndex] = extendedUsersCollection\n } else {\n config.collections.push(extendedUsersCollection)\n }\n\n // Add BA collections\n config.collections.push(emailPasswordCollection)\n config.collections.push(magicLinkCollection)\n\n /**\n * If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations.\n * If your plugin heavily modifies the database schema, you may want to remove this property.\n */\n if (pluginOptions.disabled) {\n return config\n }\n\n if (!config.endpoints) {\n config.endpoints = []\n }\n\n if (!config.admin) {\n config.admin = {}\n }\n\n if (!config.admin.user) {\n config.admin.user = extendedUsersCollection.slug\n } else if (config.admin.user !== extendedUsersCollection.slug) {\n throw new Error(\n 'Payload-better-auth plugin: admin.user property already set with conflicting value.',\n )\n }\n\n if (!config.admin.components) {\n config.admin.components = {}\n }\n\n if (!config.admin.components.views) {\n config.admin.components.views = {}\n }\n\n if (!config.admin.components.views.login) {\n config.admin.components.views.login = {\n Component: {\n path: 'payload-better-auth/rsc#BetterAuthLoginServer',\n serverProps: {\n debug,\n externalAuthClientOptions,\n internalAuthClientOptions,\n },\n },\n exact: true,\n path: '/auth',\n }\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.components.views.login property in config already set.',\n )\n }\n\n if (!config.admin.components.views.verifyEmail) {\n config.admin.components.views.verifyEmail = {\n Component: 'payload-better-auth/client#VerifyEmailInfoViewClient', // RSC or 'use client' component\n exact: true,\n path: '/auth/verify-email',\n }\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.components.views.verifyEmail property in config already set.',\n )\n }\n\n // Configure custom logout button that signs out from Better Auth\n if (!config.admin.components.logout) {\n config.admin.components.logout = {}\n }\n\n if (!config.admin.components.logout.Button) {\n config.admin.components.logout.Button = {\n clientProps: {\n authClientOptions: externalAuthClientOptions,\n },\n path: 'payload-better-auth/client#LogoutButtonClient',\n }\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.components.logout.Button property in config already set.',\n )\n }\n\n if (!config.admin.routes) {\n config.admin.routes = {}\n }\n\n if (!config.admin.routes.login) {\n config.admin.routes.login = '/auth'\n } else {\n throw new Error(\n 'Payload-better-auth plugin: admin.routes.login property in config already set.',\n )\n }\n\n const incomingOnInit = config.onInit\n\n config.onInit = async (payload) => {\n // Ensure we are executing any existing onInit functions before running our own.\n if (incomingOnInit) {\n await incomingOnInit(payload)\n }\n\n // Set Payload timestamp in storage - Better Auth will see this and trigger reconciliation\n const timestamp = Date.now()\n await storage.set(TIMESTAMP_PREFIX + 'payload', String(timestamp))\n // Also notify via event bus for same-process subscribers\n eventBus.notifyTimestampChange('payload', timestamp)\n await logger.log('ready', 'Ready, triggering Better Auth initialization')\n\n // Trigger Better Auth initialization by calling the warmup endpoint\n // Better Auth plugins are lazy-initialized on first request\n try {\n const warmupUrl = `${internalBaseURL}/api/auth/warmup`\n const response = await fetch(warmupUrl, {\n headers: { 'User-Agent': 'Payload-Better-Auth-Warmup' },\n method: 'GET',\n })\n if (response.ok) {\n const info = (await response.json()) as {\n authMethods: string[]\n initialized: boolean\n timestamp: string\n }\n await logger.log('warmup', 'Better Auth initialized', {\n authMethods: info.authMethods,\n })\n } else {\n await logger.log('warmup-error', 'Better Auth warmup returned error', {\n status: response.status,\n })\n }\n } catch (error) {\n // Log but don't fail - Better Auth will initialize on first real request\n await logger.log(\n 'warmup-error',\n 'Failed to warm up Better Auth (will init on first request)',\n {\n error: error instanceof Error ? error.message : String(error),\n },\n )\n }\n\n // Note: User sync is now handled entirely by the reconcile queue on the Better Auth side.\n // The queue enqueues ensure/delete tasks when users change, and processes them with retries.\n }\n\n return config\n }\n"],"names":["createEmailPasswordCollection","createMagicLinkCollection","extendUsersCollection","createDeduplicatedLogger","TIMESTAMP_PREFIX","betterAuthPayloadPlugin","pluginOptions","config","externalBaseURL","internalBaseURL","restClientOptions","betterAuthClientOptions","debug","collectionPrefix","baCollectionsAccess","eventBus","storage","logger","enabled","prefix","internalAuthClientOptions","baseURL","externalAuthClientOptions","log","effectiveBaAccess","read","req","Boolean","user","undefined","collections","emailPasswordCollection","access","isVisible","magicLinkCollection","existingUsersIndex","findIndex","col","slug","existingUsersCollection","extendedUsersCollection","push","disabled","endpoints","admin","Error","components","views","login","Component","path","serverProps","exact","verifyEmail","logout","Button","clientProps","authClientOptions","routes","incomingOnInit","onInit","payload","timestamp","Date","now","set","String","notifyTimestampChange","warmupUrl","response","fetch","headers","method","ok","info","json","authMethods","status","error","message"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-better-auth",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "A Payload CMS plugin that integrates Better Auth for seamless user authentication and management",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,15 +48,17 @@
48
48
  "_build": "npm run copyfiles && npm run build:types && npm run build:swc",
49
49
  "clean": "rimraf {dist,*.tsbuildinfo}",
50
50
  "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
51
- "dev": "concurrently -k -n \"WEB,MAIL\" -c \"auto\" \"npm run dev:original\" \"npm run maildev\"",
51
+ "dev": "concurrently -k -n \"WEB,MAIL,AUTH\" -c \"auto\" \"npm run dev:original\" \"npm run maildev\" \"npm run dev:auth\"",
52
+ "dev:auth": "dotenv -e ./dev/.env.development -- node --import tsx dev/auth/server.ts",
53
+ "dev:auth:test": "dotenv -e ./dev/.env.test -- node --import tsx dev/auth/server.ts",
52
54
  "dev:redis": "npm run docker:redis && concurrently -k -n \"WEB,MAIL\" -c \"auto\" \"npm run dev:redis:original\" \"npm run maildev\"",
53
55
  "dev:redis:original": "cross-env USE_REDIS=true REDIS_URL=redis://localhost:6379 npm run dev:original",
54
56
  "docker:redis": "docker compose up -d redis",
55
57
  "docker:redis:stop": "docker compose down",
56
58
  "dev:build": "nx run payload-better-auth:_dev:build",
57
59
  "_dev:build": "dotenv -e ./dev/.env.test -- next build dev",
58
- "dev:original": "dotenv -e ./dev/.env.development -- next dev dev --turbo",
59
- "dev:start": "dotenv -e ./dev/.env.test -- next start dev",
60
+ "dev:original": "dotenv -e ./dev/.env.development -- sh -c 'next dev dev --turbo -p \"$PAYLOAD_PORT\"'",
61
+ "dev:start": "dotenv -e ./dev/.env.test -- sh -c 'next start dev -p \"$PAYLOAD_PORT\"'",
60
62
  "dev:generate-importmap": "npm run dev:payload generate:importmap",
61
63
  "dev:generate-types": "npm run dev:payload generate:types",
62
64
  "dev:payload": "dotenv -e ./dev/.env.development -- cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
@@ -75,80 +77,85 @@
75
77
  "lint:fix:all": "eslint --fix --stats ./src ./dev ./dev/tests",
76
78
  "lint:fix": "eslint ./src --fix",
77
79
  "maildev": "maildev --smtp=1025 --web=1080",
78
- "postinstall": "npm run build && npm run generate:importmap && npm run generate:types",
80
+ "postinstall": "npm run build && npm run generate:importmap && npm run generate:types && npm run playwright:install",
81
+ "playwright:install": "PLAYWRIGHT_BROWSERS_PATH=.playwright-browsers pnpm exec playwright install chromium",
79
82
  "prepare": "husky",
80
83
  "prepublishOnly": "npm run clean && npm run build",
81
84
  "test": "nx run payload-better-auth:_test",
82
85
  "_test": "npm run test:int && npm run test:e2e",
83
86
  "test:e2e": "nx run payload-better-auth:_test:e2e",
84
- "_test:e2e": "npm run dev:build && dotenv -e ./dev/.env.test -- playwright test",
87
+ "_test:e2e": "npm run build && npm run dev:build && dotenv -e ./dev/.env.test -- playwright test",
85
88
  "test:int": "nx run payload-better-auth:_test:int",
86
89
  "_test:int": "npm run test:setup && dotenv -e ./dev/.env.test -- vitest run",
87
90
  "test:setup": "npm run reset:test",
88
91
  "typecheck": "nx run payload-better-auth:_typecheck",
89
92
  "_typecheck": "tsc --noEmit -p ./dev/tsconfig.json",
90
93
  "semantic-release": "semantic-release",
91
- "test:int-with-wait": "dotenv -e ./dev/.env.test -- concurrently --success first -k -n \"dev,test\" \"npm run dev\" \"sh -lc 'until curl -fsS http://127.0.0.1:3000/admin >/dev/null; do sleep 0.25; done; npm run test:int'\""
94
+ "test:int-with-wait": "dotenv -e ./dev/.env.test -- concurrently --success first -k -n \"dev,test\" \"npm run dev\" \"sh -lc 'until curl -fsS http://127.0.0.1:$PAYLOAD_PORT/admin >/dev/null; do sleep 0.25; done; npm run test:int'\""
92
95
  },
93
96
  "devDependencies": {
94
- "@better-auth/cli": "^1.4.10",
95
- "@commitlint/cli": "^20.3.1",
96
- "@commitlint/config-conventional": "^20.3.1",
97
- "@eslint/eslintrc": "^3.3.3",
98
- "@payloadcms/db-mongodb": "^3.70.0",
99
- "@payloadcms/db-postgres": "^3.70.0",
100
- "@payloadcms/db-sqlite": "^3.70.0",
97
+ "@better-auth/api-key": "^1.5.5",
98
+ "@better-auth/cli": "^1.4.21",
99
+ "@commitlint/cli": "^20.4.4",
100
+ "@commitlint/config-conventional": "^20.4.4",
101
+ "@eslint/eslintrc": "^3.3.5",
102
+ "@hono/node-server": "^1.19.11",
103
+ "@payloadcms/db-mongodb": "^3.79.0",
104
+ "@payloadcms/db-postgres": "^3.79.0",
105
+ "@payloadcms/db-sqlite": "^3.79.0",
101
106
  "@payloadcms/eslint-config": "^3.28.0",
102
- "@payloadcms/next": "^3.70.0",
103
- "@payloadcms/richtext-lexical": "^3.70.0",
104
- "@payloadcms/ui": "^3.70.0",
105
- "@playwright/test": "^1.57.0",
107
+ "@payloadcms/next": "^3.79.0",
108
+ "@payloadcms/richtext-lexical": "^3.79.0",
109
+ "@payloadcms/ui": "^3.79.0",
110
+ "@playwright/test": "^1.58.2",
106
111
  "@semantic-release/changelog": "^6.0.3",
107
112
  "@semantic-release/git": "^10.0.1",
108
- "@semantic-release/github": "^12.0.2",
109
- "@semantic-release/npm": "^13.1.3",
113
+ "@semantic-release/github": "^12.0.6",
114
+ "@semantic-release/npm": "^13.1.5",
110
115
  "@swc-node/register": "1.11.1",
111
- "@swc/cli": "0.7.9",
116
+ "@swc/cli": "0.8.0",
112
117
  "@types/better-sqlite3": "^7.6.13",
113
- "@types/node": "^25.0.6",
114
- "@types/nodemailer": "^7.0.5",
115
- "@types/react": "19.2.8",
118
+ "@types/node": "^25.4.0",
119
+ "@types/nodemailer": "^7.0.11",
120
+ "@types/react": "19.2.14",
116
121
  "@types/react-dom": "19.2.3",
117
- "better-auth": "^1.4.10",
118
- "better-sqlite3": "^12.6.0",
122
+ "better-auth": "^1.5.5",
123
+ "better-sqlite3": "^12.6.2",
119
124
  "concurrently": "^9.2.1",
120
125
  "copyfiles": "2.4.1",
121
126
  "cross-env": "^10.1.0",
122
127
  "dotenv-cli": "^11.0.0",
123
- "drizzle-orm": "^0.44.0",
124
- "eslint": "^9.39.2",
125
- "eslint-config-next": "16.1.1",
128
+ "drizzle-orm": "^0.45.1",
129
+ "eslint": "^9.39.4",
130
+ "eslint-config-next": "16.1.6",
126
131
  "execa": "^9.6.1",
127
- "graphql": "^16.12.0",
132
+ "graphql": "^16.13.1",
133
+ "hono": "^4.12.8",
128
134
  "husky": "^9.1.7",
129
- "ioredis": "^5.6.1",
135
+ "ioredis": "^5.10.0",
130
136
  "maildev": "^2.2.1",
131
137
  "mongodb-memory-server": "11.0.1",
132
- "next": "16.1.1",
133
- "nodemailer": "^7.0.12",
134
- "nx": "^22.3.3",
138
+ "next": "16.1.6",
139
+ "nodemailer": "^8.0.2",
140
+ "nx": "^22.5.4",
135
141
  "open": "^11.0.0",
136
- "payload": "^3.70.0",
137
- "prettier": "^3.7.4",
142
+ "payload": "^3.79.0",
143
+ "prettier": "^3.8.1",
138
144
  "qs-esm": "7.0.3",
139
- "react": "19.2.3",
140
- "react-dom": "19.2.3",
141
- "rimraf": "6.1.2",
142
- "semantic-release": "^25.0.2",
145
+ "react": "19.2.4",
146
+ "react-dom": "19.2.4",
147
+ "rimraf": "6.1.3",
148
+ "semantic-release": "^25.0.3",
143
149
  "sharp": "0.34.5",
144
- "sort-package-json": "^3.6.0",
150
+ "sort-package-json": "^3.6.1",
151
+ "tsx": "^4.21.0",
145
152
  "typescript": "5.9.3",
146
- "vite-tsconfig-paths": "^6.0.4",
147
- "vitest": "^4.0.16"
153
+ "vite-tsconfig-paths": "^6.1.1",
154
+ "vitest": "^4.1.0"
148
155
  },
149
156
  "peerDependencies": {
150
- "better-auth": "^1.4.10",
151
- "payload": "^3.37.0"
157
+ "better-auth": "^1.5.5",
158
+ "payload": "^3.79.0"
152
159
  },
153
160
  "packageManager": "pnpm@10.16.1",
154
161
  "engines": {
@@ -1,9 +1,8 @@
1
1
  // src/plugins/reconcile-queue-plugin.ts
2
- import type { AuthContext, BetterAuthPlugin, DeepPartial } from 'better-auth'
2
+ import type { AuthContext, BetterAuthPlugin } from 'better-auth'
3
3
  import type { SanitizedConfig } from 'payload'
4
4
 
5
- import { APIError } from 'better-auth/api'
6
- import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins'
5
+ import { APIError, createAuthEndpoint, createAuthMiddleware } from 'better-auth/api'
7
6
 
8
7
  import type { EventBus } from '../eventBus/types'
9
8
  import type { SecondaryStorage } from '../storage/types'
@@ -109,9 +108,9 @@ function createQueueBasedHooks(queue: Queue) {
109
108
  return {
110
109
  user: {
111
110
  create: {
112
- after: (user: BAUser): Promise<void> => {
111
+ after: async (user: BAUser): Promise<void> => {
113
112
  queue.enqueueEnsure(user, true, 'user-operation')
114
- return Promise.resolve()
113
+ await queue.runEnsureNow(user)
115
114
  },
116
115
  },
117
116
  delete: {
@@ -121,9 +120,9 @@ function createQueueBasedHooks(queue: Queue) {
121
120
  },
122
121
  },
123
122
  update: {
124
- after: (user: BAUser): Promise<void> => {
123
+ after: async (user: BAUser): Promise<void> => {
125
124
  queue.enqueueEnsure(user, true, 'user-operation')
126
- return Promise.resolve()
125
+ await queue.runEnsureNow(user)
127
126
  },
128
127
  },
129
128
  },
@@ -166,9 +165,12 @@ export const payloadBetterAuthPlugin = <
166
165
  endpoints: {
167
166
  // convenience for tests/admin tools (optional)
168
167
  authMethods: createAuthEndpoint(
169
- '/auth/methods',
168
+ '/methods',
170
169
  { method: 'GET' },
171
- async ({ context, json }) => {
170
+ async ({
171
+ context,
172
+ json,
173
+ }) => {
172
174
  const authMethods: AuthMethod[] = []
173
175
  // Check if emailAndPassword is enabled, or if present at all (not present defaults to false)
174
176
  if (context.options.emailAndPassword?.enabled) {
@@ -189,7 +191,11 @@ export const payloadBetterAuthPlugin = <
189
191
  deleteNow: createAuthEndpoint(
190
192
  '/reconcile/delete',
191
193
  { method: 'POST' },
192
- async ({ context, json, request }) => {
194
+ async ({
195
+ context,
196
+ json,
197
+ request,
198
+ }) => {
193
199
  if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
194
200
  throw new APIError('UNAUTHORIZED', { message: 'invalid token' })
195
201
  }
@@ -209,7 +215,11 @@ export const payloadBetterAuthPlugin = <
209
215
  ensureNow: createAuthEndpoint(
210
216
  '/reconcile/ensure',
211
217
  { method: 'POST' },
212
- async ({ context, json, request }) => {
218
+ async ({
219
+ context,
220
+ json,
221
+ request,
222
+ }) => {
213
223
  if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
214
224
  throw new APIError('UNAUTHORIZED', { message: 'invalid token' })
215
225
  }
@@ -229,7 +239,11 @@ export const payloadBetterAuthPlugin = <
229
239
  run: createAuthEndpoint(
230
240
  '/reconcile/run',
231
241
  { method: 'POST' },
232
- async ({ context, json, request }) => {
242
+ async ({
243
+ context,
244
+ json,
245
+ request,
246
+ }) => {
233
247
  if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
234
248
  throw new APIError('UNAUTHORIZED', { message: 'invalid token' })
235
249
  }
@@ -240,7 +254,11 @@ export const payloadBetterAuthPlugin = <
240
254
  status: createAuthEndpoint(
241
255
  '/reconcile/status',
242
256
  { method: 'GET' },
243
- async ({ context, json, request }) => {
257
+ async ({
258
+ context,
259
+ json,
260
+ request,
261
+ }) => {
244
262
  if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
245
263
  return Promise.reject(
246
264
  new APIError('UNAUTHORIZED', { message: 'invalid token' }) as Error,
@@ -251,22 +269,29 @@ export const payloadBetterAuthPlugin = <
251
269
  ),
252
270
  // Warmup endpoint - triggers plugin initialization without auth
253
271
  // Returns basic instance info
254
- warmup: createAuthEndpoint('/warmup', { method: 'GET' }, async ({ context, json }) => {
255
- const authMethods: string[] = []
256
- if (context.options.emailAndPassword?.enabled) {
257
- authMethods.push('emailAndPassword')
258
- }
259
- if (context.options.plugins?.some((p) => p.id === 'magic-link')) {
260
- authMethods.push('magicLink')
261
- }
272
+ warmup: createAuthEndpoint(
273
+ '/warmup',
274
+ { method: 'GET' },
275
+ async ({
276
+ context,
277
+ json,
278
+ }) => {
279
+ const authMethods: string[] = []
280
+ if (context.options.emailAndPassword?.enabled) {
281
+ authMethods.push('emailAndPassword')
282
+ }
283
+ if (context.options.plugins?.some((p) => p.id === 'magic-link')) {
284
+ authMethods.push('magicLink')
285
+ }
262
286
 
263
- return json({
264
- authMethods,
265
- initialized: true,
266
- pluginId: 'reconcile-queue-plugin',
267
- timestamp: new Date().toISOString(),
268
- })
269
- }),
287
+ return json({
288
+ authMethods,
289
+ initialized: true,
290
+ pluginId: 'reconcile-queue-plugin',
291
+ timestamp: new Date().toISOString(),
292
+ })
293
+ },
294
+ ),
270
295
  },
271
296
  hooks: {
272
297
  before: [
@@ -295,7 +320,8 @@ export const payloadBetterAuthPlugin = <
295
320
  // 1. Explicitly set via useSecureCookies option
296
321
  // 2. NODE_ENV is 'production'
297
322
  // 3. baseURL starts with 'https://'
298
- const isHttps = options.baseURL?.startsWith('https://') ?? false
323
+ const baseUrlStr = typeof options.baseURL === 'string' ? options.baseURL : undefined
324
+ const isHttps = baseUrlStr?.startsWith('https://') ?? false
299
325
  const useSecureCookies =
300
326
  options.advanced?.useSecureCookies ?? (process.env.NODE_ENV === 'production' || isHttps)
301
327
 
@@ -418,7 +444,7 @@ export const payloadBetterAuthPlugin = <
418
444
  const queueBasedHooks = createQueueBasedHooks(queue)
419
445
 
420
446
  return {
421
- context: { payloadSyncPlugin: { queue } } as DeepPartial<Omit<AuthContext, 'options'>>,
447
+ context: { payloadSyncPlugin: { queue } },
422
448
  options: {
423
449
  databaseHooks: queueBasedHooks,
424
450
  // Pass storage to Better Auth as secondaryStorage - this makes BA write sessions
@@ -438,5 +464,5 @@ export const payloadBetterAuthPlugin = <
438
464
  },
439
465
  },
440
466
  },
441
- }
467
+ } satisfies BetterAuthPlugin
442
468
  }
@@ -160,32 +160,35 @@ export class Queue {
160
160
  return { total, users }
161
161
  }
162
162
 
163
- private async runTask(t: Task) {
163
+ private async runEnsure(baUser: BAUser): Promise<void> {
164
164
  const log = this.deps?.log ?? (() => {})
165
- if (t.kind === 'ensure') {
166
- log('queue.ensure', { attempts: t.attempts, baId: t.baId })
165
+ const baId = baUser.id
167
166
 
168
- // Get user data (either from task or fetch from BA)
169
- const baUser = t.baUser ?? { id: t.baId }
167
+ // Fetch accounts from Better Auth for this user
168
+ const accounts = await this.deps.internalAdapter.findAccounts(baId)
170
169
 
171
- // Fetch accounts from Better Auth for this user
172
- const accounts = await this.deps.internalAdapter.findAccounts(t.baId)
170
+ log('queue.ensure.accounts', {
171
+ accountCount: accounts?.length ?? 0,
172
+ accounts: accounts?.map((a) => ({ id: a.id, providerId: a.providerId })),
173
+ baId,
174
+ })
173
175
 
174
- // Debug: log what accounts were found
175
- log('queue.ensure.accounts', {
176
- accountCount: accounts?.length ?? 0,
177
- accounts: accounts?.map((a) => ({ id: a.id, providerId: a.providerId })),
178
- baId: t.baId,
179
- })
176
+ await this.deps.syncUserToPayload(baUser, accounts as BetterAuthAccount[])
177
+ }
180
178
 
181
- // Sync user with accounts to Payload
182
- await this.deps.syncUserToPayload(baUser, accounts as BetterAuthAccount[])
179
+ private async runTask(t: Task) {
180
+ const log = this.deps?.log ?? (() => {})
181
+ if (t.kind === 'ensure') {
182
+ log('queue.ensure', { attempts: t.attempts, baId: t.baId })
183
+ const baUser = t.baUser ?? { id: t.baId }
184
+ await this.runEnsure(baUser)
183
185
  return
184
186
  }
185
187
  // delete
186
188
  log('queue.delete', { attempts: t.attempts, baId: t.baId })
187
189
  await this.deps.deleteUserFromPayload(t.baId)
188
190
  }
191
+
189
192
  private scheduleNextReconcile() {
190
193
  if (this.reconcileTimeout) {
191
194
  clearTimeout(this.reconcileTimeout)
@@ -211,7 +214,6 @@ export class Queue {
211
214
  this.reconcileTimeout.unref()
212
215
  }
213
216
  }
214
-
215
217
  /** Paginated approach: process users page by page to reduce memory usage */
216
218
  private async seedFullReconcilePaginated(reconcileId: string) {
217
219
  const log = this.deps?.log ?? (() => {})
@@ -350,6 +352,15 @@ export class Queue {
350
352
  )
351
353
  }
352
354
 
355
+ /**
356
+ * Run ensure (sync user to Payload) immediately without waiting for the queue tick.
357
+ * Used from database hooks so that e.g. magic-link redirect sees the user in Payload
358
+ * before the response is sent. The task remains enqueued for idempotent retry.
359
+ */
360
+ async runEnsureNow(user: BAUser): Promise<void> {
361
+ await this.runEnsure(user)
362
+ }
363
+
353
364
  /** Seed tasks by comparing users page by page (Better-Auth → Payload). */
354
365
  async seedFullReconcile() {
355
366
  const log = this.deps?.log ?? (() => {})
@@ -231,7 +231,7 @@ function createBetterAuthStrategy(storage: SecondaryStorage, _prefix: string) {
231
231
  })
232
232
 
233
233
  if (existing.docs[0]) {
234
- return { user: { collection: 'users' as const, ...existing.docs[0] } }
234
+ return { user: { ...existing.docs[0], collection: 'users' as const } }
235
235
  }
236
236
  // User not found in Payload, try next token
237
237
  }
@@ -1,11 +1,11 @@
1
- import type { ClientOptions } from 'better-auth'
1
+ import type { BetterAuthClientOptions } from 'better-auth'
2
2
  import type React from 'react'
3
3
 
4
4
  import type { AuthMethod } from '../better-auth/helpers'
5
5
 
6
6
  import { EmailPasswordFormClient } from './EmailPasswordFormClient'
7
7
 
8
- export type AuthClientOptions = { baseURL: string } & Omit<ClientOptions, 'baseURL'>
8
+ export type AuthClientOptions = { baseURL: string } & Omit<BetterAuthClientOptions, 'baseURL'>
9
9
 
10
10
  export async function fetchAuthMethods({
11
11
  additionalHeaders,
@@ -18,7 +18,7 @@ export async function fetchAuthMethods({
18
18
  }): Promise<{ data: AuthMethod[]; error: null } | { data: null; error: Error }> {
19
19
  const headers = new Headers(additionalHeaders)
20
20
  headers.append('Content-Type', 'application/json')
21
- const url = `${betterAuthBaseUrl}/api/auth/auth/methods`
21
+ const url = `${betterAuthBaseUrl}/api/auth/methods`
22
22
 
23
23
  if (debug) {
24
24
  console.log('[payload-better-auth] fetchAuthMethods: Attempting to fetch auth methods')
@@ -1,4 +1,4 @@
1
- import type { ClientOptions } from 'better-auth'
1
+ import type { BetterAuthClientOptions as AuthClientOptions } from 'better-auth'
2
2
  import type { Access, CollectionConfig, Config } from 'payload'
3
3
 
4
4
  import type { EventBus } from '../eventBus/types'
@@ -23,7 +23,7 @@ export type BetterAuthClientOptions = {
23
23
  * @example 'http://auth-service:3000'
24
24
  */
25
25
  internalBaseURL: string
26
- } & Omit<ClientOptions, 'baseURL'>
26
+ } & Omit<AuthClientOptions, 'baseURL'>
27
27
 
28
28
  export type BetterAuthPayloadPluginOptions = {
29
29
  /**