payload-better-auth 1.0.10 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +111 -174
  2. package/dist/better-auth/databaseHooks.js +1 -1
  3. package/dist/better-auth/databaseHooks.js.map +1 -1
  4. package/dist/better-auth/plugin.d.ts +1 -1
  5. package/dist/better-auth/plugin.js +3 -3
  6. package/dist/better-auth/plugin.js.map +1 -1
  7. package/dist/better-auth/reconcile-queue.d.ts +1 -1
  8. package/dist/better-auth/reconcile-queue.js.map +1 -1
  9. package/dist/better-auth/sources.js +1 -1
  10. package/dist/better-auth/sources.js.map +1 -1
  11. package/dist/collections/Users/index.js +1 -1
  12. package/dist/collections/Users/index.js.map +1 -1
  13. package/dist/components/BetterAuthLoginServer.d.ts +19 -6
  14. package/dist/components/BetterAuthLoginServer.js +24 -8
  15. package/dist/components/BetterAuthLoginServer.js.map +1 -1
  16. package/dist/components/EmailPasswordFormClient.d.ts +1 -1
  17. package/dist/components/EmailPasswordFormClient.js.map +1 -1
  18. package/dist/exports/client.d.ts +1 -1
  19. package/dist/exports/client.js +1 -1
  20. package/dist/exports/client.js.map +1 -1
  21. package/dist/exports/rsc.d.ts +1 -1
  22. package/dist/exports/rsc.js +1 -1
  23. package/dist/exports/rsc.js.map +1 -1
  24. package/dist/index.d.ts +5 -4
  25. package/dist/index.js +5 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/payload/plugin.d.ts +21 -2
  28. package/dist/payload/plugin.js +29 -7
  29. package/dist/payload/plugin.js.map +1 -1
  30. package/dist/utils/payload-reconcile.js +2 -6
  31. package/dist/utils/payload-reconcile.js.map +1 -1
  32. package/package.json +137 -63
  33. package/src/better-auth/crypto-shared.ts +169 -0
  34. package/src/better-auth/databaseHooks.ts +30 -0
  35. package/src/better-auth/helpers.ts +3 -0
  36. package/src/better-auth/plugin.ts +214 -0
  37. package/src/better-auth/reconcile-queue.ts +401 -0
  38. package/src/better-auth/sources.ts +123 -0
  39. package/src/collections/Users/index.ts +148 -0
  40. package/src/components/BetterAuthLoginServer.tsx +154 -0
  41. package/src/components/EmailPasswordFormClient.tsx +204 -0
  42. package/src/components/VerifyEmailInfoViewClient.tsx +62 -0
  43. package/src/exports/client.ts +1 -0
  44. package/src/exports/rsc.ts +1 -0
  45. package/src/index.ts +9 -0
  46. package/src/payload/plugin.ts +163 -0
  47. package/src/utils/payload-reconcile.ts +50 -0
