payload-better-auth 2.1.0 → 3.1.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.
Files changed (57) hide show
  1. package/README.md +65 -2
  2. package/dist/better-auth/plugin.d.ts +31 -9
  3. package/dist/better-auth/plugin.js +62 -67
  4. package/dist/better-auth/plugin.js.map +1 -1
  5. package/dist/better-auth/reconcile-queue.d.ts +10 -3
  6. package/dist/better-auth/reconcile-queue.js +20 -6
  7. package/dist/better-auth/reconcile-queue.js.map +1 -1
  8. package/dist/better-auth/sources.d.ts +15 -7
  9. package/dist/better-auth/sources.js +260 -33
  10. package/dist/better-auth/sources.js.map +1 -1
  11. package/dist/collections/BetterAuth/emailPassword.d.ts +30 -0
  12. package/dist/collections/BetterAuth/emailPassword.js +84 -0
  13. package/dist/collections/BetterAuth/emailPassword.js.map +1 -0
  14. package/dist/collections/BetterAuth/index.d.ts +3 -0
  15. package/dist/collections/BetterAuth/index.js +5 -0
  16. package/dist/collections/BetterAuth/index.js.map +1 -0
  17. package/dist/collections/BetterAuth/magicLink.d.ts +30 -0
  18. package/dist/collections/BetterAuth/magicLink.js +84 -0
  19. package/dist/collections/BetterAuth/magicLink.js.map +1 -0
  20. package/dist/collections/BetterAuth/shared.d.ts +83 -0
  21. package/dist/collections/BetterAuth/shared.js +113 -0
  22. package/dist/collections/BetterAuth/shared.js.map +1 -0
  23. package/dist/collections/Users/index.d.ts +19 -8
  24. package/dist/collections/Users/index.js +266 -154
  25. package/dist/collections/Users/index.js.map +1 -1
  26. package/dist/components/LogoutButtonClient.d.ts +9 -0
  27. package/dist/components/LogoutButtonClient.js +91 -0
  28. package/dist/components/LogoutButtonClient.js.map +1 -0
  29. package/dist/eventBus/SqlitePollingEventBus.d.ts +0 -18
  30. package/dist/eventBus/SqlitePollingEventBus.js +64 -21
  31. package/dist/eventBus/SqlitePollingEventBus.js.map +1 -1
  32. package/dist/exports/client.d.ts +1 -0
  33. package/dist/exports/client.js +1 -0
  34. package/dist/exports/client.js.map +1 -1
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/payload/plugin.d.ts +25 -3
  39. package/dist/payload/plugin.js +84 -13
  40. package/dist/payload/plugin.js.map +1 -1
  41. package/dist/storage/SqliteStorage.js +49 -12
  42. package/dist/storage/SqliteStorage.js.map +1 -1
  43. package/package.json +3 -2
  44. package/src/better-auth/plugin.ts +123 -75
  45. package/src/better-auth/reconcile-queue.ts +37 -13
  46. package/src/better-auth/sources.ts +238 -32
  47. package/src/collections/BetterAuth/emailPassword.ts +115 -0
  48. package/src/collections/BetterAuth/index.ts +11 -0
  49. package/src/collections/BetterAuth/magicLink.ts +115 -0
  50. package/src/collections/BetterAuth/shared.ts +130 -0
  51. package/src/collections/Users/index.ts +308 -146
  52. package/src/components/LogoutButtonClient.tsx +99 -0
  53. package/src/eventBus/SqlitePollingEventBus.ts +81 -26
  54. package/src/exports/client.ts +1 -0
  55. package/src/index.ts +17 -1
  56. package/src/payload/plugin.ts +130 -13
  57. package/src/storage/SqliteStorage.ts +57 -12
