payload-better-auth 2.0.0 → 3.0.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 +83 -2
- package/dist/better-auth/plugin.d.ts +31 -9
- package/dist/better-auth/plugin.js +84 -68
- 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 +268 -156
- package/dist/collections/Users/index.js.map +1 -1
- 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/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 +71 -15
- package/dist/payload/plugin.js.map +1 -1
- package/dist/shared/deduplicatedLogger.js +1 -1
- package/dist/shared/deduplicatedLogger.js.map +1 -1
- package/dist/storage/SqliteStorage.js +49 -12
- package/dist/storage/SqliteStorage.js.map +1 -1
- package/dist/storage/keys.d.ts +12 -0
- package/dist/storage/keys.js +9 -0
- package/dist/storage/keys.js.map +1 -0
- package/package.json +9 -3
- package/src/better-auth/plugin.ts +147 -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 +313 -147
- package/src/eventBus/SqlitePollingEventBus.ts +81 -26
- package/src/index.ts +17 -1
- package/src/payload/plugin.ts +113 -16
- package/src/shared/deduplicatedLogger.ts +1 -1
- package/src/storage/SqliteStorage.ts +57 -12
- package/src/storage/keys.ts +16 -0
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:
|
|
@@ -166,6 +229,21 @@ pnpm dev
|
|
|
166
229
|
|
|
167
230
|
The dev server starts at [http://localhost:3000](http://localhost:3000) with a mail server at port 1080.
|
|
168
231
|
|
|
232
|
+
### Testing with Redis
|
|
233
|
+
|
|
234
|
+
To test the Redis integration locally:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
# Start Redis container
|
|
238
|
+
pnpm docker:redis
|
|
239
|
+
|
|
240
|
+
# Run dev server with Redis (instead of SQLite)
|
|
241
|
+
pnpm dev:redis
|
|
242
|
+
|
|
243
|
+
# Stop Redis when done
|
|
244
|
+
pnpm docker:redis:stop
|
|
245
|
+
```
|
|
246
|
+
|
|
169
247
|
### Git Hooks
|
|
170
248
|
|
|
171
249
|
This project uses [Husky](https://typicode.github.io/husky/) for Git hooks:
|
|
@@ -209,6 +287,9 @@ pnpm add github:benjaminpreiss/payload-better-auth#v1.2.0
|
|
|
209
287
|
| Script | Description |
|
|
210
288
|
|--------|-------------|
|
|
211
289
|
| `pnpm dev` | Start dev server with mail server |
|
|
290
|
+
| `pnpm dev:redis` | Start dev server with Redis (instead of SQLite) |
|
|
291
|
+
| `pnpm docker:redis` | Start Redis container via Docker Compose |
|
|
292
|
+
| `pnpm docker:redis:stop` | Stop Redis container |
|
|
212
293
|
| `pnpm build` | Build the plugin |
|
|
213
294
|
| `pnpm reset` | Reset databases and run all migrations |
|
|
214
295
|
| `pnpm test` | Run all tests |
|
|
@@ -223,7 +304,7 @@ pnpm add github:benjaminpreiss/payload-better-auth#v1.2.0
|
|
|
223
304
|
│ ├── storage/ # SecondaryStorage implementations (SQLite, Redis)
|
|
224
305
|
│ ├── eventBus/ # EventBus implementations (SQLite polling, Redis Pub/Sub)
|
|
225
306
|
│ ├── better-auth/ # Better Auth integration & reconcile queue
|
|
226
|
-
│ ├── collections/ # Payload collections (Users)
|
|
307
|
+
│ ├── collections/ # Payload collections (Users, BetterAuth)
|
|
227
308
|
│ ├── components/ # React components (Login UI)
|
|
228
309
|
│ ├── payload/ # Payload plugin
|
|
229
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;
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
import { APIError } from 'better-auth/api';
|
|
3
3
|
import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins';
|
|
4
4
|
import { createDeduplicatedLogger } from '../shared/deduplicatedLogger';
|
|
5
|
+
import { SESSION_COOKIE_NAME_KEY, TIMESTAMP_PREFIX } from '../storage/keys';
|
|
5
6
|
import { Queue } from './reconcile-queue';
|
|
6
7
|
import { createDeleteUserFromPayload, createListPayloadUsersPage, createSyncUserToPayload } from './sources';
|
|
7
8
|
const defaultLog = (msg, extra)=>{
|
|
8
9
|
console.log(`[reconcile] ${msg}`, extra ? JSON.stringify(extra, null, 2) : '');
|
|
9
10
|
};
|
|
10
|
-
// Key prefixes for storage
|
|
11
|
-
const TIMESTAMP_PREFIX = 'timestamp:';
|
|
12
11
|
/**
|
|
13
12
|
* Create database hooks that enqueue user changes to the reconciliation queue.
|
|
14
13
|
* All sync operations go through the queue for consistent handling with retries.
|
|
@@ -37,7 +36,10 @@ const TIMESTAMP_PREFIX = 'timestamp:';
|
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
38
|
export const payloadBetterAuthPlugin = (opts)=>{
|
|
40
|
-
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`;
|
|
41
43
|
// Create deduplicated logger
|
|
42
44
|
const logger = createDeduplicatedLogger({
|
|
43
45
|
enabled: opts.enableLogging ?? false,
|
|
@@ -51,29 +53,6 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
51
53
|
return {
|
|
52
54
|
id: 'reconcile-queue-plugin',
|
|
53
55
|
endpoints: {
|
|
54
|
-
run: createAuthEndpoint('/reconcile/run', {
|
|
55
|
-
method: 'POST'
|
|
56
|
-
}, async ({ context, json, request })=>{
|
|
57
|
-
if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
|
|
58
|
-
throw new APIError('UNAUTHORIZED', {
|
|
59
|
-
message: 'invalid token'
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
await context.payloadSyncPlugin.queue.seedFullReconcile();
|
|
63
|
-
return json({
|
|
64
|
-
ok: true
|
|
65
|
-
});
|
|
66
|
-
}),
|
|
67
|
-
status: createAuthEndpoint('/reconcile/status', {
|
|
68
|
-
method: 'GET'
|
|
69
|
-
}, async ({ context, json, request })=>{
|
|
70
|
-
if (opts.token && request?.headers.get('x-reconcile-token') !== opts.token) {
|
|
71
|
-
return Promise.reject(new APIError('UNAUTHORIZED', {
|
|
72
|
-
message: 'invalid token'
|
|
73
|
-
}));
|
|
74
|
-
}
|
|
75
|
-
return json(context.payloadSyncPlugin.queue.status());
|
|
76
|
-
}),
|
|
77
56
|
// convenience for tests/admin tools (optional)
|
|
78
57
|
authMethods: createAuthEndpoint('/auth/methods', {
|
|
79
58
|
method: 'GET'
|
|
@@ -136,6 +115,48 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
136
115
|
return json({
|
|
137
116
|
ok: true
|
|
138
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
|
+
});
|
|
139
160
|
})
|
|
140
161
|
},
|
|
141
162
|
hooks: {
|
|
@@ -159,51 +180,40 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
159
180
|
}
|
|
160
181
|
]
|
|
161
182
|
},
|
|
162
|
-
async init ({ internalAdapter,
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
} else {
|
|
184
|
-
const createdUser = await internalAdapter.createUser({
|
|
185
|
-
...user,
|
|
186
|
-
role: 'admin'
|
|
187
|
-
});
|
|
188
|
-
await internalAdapter.linkAccount({
|
|
189
|
-
accountId: createdUser.id,
|
|
190
|
-
password: await password.hash(user.password),
|
|
191
|
-
providerId: 'credential',
|
|
192
|
-
userId: createdUser.id
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
}));
|
|
196
|
-
} catch (error) {
|
|
197
|
-
logger.always('Failed to create Admin user', error);
|
|
198
|
-
}
|
|
183
|
+
async init ({ internalAdapter, options }) {
|
|
184
|
+
// Always log init start for debugging
|
|
185
|
+
logger.always('Plugin init started');
|
|
186
|
+
// Compute and store the session cookie name for Payload to read
|
|
187
|
+
// This accounts for cookiePrefix, custom cookie names, and __Secure- prefix
|
|
188
|
+
const cookiePrefix = options.advanced?.cookiePrefix ?? 'better-auth';
|
|
189
|
+
const customCookieName = options.advanced?.cookies?.session_token?.name;
|
|
190
|
+
// Better Auth uses secure cookies when:
|
|
191
|
+
// 1. Explicitly set via useSecureCookies option
|
|
192
|
+
// 2. NODE_ENV is 'production'
|
|
193
|
+
// 3. baseURL starts with 'https://'
|
|
194
|
+
const isHttps = options.baseURL?.startsWith('https://') ?? false;
|
|
195
|
+
const useSecureCookies = options.advanced?.useSecureCookies ?? (process.env.NODE_ENV === 'production' || isHttps);
|
|
196
|
+
let sessionCookieName;
|
|
197
|
+
if (customCookieName) {
|
|
198
|
+
// Custom cookie name takes precedence
|
|
199
|
+
sessionCookieName = useSecureCookies ? `__Secure-${customCookieName}` : customCookieName;
|
|
200
|
+
} else {
|
|
201
|
+
// Default format: {prefix}.session_token
|
|
202
|
+
const baseName = `${cookiePrefix}.session_token`;
|
|
203
|
+
sessionCookieName = useSecureCookies ? `__Secure-${baseName}` : baseName;
|
|
199
204
|
}
|
|
205
|
+
// Store session cookie name in KV for Payload plugin to read
|
|
206
|
+
await storage.set(SESSION_COOKIE_NAME_KEY, sessionCookieName);
|
|
207
|
+
await logger.log('cookie-config', `Session cookie name: ${sessionCookieName}`);
|
|
200
208
|
// Create the reconciliation queue
|
|
201
209
|
const queue = new Queue({
|
|
202
|
-
|
|
210
|
+
collectionPrefix,
|
|
211
|
+
deleteUserFromPayload: createDeleteUserFromPayload(opts.payloadConfig, emailPasswordSlug, magicLinkSlug, usersSlug),
|
|
203
212
|
internalAdapter,
|
|
204
|
-
listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig),
|
|
213
|
+
listPayloadUsersPage: createListPayloadUsersPage(opts.payloadConfig, usersSlug),
|
|
205
214
|
log: queueLog,
|
|
206
|
-
|
|
215
|
+
mapUserToPayload,
|
|
216
|
+
syncUserToPayload: createSyncUserToPayload(opts.payloadConfig, emailPasswordSlug, magicLinkSlug, usersSlug, mapUserToPayload)
|
|
207
217
|
}, {
|
|
208
218
|
...opts,
|
|
209
219
|
// Don't run reconcile on boot - we use timestamp-based coordination instead
|
|
@@ -241,9 +251,13 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
241
251
|
const payloadTs = payloadTsStr ? parseInt(payloadTsStr, 10) : null;
|
|
242
252
|
const baTs = baTsStr ? parseInt(baTsStr, 10) : null;
|
|
243
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
|
+
});
|
|
244
258
|
if (payloadTs === null) {
|
|
245
259
|
// Payload hasn't started yet
|
|
246
|
-
|
|
260
|
+
logger.always('Waiting for Payload to start...');
|
|
247
261
|
unsubscribeFromPayload = eventBus.subscribeToTimestamp('payload', ()=>{
|
|
248
262
|
attemptReconciliation().catch((err)=>{
|
|
249
263
|
logger.always('Sync attempt failed', err);
|
|
@@ -251,17 +265,19 @@ export const payloadBetterAuthPlugin = (opts)=>{
|
|
|
251
265
|
});
|
|
252
266
|
} else if (baTs === null) {
|
|
253
267
|
// First run - always sync
|
|
268
|
+
logger.always('First run - triggering initial sync');
|
|
254
269
|
attemptReconciliation().catch((err)=>{
|
|
255
270
|
logger.always('Initial sync failed', err);
|
|
256
271
|
});
|
|
257
272
|
} else if (payloadTs > baTs) {
|
|
258
273
|
// Payload restarted since last reconcile - sync needed
|
|
274
|
+
logger.always('Payload restarted - triggering sync');
|
|
259
275
|
attemptReconciliation().catch((err)=>{
|
|
260
276
|
logger.always('Sync failed', err);
|
|
261
277
|
});
|
|
262
278
|
} else {
|
|
263
279
|
// Already reconciled and up-to-date
|
|
264
|
-
|
|
280
|
+
logger.always('Already synchronized', {
|
|
265
281
|
lastSync: new Date(baTs).toISOString()
|
|
266
282
|
});
|
|
267
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 { 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\n// Key prefixes for storage\nconst TIMESTAMP_PREFIX = 'timestamp:'\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, password }) {\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","Queue","createDeleteUserFromPayload","createListPayloadUsersPage","createSyncUserToPayload","defaultLog","msg","extra","console","log","JSON","stringify","TIMESTAMP_PREFIX","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","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","set","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;
|