@@ -0,0 +1,401 @@
1
+ import type { AuthContext } from 'better-auth'
2
+
3
+ // src/reconcile-queue.ts
4
+ import type { BAUser, PayloadUser } from './sources'
5
+
6
+ export interface QueueDeps {
7
+ deleteUserFromPayload: (baId: string) => Promise<void> // delete by externalId; ignore missing
8
+ internalAdapter: AuthContext['internalAdapter']
9
+
10
+ // Paginated loaders (efficient processing)
11
+ listPayloadUsersPage: (
12
+ limit: number,
13
+ page: number,
14
+ ) => Promise<{ hasNextPage: boolean; total: number; users: PayloadUser[] }>
15
+ // Logging
16
+ log?: (msg: string, extra?: any) => void
17
+
18
+ // Policy
19
+ prunePayloadOrphans?: boolean // default: false
20
+
21
+ // Idempotent effects (via Payload Local API)
22
+ syncUserToPayload: (baUser: BAUser) => Promise<void> // upsert by externalId=baUser.id
23
+ }
24
+
25
+ export type TaskSource = 'full-reconcile' | 'user-operation'
26
+
27
+ // Bootstrap options interface
28
+ export interface InitOptions {
29
+ forceReset?: boolean
30
+ reconcileEveryMs?: number
31
+ runOnBoot?: boolean
32
+ tickMs?: number
33
+ }
34
+
35
+ // Simplified bootstrap state interface (removed processId)
36
+ interface BootstrapState {
37
+ adminHeaders: Headers | null
38
+ bootstrapPromise: null | Promise<void>
39
+ isBootstrapped: boolean
40
+ }
41
+
42
+ type Task =
43
+ | {
44
+ attempts: number
45
+ baId: string
46
+ baUser?: BAUser
47
+ kind: 'ensure'
48
+ nextAt: number
49
+ reconcileId?: string
50
+ source: TaskSource
51
+ }
52
+ | {
53
+ attempts: number
54
+ baId: string
55
+ kind: 'delete'
56
+ nextAt: number
57
+ reconcileId?: string
58
+ source: TaskSource
59
+ }
60
+
61
+ const KEY = (t: Task) => `${t.kind}:${t.baId}`
62
+
63
+ export class Queue {
64
+ // Bootstrap state stored directly on the queue instance
65
+ private bootstrapState: BootstrapState = {
66
+ adminHeaders: null,
67
+ bootstrapPromise: null,
68
+ isBootstrapped: false,
69
+ }
70
+ private deps!: QueueDeps
71
+ private failed = 0
72
+ private keys = new Map<string, Task>()
73
+ private lastError: null | string = null
74
+ private lastSeedAt: null | string = null
75
+ private processed = 0
76
+
77
+ private processing = false
78
+ private q: Task[] = []
79
+ private reconcileEveryMs = 30 * 60_000 // default 30 minutes
80
+ private reconcileTimeout: NodeJS.Timeout | null = null
81
+ private reconciling = false
82
+
83
+ private tickTimer: NodeJS.Timeout | null = null
84
+
85
+ constructor(deps: QueueDeps, opts: InitOptions = {}) {
86
+ this.deps = deps
87
+ const log = this.deps?.log ?? (() => {})
88
+ // Start bootstrap process - but defer heavy operations
89
+ log('Starting bootstrap process...')
90
+
91
+ // Start timers but don't run reconcile immediately
92
+ this.start({
93
+ reconcileEveryMs: opts?.reconcileEveryMs ?? 30 * 60_000,
94
+ tickMs: opts?.tickMs ?? 1000,
95
+ })
96
+
97
+ // Defer the initial reconcile to avoid circular dependency issues
98
+ if (opts?.runOnBoot ?? true) {
99
+ // Use setTimeout instead of queueMicrotask to give more time for initialization
100
+ setTimeout(() => {
101
+ this.seedFullReconcile().catch(
102
+ (err) => this.deps.log && this.deps.log('[reconcile] seed failed', err),
103
+ )
104
+ }, 2000) // 2 second delay to allow Better Auth and Payload to fully initialize
105
+ }
106
+
107
+ log('Bootstrap process completed')
108
+ }
109
+
110
+ private bumpFront(task: Task) {
111
+ this.q = [task, ...this.q.filter((t) => t !== task)]
112
+ }
113
+
114
+ /** Clear all full-reconcile tasks from the queue, preserving user-operation tasks */
115
+ private clearFullReconcileTasks() {
116
+ const log = this.deps?.log ?? (() => {})
117
+ const beforeCount = this.q.length
118
+ const fullReconcileCount = this.q.filter((t) => t.source === 'full-reconcile').length
119
+
120
+ // Remove full-reconcile tasks from queue and keys map
121
+ this.q = this.q.filter((task) => {
122
+ if (task.source === 'full-reconcile') {
123
+ this.keys.delete(KEY(task))
124
+ return false
125
+ }
126
+ return true
127
+ })
128
+
129
+ const afterCount = this.q.length
130
+ log('reconcile.clear-previous', {
131
+ afterCount,
132
+ beforeCount,
133
+ clearedFullReconcile: fullReconcileCount,
134
+ preservedUserOps: afterCount,
135
+ })
136
+ }
137
+
138
+ // ——— Internals ———
139
+ private enqueue(task: Task, priority: boolean) {
140
+ const k = KEY(task)
141
+ const existing = this.keys.get(k)
142
+ if (existing) {
143
+ if (task.kind === 'ensure' && existing.kind === 'ensure' && !existing.baUser && task.baUser) {
144
+ existing.baUser = task.baUser
145
+ }
146
+ if (priority) {
147
+ this.bumpFront(existing)
148
+ }
149
+ return
150
+ }
151
+ if (priority) {
152
+ this.q.unshift(task)
153
+ } else {
154
+ this.q.push(task)
155
+ }
156
+ this.keys.set(k, task)
157
+ }
158
+
159
+ private async listBAUsersPage({ limit, offset }: { limit: number; offset: number }) {
160
+ // sort by newest (used) first
161
+ // when a delete is happening in the meantime, this will lead to some users not being listed (as the index changes)
162
+ // TODO: fix this by maintaining a delete list.
163
+ const total = await this.deps.internalAdapter.countTotalUsers()
164
+ const users = await this.deps.internalAdapter.listUsers(limit, offset, {
165
+ direction: 'desc',
166
+ field: 'updatedAt',
167
+ })
168
+ return { total, users }
169
+ }
170
+
171
+ private async runTask(t: Task) {
172
+ const log = this.deps?.log ?? (() => {})
173
+ if (t.kind === 'ensure') {
174
+ log('queue.ensure', { attempts: t.attempts, baId: t.baId })
175
+ await this.deps.syncUserToPayload(t.baUser ?? { id: t.baId })
176
+ return
177
+ }
178
+ // delete
179
+ log('queue.delete', { attempts: t.attempts, baId: t.baId })
180
+ await this.deps.deleteUserFromPayload(t.baId)
181
+ }
182
+ private scheduleNextReconcile() {
183
+ if (this.reconcileTimeout) {
184
+ clearTimeout(this.reconcileTimeout)
185
+ }
186
+
187
+ this.reconcileTimeout = setTimeout(async () => {
188
+ if (!this.reconciling) {
189
+ this.reconciling = true
190
+ try {
191
+ await this.seedFullReconcile()
192
+ } catch (error) {
193
+ // Error is already logged in seedFullReconcile
194
+ } finally {
195
+ this.reconciling = false
196
+ // Schedule the next reconcile after this one completes
197
+ this.scheduleNextReconcile()
198
+ }
199
+ }
200
+ }, this.reconcileEveryMs)
201
+
202
+ // Optional unref for Node.js environments to prevent keeping process alive
203
+ if ('unref' in this.reconcileTimeout && typeof this.reconcileTimeout.unref === 'function') {
204
+ this.reconcileTimeout.unref()
205
+ }
206
+ }
207
+
208
+ /** Paginated approach: process users page by page to reduce memory usage */
209
+ private async seedFullReconcilePaginated(reconcileId: string) {
210
+ const log = this.deps?.log ?? (() => {})
211
+ const pageSize = 500
212
+ let baIdSet: null | Set<string> = null
213
+
214
+ // If we need to prune orphans, we need to collect all BA user IDs
215
+ if (this.deps.prunePayloadOrphans) {
216
+ baIdSet = new Set<string>()
217
+ let baOffset = 0
218
+ let baTotal = 0
219
+
220
+ do {
221
+ const { total, users: baUsers } = await this.listBAUsersPage({
222
+ limit: pageSize,
223
+ offset: baOffset,
224
+ })
225
+ baTotal = total
226
+
227
+ // Enqueue ensure tasks for this page with full-reconcile source
228
+ for (const u of baUsers) {
229
+ this.enqueueEnsure(u, false, 'full-reconcile', reconcileId)
230
+ baIdSet.add(u.id)
231
+ }
232
+
233
+ baOffset += baUsers.length
234
+ log('reconcile.seed.ba-page', { processed: baOffset, reconcileId, total: baTotal })
235
+ } while (baOffset < baTotal)
236
+ } else {
237
+ // If not pruning, we can process BA users page by page without storing IDs
238
+ let baOffset = 0
239
+ let baTotal = 0
240
+
241
+ do {
242
+ // TODO: make sure that we dont go past the window through deletes happening
243
+ // (As a user deletes, the total window size becomes smaller)
244
+ const { total, users: baUsers } = await this.listBAUsersPage({
245
+ limit: pageSize,
246
+ offset: baOffset,
247
+ })
248
+ baTotal = total
249
+
250
+ // Enqueue ensure tasks for this page with full-reconcile source
251
+ for (const u of baUsers) {
252
+ this.enqueueEnsure(u, false, 'full-reconcile', reconcileId)
253
+ }
254
+
255
+ baOffset += baUsers.length
256
+ log('reconcile.seed.ba-page', { processed: baOffset, reconcileId, total: baTotal })
257
+ } while (baOffset < baTotal)
258
+ }
259
+
260
+ // Process Payload users page by page for orphan pruning
261
+ if (this.deps.prunePayloadOrphans && baIdSet) {
262
+ let payloadPage = 1
263
+ let hasNextPage = true
264
+
265
+ while (hasNextPage) {
266
+ const { hasNextPage: nextPage, users: pUsers } = await this.deps.listPayloadUsersPage(
267
+ pageSize,
268
+ payloadPage,
269
+ )
270
+ hasNextPage = nextPage
271
+
272
+ for (const pu of pUsers) {
273
+ const ext = pu.externalId?.toString()
274
+ if (ext && !baIdSet.has(ext)) {
275
+ this.enqueueDelete(ext, false, 'full-reconcile', reconcileId)
276
+ }
277
+ }
278
+
279
+ payloadPage++
280
+ log('reconcile.seed.payload-page', { page: payloadPage - 1, reconcileId })
281
+ }
282
+ }
283
+ }
284
+
285
+ private async tick() {
286
+ if (this.processing) {
287
+ return
288
+ }
289
+ const now = Date.now()
290
+ const idx = this.q.findIndex((t) => t.nextAt <= now)
291
+ if (idx === -1) {
292
+ return
293
+ }
294
+ const task = this.q[idx]
295
+ this.processing = true
296
+ try {
297
+ await this.runTask(task)
298
+ this.q.splice(idx, 1)
299
+ this.keys.delete(KEY(task))
300
+ this.processed++
301
+ } catch (e: any) {
302
+ this.failed++
303
+ this.lastError = e?.message ?? String(e)
304
+ task.attempts += 1
305
+ const delay =
306
+ Math.min(60_000, Math.pow(2, task.attempts) * 1000) + Math.floor(Math.random() * 500)
307
+ task.nextAt = now + delay
308
+ } finally {
309
+ this.processing = false
310
+ }
311
+ }
312
+
313
+ enqueueDelete(
314
+ baId: string,
315
+ priority = false,
316
+ source: TaskSource = 'user-operation',
317
+ reconcileId?: string,
318
+ ) {
319
+ this.enqueue(
320
+ { attempts: 0, baId, kind: 'delete', nextAt: Date.now(), reconcileId, source },
321
+ priority,
322
+ )
323
+ }
324
+
325
+ // ——— Public enqueue API ———
326
+ enqueueEnsure(
327
+ user: BAUser,
328
+ priority = false,
329
+ source: TaskSource = 'user-operation',
330
+ reconcileId?: string,
331
+ ) {
332
+ this.enqueue(
333
+ {
334
+ attempts: 0,
335
+ baId: user.id,
336
+ baUser: user,
337
+ kind: 'ensure',
338
+ nextAt: Date.now(),
339
+ reconcileId,
340
+ source,
341
+ },
342
+ priority,
343
+ )
344
+ }
345
+
346
+ // Get current instance info
347
+ getInstanceInfo() {
348
+ return {
349
+ isBootstrapped: this.bootstrapState.isBootstrapped,
350
+ }
351
+ }
352
+
353
+ /** Seed tasks by comparing users page by page (Better-Auth → Payload). */
354
+ async seedFullReconcile() {
355
+ const log = this.deps?.log ?? (() => {})
356
+ this.lastSeedAt = new Date().toISOString()
357
+ const reconcileId = `reconcile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
358
+
359
+ log('reconcile.seed.start', { reconcileId })
360
+
361
+ // Clear all previous full-reconcile tasks, but preserve user-operation tasks
362
+ this.clearFullReconcileTasks()
363
+
364
+ await this.seedFullReconcilePaginated(reconcileId)
365
+
366
+ log('reconcile.seed.done', this.status())
367
+ }
368
+
369
+ start({ reconcileEveryMs = 30 * 60_000, tickMs = 1000 } = {}) {
370
+ this.reconcileEveryMs = reconcileEveryMs
371
+
372
+ if (!this.tickTimer) {
373
+ this.tickTimer = setInterval(() => this.tick(), tickMs)
374
+ // Optional unref for Node.js environments to prevent keeping process alive
375
+ if ('unref' in this.tickTimer && typeof this.tickTimer.unref === 'function') {
376
+ this.tickTimer.unref()
377
+ }
378
+ }
379
+
380
+ // Schedule the first reconcile
381
+ this.scheduleNextReconcile()
382
+ }
383
+
384
+ status() {
385
+ const userOpCount = this.q.filter((t) => t.source === 'user-operation').length
386
+ const fullReconcileCount = this.q.filter((t) => t.source === 'full-reconcile').length
387
+
388
+ return {
389
+ failed: this.failed,
390
+ fullReconcileTasks: fullReconcileCount,
391
+ lastError: this.lastError,
392
+ lastSeedAt: this.lastSeedAt,
393
+ processed: this.processed,
394
+ processing: this.processing,
395
+ queueSize: this.q.length,
396
+ reconciling: this.reconciling,
397
+ sampleKeys: Array.from(this.keys.keys()).slice(0, 50),
398
+ userOperationTasks: userOpCount,
399
+ }
400
+ }
401
+ }
@@ -0,0 +1,123 @@
1
+ // src/sources.ts
2
+ import { getPayload, type SanitizedConfig } from 'payload'
3
+
4
+ import { signCanonical } from './crypto-shared'
5
+
6
+ const INTERNAL_SECRET = process.env.BA_TO_PAYLOAD_SECRET!
7
+
8
+ export type BAUser = { [k: string]: any; email?: null | string; id: string }
9
+ export type PayloadUser = { externalId?: null | string; id: number | string }
10
+
11
+ // Better Auth user type for sync operations
12
+ export interface BetterAuthUser {
13
+ [k: string]: any
14
+ email?: null | string
15
+ id: string
16
+ name?: null | string
17
+ }
18
+
19
+ /** Create a function to load Payload users page by page via Local API. */
20
+ export function createListPayloadUsersPage(config: Promise<SanitizedConfig>) {
21
+ return async function listPayloadUsersPage(
22
+ limit: number,
23
+ page: number,
24
+ ): Promise<{ hasNextPage: boolean; total: number; users: PayloadUser[] }> {
25
+ const payload = await getPayload({ config })
26
+ const res = await payload.find({
27
+ collection: 'users',
28
+ depth: 0,
29
+ limit,
30
+ overrideAccess: true,
31
+ page,
32
+ })
33
+ return {
34
+ hasNextPage: res.hasNextPage || false,
35
+ total: res.totalDocs || 0,
36
+ users: res.docs.map((d: any) => ({
37
+ id: d.id,
38
+ externalId: d.externalId,
39
+ })),
40
+ }
41
+ }
42
+ }
43
+
44
+ // Better-auth is the single source of truth and manages users through database hooks
45
+ // These functions provide bidirectional validation and sync capabilities
46
+ /**
47
+ * Sync user from better-auth to Payload
48
+ * This is called from the better-auth hooks
49
+ * Creates a Payload user with externalId, which prevents reverse sync
50
+ */
51
+
52
+ /**
53
+ * Create a function to sync user from better-auth to Payload
54
+ * This is called from the better-auth hooks
55
+ * Creates a Payload user with externalId, which prevents reverse sync
56
+ */
57
+ export function createSyncUserToPayload(config: Promise<SanitizedConfig>) {
58
+ return async function syncUserToPayload(betterAuthUser: BetterAuthUser) {
59
+ const payload = await getPayload({ config })
60
+
61
+ // idempotency check (keep as-is)
62
+ const existing = await payload.find({
63
+ collection: 'users',
64
+ limit: 1,
65
+ where: { externalId: { equals: betterAuthUser.id } },
66
+ })
67
+ if (existing.docs.length) {
68
+ return
69
+ }
70
+
71
+ const baBody = { op: 'create', userId: betterAuthUser.id } // keep body minimal & stable
72
+ const baSig = signCanonical(baBody, INTERNAL_SECRET)
73
+
74
+ await payload.create({
75
+ collection: 'users',
76
+ context: { baBody, baSig },
77
+ data: {
78
+ name: betterAuthUser.name ?? '',
79
+ externalId: betterAuthUser.id,
80
+ },
81
+ overrideAccess: false,
82
+ })
83
+ }
84
+ }
85
+
86
+ // Create a function to delete user from Payload
87
+ export function createDeleteUserFromPayload(config: Promise<SanitizedConfig>) {
88
+ return async function deleteUserFromPayload(betterAuthUserId: string) {
89
+ const payload = await getPayload({ config })
90
+
91
+ const existing = await payload.find({
92
+ collection: 'users',
93
+ limit: 1,
94
+ where: { externalId: { equals: betterAuthUserId } },
95
+ })
96
+ if (!existing.docs.length) {
97
+ return
98
+ }
99
+
100
+ const baBody = { op: 'delete', userId: betterAuthUserId }
101
+ const baSig = signCanonical(baBody, INTERNAL_SECRET)
102
+
103
+ await payload.delete({
104
+ id: existing.docs[0].id,
105
+ collection: 'users',
106
+ context: { baBody, baSig },
107
+ overrideAccess: false,
108
+ })
109
+ }
110
+ }
111
+
112
+ // ——— Optional: link an existing Payload user (id-matched) to BA id
113
+ export function createAttachExternalIdInPayload(config: Promise<SanitizedConfig>) {
114
+ return async function attachExternalIdInPayload(payloadUserId: number | string, baId: string) {
115
+ const payload = await getPayload({ config })
116
+ await payload.update({
117
+ id: payloadUserId,
118
+ collection: 'users',
119
+ data: { externalId: baId },
120
+ overrideAccess: true,
121
+ })
122
+ }
123
+ }
@@ -0,0 +1,148 @@
1
+ import type { AccessArgs, CollectionConfig, PayloadRequest, User } from 'payload'
2
+
3
+ import { createAuthClient } from 'better-auth/react'
4
+ import { APIError } from 'payload'
5
+
6
+ import { type CryptoSignature, verifyCanonical } from '../../better-auth/crypto-shared'
7
+
8
+ const INTERNAL_SECRET = process.env.BA_TO_PAYLOAD_SECRET!
9
+
10
+ type isAuthenticated = (args: AccessArgs<User>) => boolean
11
+ const authenticated: isAuthenticated = ({ req: { user } }) => {
12
+ return Boolean(user)
13
+ }
14
+
15
+ // (optional) simple anti-replay for Local API calls
16
+ const seenNonces = new Map<string, number>()
17
+ const TTL = 5 * 60 * 1000
18
+ setInterval(() => {
19
+ const now = Date.now()
20
+ for (const [k, exp] of seenNonces) {
21
+ if (exp < now) {
22
+ seenNonces.delete(k)
23
+ }
24
+ }
25
+ }, 60_000).unref()
26
+
27
+ function basicSigOk(req: { context: { baSig?: CryptoSignature } } & PayloadRequest) {
28
+ const sig = req.context.baSig
29
+ const body = req.context.baBody
30
+ if (!sig || !body) {
31
+ return false
32
+ }
33
+ const ok = verifyCanonical(body, sig, INTERNAL_SECRET)
34
+ if (!ok) {
35
+ return false
36
+ }
37
+ if (seenNonces.has(sig.nonce)) {
38
+ return false
39
+ } // replay
40
+ // don't mark used yet; final verification happens in hooks
41
+ return true
42
+ }
43
+
44
+ export function createUsersCollection({
45
+ authClientOptions,
46
+ }: {
47
+ authClientOptions: Parameters<typeof createAuthClient>['0']
48
+ }): CollectionConfig {
49
+ const authClient = createAuthClient(authClientOptions)
50
+ return {
51
+ slug: 'users',
52
+ access: {
53
+ admin: authenticated,
54
+ // Disable manual user management through Payload admin
55
+ // Users can only be managed through better-auth
56
+ create: ({ req }) => basicSigOk(req),
57
+ delete: ({ req }) => basicSigOk(req),
58
+ read: authenticated,
59
+ update: ({ req }) => basicSigOk(req),
60
+ },
61
+ admin: {
62
+ defaultColumns: ['name', 'email'],
63
+ useAsTitle: 'name',
64
+ },
65
+ auth: {
66
+ disableLocalStrategy: true,
67
+ strategies: [
68
+ {
69
+ name: 'better-auth',
70
+ authenticate: async ({ headers, payload }) => {
71
+ // Validate Better Auth session (cookie/JWT) from headers
72
+ const session = await authClient.getSession({ fetchOptions: { headers } })
73
+ if (!session.data) {
74
+ return { user: null }
75
+ }
76
+ const externalId = session.data.user.id
77
+
78
+ // Find or provision the minimal Payload user
79
+ const existing = await payload.find({
80
+ collection: 'users',
81
+ limit: 1,
82
+ where: { externalId: { equals: externalId } },
83
+ })
84
+ const doc =
85
+ existing.docs[0] ??
86
+ (await payload.create({
87
+ collection: 'users',
88
+ data: { externalId },
89
+ }))
90
+
91
+ return { user: { collection: 'users', ...doc } }
92
+ },
93
+ },
94
+ ],
95
+ },
96
+ fields: [
97
+ { name: 'externalId', type: 'text', index: true, required: true, unique: true },
98
+ {
99
+ name: 'name',
100
+ type: 'text',
101
+ },
102
+ ],
103
+ hooks: {
104
+ beforeChange: [
105
+ async ({ data, operation, originalDoc, req }) => {
106
+ if (operation === 'create') {
107
+ // authoritative check: tie signature to the actual mutation
108
+ const sig = req.context.baSig as CryptoSignature | undefined
109
+ const expectedBody = { op: 'create', userId: data.externalId }
110
+ if (!sig || !verifyCanonical(expectedBody, sig, INTERNAL_SECRET)) {
111
+ return Promise.reject(new APIError('User creation is managed by Better Auth.'))
112
+ }
113
+ // mark nonce as used
114
+ seenNonces.set(sig.nonce, Date.now() + TTL)
115
+ } else if (operation === 'update') {
116
+ // authoritative check: tie signature to the actual mutation
117
+ const sig = req.context.baSig as CryptoSignature | undefined
118
+ const userId = originalDoc?.externalId || data.externalId
119
+ const expectedBody = { op: 'update', userId }
120
+ if (!sig || !verifyCanonical(expectedBody, sig, INTERNAL_SECRET)) {
121
+ return Promise.reject(new APIError('User updates are managed by Better Auth.'))
122
+ }
123
+ // mark nonce as used
124
+ seenNonces.set(sig.nonce, Date.now() + TTL)
125
+ }
126
+ return data
127
+ },
128
+ ],
129
+ beforeDelete: [
130
+ async ({ id, req }) => {
131
+ // Get the document first to access externalId
132
+ const doc = await req.payload.findByID({
133
+ id,
134
+ collection: 'users',
135
+ })
136
+
137
+ const sig = req.context.baSig as CryptoSignature | undefined
138
+ const expectedBody = { op: 'delete', userId: doc.externalId }
139
+ if (!sig || !verifyCanonical(expectedBody, sig, INTERNAL_SECRET)) {
140
+ return Promise.reject(new APIError('User deletion is managed by Better Auth.'))
141
+ }
142
+ seenNonces.set(sig.nonce, Date.now() + TTL)
143
+ },
144
+ ],
145
+ },
146
+ timestamps: true,
147
+ }
148
+ }