package/README.md CHANGED
@@ -11,6 +11,8 @@ A Payload CMS plugin that integrates [Better Auth](https://better-auth.com) for
11
11
  - **Horizontal Scaling** — Redis adapter supports multiple instances
12
12
  - **Timestamp-based Coordination** — Automatic reconciliation without race conditions
13
13
  - **Custom Login UI** — Replaces Payload's default login with Better Auth authentication
14
+ - **Auto-extending Users Collection** — Plugin extends your existing users collection with auth integration
15
+ - **Better Auth Collections** — Dedicated collections for each auth method (email-password, magic-link)
14
16
 
15
17
  ## Installation
16
18
 
@@ -48,6 +50,7 @@ import { betterAuth } from 'better-auth'
48
50
  import { admin, apiKey } from 'better-auth/plugins'
49
51
  import Database from 'better-sqlite3'
50
52
  import { payloadBetterAuthPlugin } from 'payload-better-auth'
53
+ import type { User } from './payload-types' // Generated Payload types
51
54
  import buildConfig from './payload.config.js'
52
55
  import { eventBus } from './eventBus'
53
56
  import { storage } from './syncAdapter'
@@ -59,11 +62,17 @@ export const auth = betterAuth({
59
62
  plugins: [
60
63
  admin(),
61
64
  apiKey(),
62
- payloadBetterAuthPlugin({
65
+ payloadBetterAuthPlugin<User>({
63
66
  payloadConfig: buildConfig,
64
67
  token: process.env.RECONCILE_TOKEN,
65
68
  storage, // Shared with Payload plugin
66
69
  eventBus, // Shared with Payload plugin
70
+ // Map Better Auth user data to your Payload user fields
71
+ mapUserToPayload: (baUser) => ({
72
+ email: baUser.email ?? '',
73
+ name: baUser.name ?? '',
74
+ // Add defaults for any required fields in your users collection
75
+ }),
67
76
  }),
68
77
  ],
69
78
  })
@@ -79,6 +88,22 @@ import { eventBus } from './lib/eventBus'
79
88
  import { storage } from './lib/syncAdapter'
80
89
 
81
90
  export default buildConfig({
91
+ collections: [
92
+ // Optional: Define your own users collection - it will be auto-extended
93
+ {
94
+ slug: 'users',
95
+ fields: [
96
+ { name: 'email', type: 'email', required: true },
97
+ { name: 'name', type: 'text' },
98
+ // Add your custom fields...
99
+ ],
100
+ // Your access rules are preserved and OR'd with BA sync access
101
+ access: {
102
+ read: ({ req }) => Boolean(req.user),
103
+ },
104
+ },
105
+ // ... other collections
106
+ ],
82
107
  plugins: [
83
108
  betterAuthPayloadPlugin({
84
109
  betterAuthClientOptions: {
@@ -87,12 +112,21 @@ export default buildConfig({
87
112
  },
88
113
  storage, // Shared with Better Auth plugin
89
114
  eventBus, // Shared with Better Auth plugin
115
+ collectionPrefix: '__better_auth', // optional, this is the default
116
+ debug: false, // Enable to see BA collections in admin panel
117
+ // Optional: Custom access for BA collections
118
+ baCollectionsAccess: {
119
+ read: ({ req }) => req.user?.role === 'admin',
120
+ delete: ({ req }) => req.user?.role === 'admin',
121
+ },
90
122
  }),
91
123
  ],
92
124
  // ... rest of your config
93
125
  })
94
126
  ```
95
127
 
128
+ If you don't define a users collection, a minimal one will be created automatically.
129
+
96
130
  ### 4. Set Environment Variables
97
131
 
98
132
  ```bash
@@ -104,6 +138,34 @@ PAYLOAD_SECRET=your-payload-secret
104
138
  DATABASE_URI=file:./payload.db
105
139
  ```
106
140
 
141
+ ## Access Control
142
+
143
+ Your access rules are preserved and combined with Better Auth's internal access. BA sync operations (signed with `BA_TO_PAYLOAD_SECRET`) always pass.
144
+
145
+ ```typescript
146
+ // Example: Allow admins to manage all users, regular users to read only
147
+ {
148
+ slug: 'users',
149
+ access: {
150
+ read: () => true, // everyone can read
151
+ create: ({ req }) => req.user?.role === 'admin', // only admins create manually
152
+ update: ({ req, id }) => req.user?.role === 'admin' || req.user?.id === id,
153
+ delete: ({ req }) => req.user?.role === 'admin',
154
+ },
155
+ }
156
+ // Result: BA sync operations pass via signature, manual operations use your rules
157
+ ```
158
+
159
+ ### Better Auth Collections Access
160
+
161
+ The plugin creates two additional collections for auth method data:
162
+ - `__better_auth_email_password` - Email/password account data
163
+ - `__better_auth_magic_link` - Magic link account data
164
+
165
+ These collections are locked down by default (only BA sync agent can access). You can optionally open up `read` and `delete` access.
166
+
167
+ > **Note:** These collections are hidden from the admin panel by default. Set `debug: true` in the Payload plugin options to make them visible under the "Better Auth (DEBUG)" group for troubleshooting.
168
+
107
169
  ## Production Setup with Redis
108
170
 
109
171
  For multi-server or geo-distributed deployments, use the Redis storage and EventBus adapters:
@@ -135,6 +197,7 @@ payloadBetterAuthPlugin({
135
197
  eventBus,
136
198
  payloadConfig: buildConfig,
137
199
  token: process.env.RECONCILE_TOKEN,
200
+ mapUserToPayload: (baUser) => ({ ... }),
138
201
  })
139
202
 
140
203
  // In Payload config:
@@ -241,7 +304,7 @@ pnpm add github:benjaminpreiss/payload-better-auth#v1.2.0
241
304
  │ ├── storage/ # SecondaryStorage implementations (SQLite, Redis)
242
305
  │ ├── eventBus/ # EventBus implementations (SQLite polling, Redis Pub/Sub)
243
306
  │ ├── better-auth/ # Better Auth integration & reconcile queue
244
- │ ├── collections/ # Payload collections (Users)
307
+ │ ├── collections/ # Payload collections (Users, BetterAuth)
245
308
  │ ├── components/ # React components (Login UI)
246
309
  │ ├── payload/ # Payload plugin
247
310
  │ ├── shared/ # Shared utilities (deduplicated logger)
@@ -1,14 +1,20 @@
1
- import type { AuthContext, BetterAuthPlugin } from 'better-auth';
1
+ import type { BetterAuthPlugin } from 'better-auth';
2
2
  import type { SanitizedConfig } from 'payload';
3
3
  import type { EventBus } from '../eventBus/types';
4
4
  import type { SecondaryStorage } from '../storage/types';
5
5
  import { type InitOptions } from './reconcile-queue';
6
- type CreateAdminsUser = Parameters<AuthContext['internalAdapter']['createUser']>['0'];
7
- export interface PayloadBetterAuthPluginOptions extends InitOptions {
8
- createAdmins?: {
9
- overwrite?: boolean;
10
- user: CreateAdminsUser;
11
- }[];
6
+ import { type BetterAuthUser } from './sources';
7
+ /**
8
+ * Type for the user data that will be written to Payload.
9
+ * Excludes auto-generated fields.
10
+ */
11
+ export type PayloadUserData<TUser extends object> = Omit<TUser, 'baUserId' | 'betterAuthAccounts' | 'createdAt' | 'id' | 'updatedAt'>;
12
+ export interface PayloadBetterAuthPluginOptions<TUser extends object = Record<string, unknown>, TCollectionSlug extends string = string> extends InitOptions {
13
+ /**
14
+ * Prefix for Better Auth collections in Payload (default: '__better_auth').
15
+ * The collections will be named: {prefix}_email_password, {prefix}_magic_link
16
+ */
17
+ collectionPrefix?: string;
12
18
  enableLogging?: boolean;
13
19
  /**
14
20
  * EventBus for timestamp-based coordination between plugins.
@@ -25,6 +31,18 @@ export interface PayloadBetterAuthPluginOptions extends InitOptions {
25
31
  * export const eventBus = createSqlitePollingEventBus({ db })
26
32
  */
27
33
  eventBus: EventBus;
34
+ /**
35
+ * Map Better Auth user data to Payload user fields.
36
+ * Called on create AND update - allows filling defaults for schema changes.
37
+ *
38
+ * @example
39
+ * mapUserToPayload: (baUser) => ({
40
+ * email: baUser.email ?? '',
41
+ * name: baUser.name ?? 'New User',
42
+ * role: 'user', // default for new required fields
43
+ * })
44
+ */
45
+ mapUserToPayload: (baUser: BetterAuthUser) => PayloadUserData<TUser>;
28
46
  payloadConfig: Promise<SanitizedConfig>;
29
47
  /**
30
48
  * Secondary storage for state coordination between Better Auth and Payload.
@@ -47,6 +65,10 @@ export interface PayloadBetterAuthPluginOptions extends InitOptions {
47
65
  */
48
66
  storage: SecondaryStorage;
49
67
  token: string;
68
+ /**
69
+ * Slug for the Payload users collection (default: 'users').
70
+ * Must match the collection slug defined in your Payload config.
71
+ */
72
+ usersSlug?: TCollectionSlug;
50
73
  }
51
- export declare const payloadBetterAuthPlugin: (opts: PayloadBetterAuthPluginOptions) => BetterAuthPlugin;
52
- export {};
74
+ export declare const payloadBetterAuthPlugin: <TUser extends object = Record<string, unknown>, TCollectionSlug extends string = string>(opts: PayloadBetterAuthPluginOptions<TUser, TCollectionSlug>) => BetterAuthPlugin;
@@ -36,7 +36,10 @@ const defaultLog = (msg, extra)=>{
36
36
  };
37
37
  }
38
38
  export const payloadBetterAuthPlugin = (opts)=>{
39
- const { eventBus, storage } = opts;
39
+ const { collectionPrefix = '__better_auth', eventBus, mapUserToPayload, storage, usersSlug = 'users' } = opts;
40
+ // Compute derived collection slugs
41
+ const emailPasswordSlug = `${collectionPrefix}_email_password`;
42
+ const magicLinkSlug = `${collectionPrefix}_magic_link`;
40
43
  // Create deduplicated logger
41
44
  const logger = createDeduplicatedLogger({
42
45
  enabled: opts.enableLogging ?? false,
@@ -50,29 +53,6 @@ export const payloadBetterAuthPlugin = (opts)=>{
50
53
  return {
51
54
  id: 'reconcile-queue-plugin',
52
55
  endpoints: {
53
- run: createAuthEndpoint('/reconcile/run', {
54
- method: 'POST'
55
- }, async ({ context, json, request })=>{
56
- if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
57
- throw new APIError('UNAUTHORIZED', {
58
- message: 'invalid token'
59
- });
60
- }
61
- await context.payloadSyncPlugin.queue.seedFullReconcile();
62
- return json({
63
- ok: true
64
- });
65
- }),
66
- status: createAuthEndpoint('/reconcile/status', {
67
- method: 'GET'
68
- }, async ({ context, json, request })=>{
69
- if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
70
- return Promise.reject(new APIError('UNAUTHORIZED', {
71
- message: 'invalid token'
72
- }));
73
- }
74
- return json(context.payloadSyncPlugin.queue.status());
75
- }),
76
56
  // convenience for tests/admin tools (optional)
77
57
  authMethods: createAuthEndpoint('/auth/methods', {
78
58
  method: 'GET'
@@ -135,6 +115,48 @@ export const payloadBetterAuthPlugin = (opts)=>{
135
115
  return json({
136
116
  ok: true
137
117
  });
118
+ }),
119
+ run: createAuthEndpoint('/reconcile/run', {
120
+ method: 'POST'
121
+ }, async ({ context, json, request })=>{
122
+ if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
123
+ throw new APIError('UNAUTHORIZED', {
124
+ message: 'invalid token'
125
+ });
126
+ }
127
+ await context.payloadSyncPlugin.queue.seedFullReconcile();
128
+ return json({
129
+ ok: true
130
+ });
131
+ }),
132
+ status: createAuthEndpoint('/reconcile/status', {
133
+ method: 'GET'
134
+ }, async ({ context, json, request })=>{
135
+ if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
136
+ return Promise.reject(new APIError('UNAUTHORIZED', {
137
+ message: 'invalid token'
138
+ }));
139
+ }
140
+ return json(context.payloadSyncPlugin.queue.status());
141
+ }),
142
+ // Warmup endpoint - triggers plugin initialization without auth
143
+ // Returns basic instance info
144
+ warmup: createAuthEndpoint('/warmup', {
145
+ method: 'GET'
146
+ }, async ({ context, json })=>{
147
+ const authMethods = [];
148
+ if (context.options.emailAndPassword?.enabled) {
149
+ authMethods.push('emailAndPassword');
150
+ }
151
+ if (context.options.plugins?.some((p)=>p.id === 'magic-link')) {
152
+ authMethods.push('magicLink');
153
+ }
154
+ return json({
155
+ authMethods,
156
+ initialized: true,
157
+ pluginId: 'reconcile-queue-plugin',
158
+ timestamp: new Date().toISOString()
159
+ });
138
160
  })
139
161
  },
140
162
  hooks: {
@@ -158,7 +180,9 @@ export const payloadBetterAuthPlugin = (opts)=>{
158
180
  }
159
181
  ]
160
182
  },
161
- async init ({ internalAdapter, options, password }) {
183
+ async init ({ internalAdapter, options }) {
184
+ // Always log init start for debugging
185
+ logger.always('Plugin init started');
162
186
  // Compute and store the session cookie name for Payload to read
163
187
  // This accounts for cookiePrefix, custom cookie names, and __Secure- prefix
164
188
  const cookiePrefix = options.advanced?.cookiePrefix ?? 'better-auth';
@@ -181,50 +205,15 @@ export const payloadBetterAuthPlugin = (opts)=>{
181
205
  // Store session cookie name in KV for Payload plugin to read
182
206
  await storage.set(SESSION_COOKIE_NAME_KEY, sessionCookieName);
183
207
  await logger.log('cookie-config', `Session cookie name: ${sessionCookieName}`);
184
- // Create admin users if configured
185
- if (opts.createAdmins) {
186
- try {
187
- await Promise.all(opts.createAdmins.map(async ({ overwrite, user })=>{
188
- const alreadyExistingUser = await internalAdapter.findUserByEmail(user.email);
189
- if (alreadyExistingUser) {
190
- if (overwrite) {
191
- // clear accounts
192
- await internalAdapter.deleteAccounts(alreadyExistingUser.user.id);
193
- const createdUser = await internalAdapter.updateUser(alreadyExistingUser.user.id, {
194
- ...user,
195
- role: 'admin'
196
- });
197
- await internalAdapter.linkAccount({
198
- accountId: createdUser.id,
199
- password: await password.hash(user.password),
200
- providerId: 'credential',
201
- userId: createdUser.id
202
- });
203
- }
204
- } else {
205
- const createdUser = await internalAdapter.createUser({
206
- ...user,
207
- role: 'admin'
208
- });
209
- await internalAdapter.linkAccount({
210
- accountId: createdUser.id,
211
- password: await password.hash(user.password),
212
- providerId: 'credential',
213
- userId: createdUser.id
214
- });
215
- }
216
- }));
217
- } catch (error) {
218
- logger.always('Failed to create Admin user', error);
219
- }
220
- }
221
208
  // Create the reconciliation queue
222
209
  const queue = new Queue({
223
- deleteUserFromPayload: createDeleteUserFromPayload(opts.payloadConfig),
210
+ collectionPrefix,
211
+ deleteUserFromPayload: createDeleteUserFromPayload(opts.payloadConfig, emailPasswordSlug, magicLinkSlug, usersSlug),
224
212
  internalAdapter,
225
- listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig),
213
+ listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig, usersSlug),
226
214
  log: queueLog,
227
- syncUserToPayload: createSyncUserToPayload(opts.payloadConfig)
215
+ mapUserToPayload,
216
+ syncUserToPayload: createSyncUserToPayload(opts.payloadConfig, emailPasswordSlug, magicLinkSlug, usersSlug, mapUserToPayload)
228
217
  }, {
229
218
  ...opts,
230
219
  // Don't run reconcile on boot - we use timestamp-based coordination instead
@@ -262,9 +251,13 @@ export const payloadBetterAuthPlugin = (opts)=>{
262
251
  const payloadTs = payloadTsStr ? parseInt(payloadTsStr, 10) : null;
263
252
  const baTs = baTsStr ? parseInt(baTsStr, 10) : null;
264
253
  // Determine reconciliation state
254
+ logger.always('Checking reconciliation state', {
255
+ baTs: baTs ? new Date(baTs).toISOString() : null,
256
+ payloadTs: payloadTs ? new Date(payloadTs).toISOString() : null
257
+ });
265
258
  if (payloadTs === null) {
266
259
  // Payload hasn't started yet
267
- await logger.log('status', 'Waiting for Payload to start...');
260
+ logger.always('Waiting for Payload to start...');
268
261
  unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', ()=>{
269
262
  attemptReconciliation().catch((err)=>{
270
263
  logger.always('Sync attempt failed', err);
@@ -272,17 +265,19 @@ export const payloadBetterAuthPlugin = (opts)=>{
272
265
  });
273
266
  } else if (baTs === null) {
274
267
  // First run - always sync
268
+ logger.always('First run - triggering initial sync');
275
269
  attemptReconciliation().catch((err)=>{
276
270
  logger.always('Initial sync failed', err);
277
271
  });
278
272
  } else if (payloadTs > baTs) {
279
273
  // Payload restarted since last reconcile - sync needed
274
+ logger.always('Payload restarted - triggering sync');
280
275
  attemptReconciliation().catch((err)=>{
281
276
  logger.always('Sync failed', err);
282
277
  });
283
278
  } else {
284
279
  // Already reconciled and up-to-date
285
- await logger.log('status', 'Already synchronized', {
280
+ logger.always('Already synchronized', {
286
281
  lastSync: new Date(baTs).toISOString()
287
282
  });
288
283
  unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', ()=>{
@@ -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 createDeleteUserFromPayload,\n createListPayloadUsersPage,\n createSyncUserToPayload,\n} from './sources'\n\ntype PayloadSyncPluginContext = { payloadSyncPlugin: { queue: Queue } } & AuthContext\n\ntype CreateAdminsUser = Parameters<AuthContext['internalAdapter']['createUser']>['0']\n\nconst defaultLog = (msg: string, extra?: unknown) => {\n console.log(`[reconcile] ${msg}`, extra ? JSON.stringify(extra, null, 2) : '')\n}\n\nexport interface PayloadBetterAuthPluginOptions extends InitOptions {\n createAdmins?: { overwrite?: boolean; user: CreateAdminsUser }[]\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 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\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 = (opts: PayloadBetterAuthPluginOptions): BetterAuthPlugin => {\n const { eventBus, storage } = opts\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 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 // 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 },\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, password }) {\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 admin users if configured\n if (opts.createAdmins) {\n try {\n await Promise.all(\n opts.createAdmins.map(async ({ overwrite, user }) => {\n const alreadyExistingUser = await internalAdapter.findUserByEmail(user.email)\n if (alreadyExistingUser) {\n if (overwrite) {\n // clear accounts\n await internalAdapter.deleteAccounts(alreadyExistingUser.user.id)\n const createdUser = await internalAdapter.updateUser(\n alreadyExistingUser.user.id,\n {\n ...user,\n role: 'admin',\n },\n )\n await internalAdapter.linkAccount({\n accountId: createdUser.id,\n password: await password.hash(user.password),\n providerId: 'credential',\n userId: createdUser.id,\n })\n }\n } else {\n const createdUser = await internalAdapter.createUser({ ...user, role: 'admin' })\n await internalAdapter.linkAccount({\n accountId: createdUser.id,\n password: await password.hash(user.password),\n providerId: 'credential',\n userId: createdUser.id,\n })\n }\n }),\n )\n } catch (error) {\n logger.always('Failed to create Admin user', error)\n }\n }\n\n // Create the reconciliation queue\n const queue = new Queue(\n {\n deleteUserFromPayload: createDeleteUserFromPayload(opts.payloadConfig),\n internalAdapter,\n listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig),\n log: queueLog,\n syncUserToPayload: createSyncUserToPayload(opts.payloadConfig),\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 if (payloadTs === null) {\n // Payload hasn't started yet\n await logger.log('status', '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 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 attemptReconciliation().catch((err) => {\n logger.always('Sync failed', err)\n })\n } else {\n // Already reconciled and up-to-date\n await logger.log('status', '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","eventBus","storage","logger","enabled","enableLogging","prefix","queueLog","undefined","unsubscribeFromPayload","endpoints","run","method","context","json","request","token","headers","get","message","payloadSyncPlugin","seedFullReconcile","ok","status","reject","authMethods","options","emailAndPassword","push","minPasswordLength","plugins","some","p","deleteNow","body","catch","baId","ensureNow","hooks","before","handler","ctx","locale","getHeader","matcher","path","init","internalAdapter","password","cookiePrefix","advanced","customCookieName","cookies","session_token","name","isHttps","baseURL","startsWith","useSecureCookies","process","env","NODE_ENV","sessionCookieName","baseName","set","createAdmins","all","map","overwrite","alreadyExistingUser","findUserByEmail","email","deleteAccounts","createdUser","updateUser","role","linkAccount","accountId","hash","providerId","userId","createUser","error","always","deleteUserFromPayload","payloadConfig","listPayloadUsersPage","syncUserToPayload","runOnBoot","attemptReconciliation","String","Date","now","subscribeToTimestamp","err","payloadTsStr","baTsStr","payloadTs","parseInt","baTs","lastSync","toISOString","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, 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,16 +1,23 @@
1
1
  import type { AuthContext } from 'better-auth';
2
- import type { BAUser, PayloadUser } from './sources';
2
+ import type { BAUser, BetterAuthAccount, BetterAuthUser, PayloadUser } from './sources';
3
3
  export interface QueueDeps {
4
+ /** Prefix for Better Auth collections */
5
+ collectionPrefix: string;
6
+ /** Delete user and associated BA collection entries from Payload */
4
7
  deleteUserFromPayload: (baId: string) => Promise<void>;
8
+ /** Better Auth internal adapter for fetching users and accounts */
5
9
  internalAdapter: AuthContext['internalAdapter'];
6
10
  listPayloadUsersPage: (limit: number, page: number) => Promise<{
7
11
  hasNextPage: boolean;
8
12
  total: number;
9
13
  users: PayloadUser[];
10
14
  }>;
11
- log?: (msg: string, extra?: any) => void;
15
+ log?: (msg: string, extra?: unknown) => void;
16
+ /** Map BA user to Payload user data */
17
+ mapUserToPayload: (baUser: BetterAuthUser) => Record<string, unknown>;
12
18
  prunePayloadOrphans?: boolean;
13
- syncUserToPayload: (baUser: BAUser) => Promise<void>;
19
+ /** Sync user and BA collection entries to Payload */
20
+ syncUserToPayload: (baUser: BAUser, accounts?: BetterAuthAccount[]) => Promise<void>;
14
21
  }
15
22
  export type TaskSource = 'full-reconcile' | 'user-operation';
16
23
  export interface InitOptions {
@@ -95,9 +95,23 @@ export class Queue {
95
95
  attempts: t.attempts,
96
96
  baId: t.baId
97
97
  });
98
- await this.deps.syncUserToPayload(t.baUser ?? {
98
+ // Get user data (either from task or fetch from BA)
99
+ const baUser = t.baUser ?? {
99
100
  id: t.baId
101
+ };
102
+ // Fetch accounts from Better Auth for this user
103
+ const accounts = await this.deps.internalAdapter.findAccounts(t.baId);
104
+ // Debug: log what accounts were found
105
+ log('queue.ensure.accounts', {
106
+ accountCount: accounts?.length ?? 0,
107
+ accounts: accounts?.map((a)=>({
108
+ id: a.id,
109
+ providerId: a.providerId
110
+ })),
111
+ baId: t.baId
100
112
  });
113
+ // Sync user with accounts to Payload
114
+ await this.deps.syncUserToPayload(baUser, accounts);
101
115
  return;
102
116
  }
103
117
  // delete
@@ -116,7 +130,7 @@ export class Queue {
116
130
  this.reconciling = true;
117
131
  try {
118
132
  await this.seedFullReconcile();
119
- } catch (error) {
133
+ } catch (_error) {
120
134
  // Error is already logged in seedFullReconcile
121
135
  } finally{
122
136
  this.reconciling = false;
@@ -189,9 +203,9 @@ export class Queue {
189
203
  const { hasNextPage: nextPage, users: pUsers } = await this.deps.listPayloadUsersPage(pageSize, payloadPage);
190
204
  hasNextPage = nextPage;
191
205
  for (const pu of pUsers){
192
- const ext = pu.externalId?.toString();
193
- if (ext && !baIdSet.has(ext)) {
194
- this.enqueueDelete(ext, false, 'full-reconcile', reconcileId);
206
+ const baId = pu.baUserId?.toString();
207
+ if (baId && !baIdSet.has(baId)) {
208
+ this.enqueueDelete(baId, false, 'full-reconcile', reconcileId);
195
209
  }
196
210
  }
197
211
  payloadPage++;
@@ -220,7 +234,7 @@ export class Queue {
220
234
  this.processed++;
221
235
  } catch (e) {
222
236
  this.failed++;
223
- this.lastError = e?.message ?? String(e);
237
+ this.lastError = e instanceof Error ? e.message : String(e);
224
238
  task.attempts += 1;
225
239
  const delay = Math.min(60_000, Math.pow(2, task.attempts) * 1000) + Math.floor(Math.random() * 500);
226
240
  task.nextAt = now + delay;