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.
- package/README.md +65 -2
- package/dist/better-auth/plugin.d.ts +31 -9
- package/dist/better-auth/plugin.js +62 -67
- package/dist/better-auth/plugin.js.map +1 -1
- package/dist/better-auth/reconcile-queue.d.ts +10 -3
- package/dist/better-auth/reconcile-queue.js +20 -6
- package/dist/better-auth/reconcile-queue.js.map +1 -1
- package/dist/better-auth/sources.d.ts +15 -7
- package/dist/better-auth/sources.js +260 -33
- package/dist/better-auth/sources.js.map +1 -1
- package/dist/collections/BetterAuth/emailPassword.d.ts +30 -0
- package/dist/collections/BetterAuth/emailPassword.js +84 -0
- package/dist/collections/BetterAuth/emailPassword.js.map +1 -0
- package/dist/collections/BetterAuth/index.d.ts +3 -0
- package/dist/collections/BetterAuth/index.js +5 -0
- package/dist/collections/BetterAuth/index.js.map +1 -0
- package/dist/collections/BetterAuth/magicLink.d.ts +30 -0
- package/dist/collections/BetterAuth/magicLink.js +84 -0
- package/dist/collections/BetterAuth/magicLink.js.map +1 -0
- package/dist/collections/BetterAuth/shared.d.ts +83 -0
- package/dist/collections/BetterAuth/shared.js +113 -0
- package/dist/collections/BetterAuth/shared.js.map +1 -0
- package/dist/collections/Users/index.d.ts +19 -8
- package/dist/collections/Users/index.js +266 -154
- package/dist/collections/Users/index.js.map +1 -1
- package/dist/components/LogoutButtonClient.d.ts +9 -0
- package/dist/components/LogoutButtonClient.js +91 -0
- package/dist/components/LogoutButtonClient.js.map +1 -0
- package/dist/eventBus/SqlitePollingEventBus.d.ts +0 -18
- package/dist/eventBus/SqlitePollingEventBus.js +64 -21
- package/dist/eventBus/SqlitePollingEventBus.js.map +1 -1
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.js +1 -0
- package/dist/exports/client.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/payload/plugin.d.ts +25 -3
- package/dist/payload/plugin.js +84 -13
- package/dist/payload/plugin.js.map +1 -1
- package/dist/storage/SqliteStorage.js +49 -12
- package/dist/storage/SqliteStorage.js.map +1 -1
- package/package.json +3 -2
- package/src/better-auth/plugin.ts +123 -75
- package/src/better-auth/reconcile-queue.ts +37 -13
- package/src/better-auth/sources.ts +238 -32
- package/src/collections/BetterAuth/emailPassword.ts +115 -0
- package/src/collections/BetterAuth/index.ts +11 -0
- package/src/collections/BetterAuth/magicLink.ts +115 -0
- package/src/collections/BetterAuth/shared.ts +130 -0
- package/src/collections/Users/index.ts +308 -146
- package/src/components/LogoutButtonClient.tsx +99 -0
- package/src/eventBus/SqlitePollingEventBus.ts +81 -26
- package/src/exports/client.ts +1 -0
- package/src/index.ts +17 -1
- package/src/payload/plugin.ts +130 -13
- 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 {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
193
|
-
if (
|
|
194
|
-
this.enqueueDelete(
|
|
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
|
|
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;
|