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 +4 -0
- package/dist/better-auth/plugin.js +8 -8
- package/dist/better-auth/plugin.js.map +1 -1
- package/dist/better-auth/reconcile-queue.d.ts +7 -0
- package/dist/better-auth/reconcile-queue.js +23 -14
- package/dist/better-auth/reconcile-queue.js.map +1 -1
- package/dist/collections/Users/index.js +2 -2
- package/dist/collections/Users/index.js.map +1 -1
- package/dist/components/BetterAuthLoginServer.d.ts +2 -2
- package/dist/components/BetterAuthLoginServer.js +1 -1
- package/dist/components/BetterAuthLoginServer.js.map +1 -1
- package/dist/payload/plugin.d.ts +2 -2
- package/dist/payload/plugin.js.map +1 -1
- package/package.json +52 -45
- package/src/better-auth/plugin.ts +57 -31
- package/src/better-auth/reconcile-queue.ts +27 -16
- package/src/collections/Users/index.ts +1 -1
- package/src/components/BetterAuthLoginServer.tsx +3 -3
- package/src/payload/plugin.ts +2 -2
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
|
-
|
|
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
|
-
|
|
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('/
|
|
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
|
|
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
|
-
|
|
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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"}
|
|
@@ -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 {
|
|
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<
|
|
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/
|
|
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 {
|
|
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"}
|
package/dist/payload/plugin.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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<
|
|
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.
|
|
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
|
|
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/
|
|
95
|
-
"@
|
|
96
|
-
"@commitlint/
|
|
97
|
-
"@
|
|
98
|
-
"@
|
|
99
|
-
"@
|
|
100
|
-
"@payloadcms/db-
|
|
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.
|
|
103
|
-
"@payloadcms/richtext-lexical": "^3.
|
|
104
|
-
"@payloadcms/ui": "^3.
|
|
105
|
-
"@playwright/test": "^1.
|
|
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.
|
|
109
|
-
"@semantic-release/npm": "^13.1.
|
|
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.
|
|
116
|
+
"@swc/cli": "0.8.0",
|
|
112
117
|
"@types/better-sqlite3": "^7.6.13",
|
|
113
|
-
"@types/node": "^25.0
|
|
114
|
-
"@types/nodemailer": "^7.0.
|
|
115
|
-
"@types/react": "19.2.
|
|
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.
|
|
118
|
-
"better-sqlite3": "^12.6.
|
|
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.
|
|
124
|
-
"eslint": "^9.39.
|
|
125
|
-
"eslint-config-next": "16.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.
|
|
132
|
+
"graphql": "^16.13.1",
|
|
133
|
+
"hono": "^4.12.8",
|
|
128
134
|
"husky": "^9.1.7",
|
|
129
|
-
"ioredis": "^5.
|
|
135
|
+
"ioredis": "^5.10.0",
|
|
130
136
|
"maildev": "^2.2.1",
|
|
131
137
|
"mongodb-memory-server": "11.0.1",
|
|
132
|
-
"next": "16.1.
|
|
133
|
-
"nodemailer": "^
|
|
134
|
-
"nx": "^22.
|
|
138
|
+
"next": "16.1.6",
|
|
139
|
+
"nodemailer": "^8.0.2",
|
|
140
|
+
"nx": "^22.5.4",
|
|
135
141
|
"open": "^11.0.0",
|
|
136
|
-
"payload": "^3.
|
|
137
|
-
"prettier": "^3.
|
|
142
|
+
"payload": "^3.79.0",
|
|
143
|
+
"prettier": "^3.8.1",
|
|
138
144
|
"qs-esm": "7.0.3",
|
|
139
|
-
"react": "19.2.
|
|
140
|
-
"react-dom": "19.2.
|
|
141
|
-
"rimraf": "6.1.
|
|
142
|
-
"semantic-release": "^25.0.
|
|
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.
|
|
150
|
+
"sort-package-json": "^3.6.1",
|
|
151
|
+
"tsx": "^4.21.0",
|
|
145
152
|
"typescript": "5.9.3",
|
|
146
|
-
"vite-tsconfig-paths": "^6.
|
|
147
|
-
"vitest": "^4.0
|
|
153
|
+
"vite-tsconfig-paths": "^6.1.1",
|
|
154
|
+
"vitest": "^4.1.0"
|
|
148
155
|
},
|
|
149
156
|
"peerDependencies": {
|
|
150
|
-
"better-auth": "^1.
|
|
151
|
-
"payload": "^3.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'/
|
|
168
|
+
'/methods',
|
|
170
169
|
{ method: 'GET' },
|
|
171
|
-
async ({
|
|
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 ({
|
|
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 ({
|
|
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 ({
|
|
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 ({
|
|
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(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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 } }
|
|
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
|
|
163
|
+
private async runEnsure(baUser: BAUser): Promise<void> {
|
|
164
164
|
const log = this.deps?.log ?? (() => {})
|
|
165
|
-
|
|
166
|
-
log('queue.ensure', { attempts: t.attempts, baId: t.baId })
|
|
165
|
+
const baId = baUser.id
|
|
167
166
|
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
// Fetch accounts from Better Auth for this user
|
|
168
|
+
const accounts = await this.deps.internalAdapter.findAccounts(baId)
|
|
170
169
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
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 {
|
|
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<
|
|
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/
|
|
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')
|
package/src/payload/plugin.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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<
|
|
26
|
+
} & Omit<AuthClientOptions, 'baseURL'>
|
|
27
27
|
|
|
28
28
|
export type BetterAuthPayloadPluginOptions = {
|
|
29
29
|
/**
|