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.
- package/README.md +111 -174
- package/dist/better-auth/databaseHooks.js +1 -1
- package/dist/better-auth/databaseHooks.js.map +1 -1
- package/dist/better-auth/plugin.d.ts +1 -1
- package/dist/better-auth/plugin.js +3 -3
- package/dist/better-auth/plugin.js.map +1 -1
- package/dist/better-auth/reconcile-queue.d.ts +1 -1
- package/dist/better-auth/reconcile-queue.js.map +1 -1
- package/dist/better-auth/sources.js +1 -1
- package/dist/better-auth/sources.js.map +1 -1
- package/dist/collections/Users/index.js +1 -1
- package/dist/collections/Users/index.js.map +1 -1
- package/dist/components/BetterAuthLoginServer.d.ts +19 -6
- package/dist/components/BetterAuthLoginServer.js +24 -8
- package/dist/components/BetterAuthLoginServer.js.map +1 -1
- package/dist/components/EmailPasswordFormClient.d.ts +1 -1
- package/dist/components/EmailPasswordFormClient.js.map +1 -1
- package/dist/exports/client.d.ts +1 -1
- package/dist/exports/client.js +1 -1
- package/dist/exports/client.js.map +1 -1
- package/dist/exports/rsc.d.ts +1 -1
- package/dist/exports/rsc.js +1 -1
- package/dist/exports/rsc.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/payload/plugin.d.ts +21 -2
- package/dist/payload/plugin.js +29 -7
- package/dist/payload/plugin.js.map +1 -1
- package/dist/utils/payload-reconcile.js +2 -6
- package/dist/utils/payload-reconcile.js.map +1 -1
- package/package.json +137 -63
- package/src/better-auth/crypto-shared.ts +169 -0
- package/src/better-auth/databaseHooks.ts +30 -0
- package/src/better-auth/helpers.ts +3 -0
- package/src/better-auth/plugin.ts +214 -0
- package/src/better-auth/reconcile-queue.ts +401 -0
- package/src/better-auth/sources.ts +123 -0
- package/src/collections/Users/index.ts +148 -0
- package/src/components/BetterAuthLoginServer.tsx +154 -0
- package/src/components/EmailPasswordFormClient.tsx +204 -0
- package/src/components/VerifyEmailInfoViewClient.tsx +62 -0
- package/src/exports/client.ts +1 -0
- package/src/exports/rsc.ts +1 -0
- package/src/index.ts +9 -0
- package/src/payload/plugin.ts +163 -0
- 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
|
+
}
|