@payload-auth-link/migrate 0.4.2 → 0.5.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/LICENSE +21 -0
- package/README.md +7 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +103 -19
- package/dist/cli.js.map +1 -1
- package/dist/doctor/database.d.ts.map +1 -1
- package/dist/doctor/database.js +31 -4
- package/dist/doctor/database.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +67 -0
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/types.d.ts +12 -0
- package/dist/doctor/types.d.ts.map +1 -1
- package/dist/doctor/users.d.ts.map +1 -1
- package/dist/doctor/users.js +20 -4
- package/dist/doctor/users.js.map +1 -1
- package/dist/importer/apply.d.ts +23 -5
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +154 -178
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/host-writes.d.ts +47 -0
- package/dist/importer/host-writes.d.ts.map +1 -0
- package/dist/importer/host-writes.js +198 -0
- package/dist/importer/host-writes.js.map +1 -0
- package/dist/importer/lifecycle.d.ts +24 -0
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +104 -22
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/prelink.d.ts +55 -0
- package/dist/importer/prelink.d.ts.map +1 -0
- package/dist/importer/prelink.js +152 -0
- package/dist/importer/prelink.js.map +1 -0
- package/dist/importer/run-import.d.ts +13 -0
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +147 -14
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/source.d.ts +35 -7
- package/dist/importer/source.d.ts.map +1 -1
- package/dist/importer/source.js +37 -6
- package/dist/importer/source.js.map +1 -1
- package/dist/importer/types.d.ts +26 -4
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/private-file.d.ts +8 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +19 -0
- package/dist/private-file.js.map +1 -0
- package/package.json +6 -6
- package/src/cli.ts +111 -9
- package/src/doctor/database.ts +36 -4
- package/src/doctor/run-doctor.ts +69 -0
- package/src/doctor/types.ts +12 -0
- package/src/doctor/users.ts +23 -4
- package/src/importer/apply.ts +186 -180
- package/src/importer/host-writes.ts +231 -0
- package/src/importer/lifecycle.ts +126 -24
- package/src/importer/prelink.ts +228 -0
- package/src/importer/run-import.ts +196 -29
- package/src/importer/source.ts +72 -14
- package/src/importer/types.ts +27 -4
- package/src/index.ts +13 -1
- package/src/private-file.ts +18 -0
package/src/cli.ts
CHANGED
|
@@ -9,16 +9,19 @@
|
|
|
9
9
|
* the `--json` echo of what the caller passed, and secrets inside them
|
|
10
10
|
* are the caller's own input.
|
|
11
11
|
*/
|
|
12
|
+
import type * as Codemods from './codemods.js'
|
|
12
13
|
import { runDoctor } from './doctor/run-doctor.js'
|
|
13
14
|
import type { DoctorReport } from './doctor/types.js'
|
|
14
15
|
import {
|
|
16
|
+
runAdoptPrelink,
|
|
15
17
|
runFinalize,
|
|
16
18
|
runFromPayloadAuth,
|
|
17
19
|
runRollback,
|
|
18
20
|
runVerify,
|
|
19
21
|
type ImportCommandOptions,
|
|
20
22
|
} from './importer/run-import.js'
|
|
21
|
-
import {
|
|
23
|
+
import { summarizePlanRows } from './importer/apply.js'
|
|
24
|
+
import { writePrivateFile } from './private-file.js'
|
|
22
25
|
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
23
26
|
import { extname, join, relative } from 'node:path'
|
|
24
27
|
|
|
@@ -73,6 +76,12 @@ Commands:
|
|
|
73
76
|
[--source-map '{"accounts":"user_accounts","sessions":"user_sessions"}']
|
|
74
77
|
rollback --config ./src/payload.config.ts [--manifest ...]
|
|
75
78
|
finalize [--manifest ...]
|
|
79
|
+
adopt-prelink --config ./src/payload.config.ts [--apply] [--json]
|
|
80
|
+
[--manifest ./payload-auth-link-prelink.json]
|
|
81
|
+
[--users-slug users] [--verified-field emailVerified]
|
|
82
|
+
(adopt mode: one better-auth identity per user, plus the
|
|
83
|
+
linking proof for verified emails; undo with
|
|
84
|
+
rollback --manifest ./payload-auth-link-prelink.json)
|
|
76
85
|
|
|
77
86
|
--source-map renames source models to your layout's tables (custom
|
|
78
87
|
slugs like user_accounts/user_sessions); a null value records a
|
|
@@ -93,6 +102,12 @@ Commands:
|
|
|
93
102
|
rollback undoes exactly the manifest; finalize marks it read-only
|
|
94
103
|
so the legacy tables may be dropped later, in their own migration.
|
|
95
104
|
|
|
105
|
+
The manifest lands in the CURRENT DIRECTORY unless --manifest says
|
|
106
|
+
otherwise. It holds ids and sealed values only (no emails in the
|
|
107
|
+
clear; sealed with the app's PAYLOAD_SECRET, so rollback must run
|
|
108
|
+
against the same app) and is written with mode 0600. Delete it once
|
|
109
|
+
the migration is finalized and your soak window has passed.
|
|
110
|
+
|
|
96
111
|
codemod rewrites the three named payload-auth call-site patterns
|
|
97
112
|
(getPayloadAuth, payload.betterAuth.api.getSession, toNextJsHandler)
|
|
98
113
|
and reports what needs a human. Dry run by default; --write rewrites.
|
|
@@ -169,6 +184,17 @@ function formatReport(report: DoctorReport): string {
|
|
|
169
184
|
if (report.database.unverifiedUsers !== null) {
|
|
170
185
|
lines.push(` unverified users: ${report.database.unverifiedUsers}`)
|
|
171
186
|
}
|
|
187
|
+
if (report.database.bannedUsers !== undefined && report.database.bannedUsers !== null) {
|
|
188
|
+
lines.push(` banned users: ${report.database.bannedUsers}`)
|
|
189
|
+
}
|
|
190
|
+
if (report.database.anonymousUsers !== undefined && report.database.anonymousUsers !== null) {
|
|
191
|
+
lines.push(` anonymous users: ${report.database.anonymousUsers}`)
|
|
192
|
+
}
|
|
193
|
+
if ((report.database.oauthTables ?? []).length > 0) {
|
|
194
|
+
lines.push(
|
|
195
|
+
` oauth_* tables (NOT carried by the importer): ${(report.database.oauthTables ?? []).join(', ')}`
|
|
196
|
+
)
|
|
197
|
+
}
|
|
172
198
|
for (const orphan of report.database.orphans) {
|
|
173
199
|
lines.push(` ORPHANS ${orphan.kind}: ${orphan.count}`)
|
|
174
200
|
}
|
|
@@ -216,9 +242,18 @@ async function main(
|
|
|
216
242
|
'verify',
|
|
217
243
|
'rollback',
|
|
218
244
|
'finalize',
|
|
245
|
+
'adopt-prelink',
|
|
219
246
|
'codemod',
|
|
220
247
|
'export',
|
|
221
248
|
]
|
|
249
|
+
const json = flags.get('json') === true
|
|
250
|
+
// Every command prints (issue #88): progress and the summary go to
|
|
251
|
+
// stdout; --json replaces the human lines with one JSON document.
|
|
252
|
+
const log = (message: string): void => {
|
|
253
|
+
if (!json) {
|
|
254
|
+
out.write(`${message}\n`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
222
257
|
if (command !== 'doctor' && !importCommands.includes(command)) {
|
|
223
258
|
err.write(`unknown command '${command}'\n\n${usage()}`)
|
|
224
259
|
return 3
|
|
@@ -263,7 +298,9 @@ async function main(
|
|
|
263
298
|
return 1
|
|
264
299
|
}
|
|
265
300
|
if (typeof flags.get('out') === 'string') {
|
|
266
|
-
|
|
301
|
+
// The export carries credential material (password hashes, OAuth
|
|
302
|
+
// tokens, sessions, API keys, JWKS private keys): owner-only.
|
|
303
|
+
writePrivateFile(flags.get('out') as string, result.jsonl)
|
|
267
304
|
out.write(`wrote ${result.totalRows} row(s) to ${String(flags.get('out'))}\n`)
|
|
268
305
|
} else {
|
|
269
306
|
out.write(result.jsonl)
|
|
@@ -280,6 +317,10 @@ async function main(
|
|
|
280
317
|
}
|
|
281
318
|
const srcDir = new URL(srcFlag, `file://${process.cwd()}/`).pathname
|
|
282
319
|
const write = flags.get('write') === true
|
|
320
|
+
// Lazy: the codemods parse with the TypeScript compiler API
|
|
321
|
+
// (~24 MB). Only this command loads it; importing the package
|
|
322
|
+
// (or running doctor/import/export) never does.
|
|
323
|
+
const codemods: typeof Codemods = await import('./codemods.js')
|
|
283
324
|
const changed: string[] = []
|
|
284
325
|
const manual: string[] = []
|
|
285
326
|
const walk = (dir: string): void => {
|
|
@@ -304,7 +345,7 @@ async function main(
|
|
|
304
345
|
walk(full)
|
|
305
346
|
} else if (['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'].includes(extname(entry))) {
|
|
306
347
|
const before = readFileSync(full, 'utf8')
|
|
307
|
-
const result = applyCodemods(relative(srcDir, full), before)
|
|
348
|
+
const result = codemods.applyCodemods(relative(srcDir, full), before)
|
|
308
349
|
if (result.changed) {
|
|
309
350
|
if (write) {
|
|
310
351
|
writeFileSync(full, result.after)
|
|
@@ -331,11 +372,15 @@ async function main(
|
|
|
331
372
|
// finalize [--manifest ...]
|
|
332
373
|
// (no --config: manifest-only; no Payload instance is loaded)
|
|
333
374
|
if (command === 'finalize') {
|
|
334
|
-
await runFinalize(
|
|
335
|
-
|
|
375
|
+
const finalized = await runFinalize({
|
|
376
|
+
log,
|
|
377
|
+
...(typeof flags.get('manifest') === 'string'
|
|
336
378
|
? { manifestPath: flags.get('manifest') as string }
|
|
337
|
-
: {}
|
|
338
|
-
)
|
|
379
|
+
: {}),
|
|
380
|
+
})
|
|
381
|
+
if (json) {
|
|
382
|
+
out.write(`${JSON.stringify({ finalized: finalized.finalized })}\n`)
|
|
383
|
+
}
|
|
339
384
|
return 0
|
|
340
385
|
}
|
|
341
386
|
// The remaining commands (from-payload-auth, verify, rollback) all
|
|
@@ -345,6 +390,29 @@ async function main(
|
|
|
345
390
|
err.write(`${command} requires --config <path to payload.config.ts>\n`)
|
|
346
391
|
return 3
|
|
347
392
|
}
|
|
393
|
+
if (command === 'adopt-prelink') {
|
|
394
|
+
const usersSlug = flags.get('users-slug')
|
|
395
|
+
const verifiedField = flags.get('verified-field')
|
|
396
|
+
const result = await runAdoptPrelink({
|
|
397
|
+
configPath: new URL(configFlag, `file://${process.cwd()}/`).pathname,
|
|
398
|
+
apply: flags.get('apply') === true,
|
|
399
|
+
json,
|
|
400
|
+
log,
|
|
401
|
+
...(typeof flags.get('manifest') === 'string'
|
|
402
|
+
? { manifestPath: flags.get('manifest') as string }
|
|
403
|
+
: {}),
|
|
404
|
+
...(typeof usersSlug === 'string' ? { usersSlug } : {}),
|
|
405
|
+
...(typeof verifiedField === 'string' ? { verifiedField } : {}),
|
|
406
|
+
})
|
|
407
|
+
if (json) {
|
|
408
|
+
out.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
409
|
+
}
|
|
410
|
+
if (result.unmappable.length > 0) {
|
|
411
|
+
err.write(`ABORT: ${result.unmappable.length} unmappable user(s); nothing was written\n`)
|
|
412
|
+
return 1
|
|
413
|
+
}
|
|
414
|
+
return 0
|
|
415
|
+
}
|
|
348
416
|
if ((command === 'from-payload-auth' || command === 'verify') && databaseUrl === '') {
|
|
349
417
|
err.write(`${command} requires --database-url or DATABASE_URL\n`)
|
|
350
418
|
return 3
|
|
@@ -365,10 +433,29 @@ async function main(
|
|
|
365
433
|
...(typeof flags.get('source-map') === 'string'
|
|
366
434
|
? { sourceMapJson: flags.get('source-map') as string }
|
|
367
435
|
: {}),
|
|
368
|
-
json
|
|
436
|
+
json,
|
|
437
|
+
log,
|
|
369
438
|
}
|
|
370
439
|
if (command === 'from-payload-auth') {
|
|
371
440
|
const outcome = await runFromPayloadAuth(importOptions)
|
|
441
|
+
if (json) {
|
|
442
|
+
out.write(
|
|
443
|
+
`${JSON.stringify(
|
|
444
|
+
{
|
|
445
|
+
exitCode: outcome.exitCode,
|
|
446
|
+
created: outcome.result?.created ?? 0,
|
|
447
|
+
skipped: outcome.result?.skipped ?? 0,
|
|
448
|
+
fieldWrites: outcome.result?.fieldWrites ?? 0,
|
|
449
|
+
rows: summarizePlanRows(outcome.plan.rows),
|
|
450
|
+
unmappable: outcome.plan.unmappable,
|
|
451
|
+
warnings: outcome.plan.warnings,
|
|
452
|
+
unverified: outcome.unverified.length,
|
|
453
|
+
},
|
|
454
|
+
null,
|
|
455
|
+
2
|
|
456
|
+
)}\n`
|
|
457
|
+
)
|
|
458
|
+
}
|
|
372
459
|
if (outcome.plan.unmappable.length > 0) {
|
|
373
460
|
err.write(
|
|
374
461
|
`ABORT: ${outcome.plan.unmappable.length} unmappable row(s); nothing was silently skipped\n`
|
|
@@ -379,10 +466,16 @@ async function main(
|
|
|
379
466
|
}
|
|
380
467
|
if (command === 'verify') {
|
|
381
468
|
const result = await runVerify(importOptions)
|
|
469
|
+
if (json) {
|
|
470
|
+
out.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
471
|
+
}
|
|
382
472
|
return result.ok ? 0 : 1
|
|
383
473
|
}
|
|
384
474
|
if (command === 'rollback') {
|
|
385
|
-
await runRollback(importOptions)
|
|
475
|
+
const undone = await runRollback(importOptions)
|
|
476
|
+
if (json) {
|
|
477
|
+
out.write(`${JSON.stringify(undone)}\n`)
|
|
478
|
+
}
|
|
386
479
|
return 0
|
|
387
480
|
}
|
|
388
481
|
}
|
|
@@ -423,7 +516,16 @@ const invokedDirectly =
|
|
|
423
516
|
if (invokedDirectly) {
|
|
424
517
|
main(process.argv.slice(2))
|
|
425
518
|
.then((code) => {
|
|
519
|
+
// Every resource the command opened is closed by now (issue #88:
|
|
520
|
+
// Payload destroyed, pg clients ended); exit explicitly anyway,
|
|
521
|
+
// after stdout/stderr drain, so a third-party handle in the app's
|
|
522
|
+
// config (a cron, a websocket) can never park a finished CLI.
|
|
426
523
|
process.exitCode = code
|
|
524
|
+
process.stdout.write('', () => {
|
|
525
|
+
process.stderr.write('', () => {
|
|
526
|
+
process.exit(code)
|
|
527
|
+
})
|
|
528
|
+
})
|
|
427
529
|
})
|
|
428
530
|
.catch((error: unknown) => {
|
|
429
531
|
// Name the command that failed (issue #71) — 'doctor failed:' was
|
package/src/doctor/database.ts
CHANGED
|
@@ -100,18 +100,41 @@ export async function introspectDatabase(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
let unverifiedUsers: number | null = null
|
|
103
|
+
let bannedUsers: number | null = null
|
|
104
|
+
let anonymousUsers: number | null = null
|
|
103
105
|
const usersTable = tables['users']
|
|
104
106
|
if (usersTable !== null && (await tableExists(client, usersTable))) {
|
|
105
107
|
const columns = await columnsOf(client, usersTable)
|
|
106
|
-
|
|
108
|
+
const countWhere = async (predicate: string): Promise<number | null> => {
|
|
107
109
|
const result = await client.query(
|
|
108
|
-
`SELECT count(*)::int AS n FROM ${usersTable} WHERE
|
|
110
|
+
`SELECT count(*)::int AS n FROM ${usersTable} WHERE ${predicate}`
|
|
109
111
|
)
|
|
110
112
|
const first = result.rows[0] as { n?: number } | undefined
|
|
111
|
-
|
|
113
|
+
return first?.n ?? null
|
|
114
|
+
}
|
|
115
|
+
if (columns.has('email_verified')) {
|
|
116
|
+
unverifiedUsers = await countWhere('email_verified = false')
|
|
117
|
+
}
|
|
118
|
+
// Better Auth plugin state the import must carry (issue #88).
|
|
119
|
+
if (columns.has('banned')) {
|
|
120
|
+
bannedUsers = await countWhere('banned = true')
|
|
121
|
+
}
|
|
122
|
+
if (columns.has('is_anonymous')) {
|
|
123
|
+
anonymousUsers = await countWhere('is_anonymous = true')
|
|
112
124
|
}
|
|
113
125
|
}
|
|
114
126
|
|
|
127
|
+
// OAuth-provider plugin tables (oauthProvider / oidcProvider /
|
|
128
|
+
// mcp): the importer does not carry them (issue #88).
|
|
129
|
+
const oauthResult = await client.query(
|
|
130
|
+
`SELECT table_name FROM information_schema.tables
|
|
131
|
+
WHERE table_schema = 'public' AND table_name LIKE 'oauth\\_%'
|
|
132
|
+
ORDER BY table_name`
|
|
133
|
+
)
|
|
134
|
+
const oauthTables = oauthResult.rows.map((row) =>
|
|
135
|
+
String((row as { table_name: unknown }).table_name)
|
|
136
|
+
)
|
|
137
|
+
|
|
115
138
|
const orphans: { kind: string; count: number }[] = []
|
|
116
139
|
const skippedChecks: string[] = []
|
|
117
140
|
const orphanChecks: readonly {
|
|
@@ -206,7 +229,16 @@ export async function introspectDatabase(
|
|
|
206
229
|
}
|
|
207
230
|
}
|
|
208
231
|
|
|
209
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
tables: counted,
|
|
234
|
+
unverifiedUsers,
|
|
235
|
+
orphans,
|
|
236
|
+
missingTables: missing,
|
|
237
|
+
skippedChecks,
|
|
238
|
+
bannedUsers,
|
|
239
|
+
anonymousUsers,
|
|
240
|
+
oauthTables,
|
|
241
|
+
}
|
|
210
242
|
} finally {
|
|
211
243
|
await client.end()
|
|
212
244
|
}
|
package/src/doctor/run-doctor.ts
CHANGED
|
@@ -143,6 +143,29 @@ export function collectionSlugs(config: unknown): string[] {
|
|
|
143
143
|
.filter((slug): slug is string => typeof slug === 'string')
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Top-level field names of one collection in the loaded config, or
|
|
148
|
+
* `null` when the config declares no such collection.
|
|
149
|
+
*/
|
|
150
|
+
function collectionFieldNames(config: unknown, slug: string): Set<string> | null {
|
|
151
|
+
if (!isRecord(config) || !Array.isArray(config.collections)) {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
const collection = config.collections.find(
|
|
155
|
+
(entry): entry is Record<string, unknown> => isRecord(entry) && entry.slug === slug
|
|
156
|
+
)
|
|
157
|
+
if (collection === undefined || !Array.isArray(collection.fields)) {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
const names = new Set<string>()
|
|
161
|
+
for (const field of collection.fields) {
|
|
162
|
+
if (isRecord(field) && typeof field.name === 'string') {
|
|
163
|
+
names.add(field.name)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return names
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
/** Default config loader: a dynamic import of the TS/JS config module. */
|
|
147
170
|
async function defaultLoadModule(path: string): Promise<unknown> {
|
|
148
171
|
const mod: unknown = await import(pathToFileURL(path).href)
|
|
@@ -241,6 +264,13 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
241
264
|
const files = filesById.get(finding.id)
|
|
242
265
|
return files === undefined ? finding : { ...finding, files }
|
|
243
266
|
})
|
|
267
|
+
if (pluginIds.includes('generic-oauth')) {
|
|
268
|
+
warnings.push({
|
|
269
|
+
code: 'generic-oauth-providers',
|
|
270
|
+
detail:
|
|
271
|
+
"genericOAuth() is declared: its provider list lives in the engine options, not the database — carry every `config` entry (providerId, discovery URL, client id/secret, scopes) into the new engine's genericOAuth() call. The importer copies the accounts rows (provider_id stays), so a provider missing from the new config strands those users' sign-in",
|
|
272
|
+
})
|
|
273
|
+
}
|
|
244
274
|
for (const finding of pluginFindings) {
|
|
245
275
|
if (finding.status === 'removed-upstream') {
|
|
246
276
|
blockers.push({
|
|
@@ -316,6 +346,45 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
316
346
|
for (const skip of found.skippedChecks ?? []) {
|
|
317
347
|
warnings.push({ code: 'orphan-check-skipped', detail: skip })
|
|
318
348
|
}
|
|
349
|
+
// Better Auth plugin state and tables the migration must account
|
|
350
|
+
// for (issue #88). When the loaded config is the TARGET (it
|
|
351
|
+
// declares the engine's ba-users collection), a target that
|
|
352
|
+
// cannot hold the state is a blocker — the importer refuses it.
|
|
353
|
+
const baUsersFields = configLoaded ? collectionFieldNames(config, 'ba-users') : null
|
|
354
|
+
const banned = found.bannedUsers ?? 0
|
|
355
|
+
if (banned > 0) {
|
|
356
|
+
if (baUsersFields !== null && !baUsersFields.has('banned')) {
|
|
357
|
+
blockers.push({
|
|
358
|
+
code: 'target-cannot-hold-bans',
|
|
359
|
+
detail: `${banned} banned user(s) on the source, but this config's ba-users collection declares no 'banned' field — add Better Auth's admin() plugin to the engine (autoDeclare derives banned/banReason/banExpires); the importer refuses to import a banned user unbanned`,
|
|
360
|
+
})
|
|
361
|
+
} else {
|
|
362
|
+
warnings.push({
|
|
363
|
+
code: 'banned-users',
|
|
364
|
+
detail: `${banned} banned user(s) (Better Auth admin plugin): the importer carries banned/banReason/banExpires onto ba-users only when the target engine declares admin(), and refuses the run otherwise — a banned user never imports unbanned`,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const anonymous = found.anonymousUsers ?? 0
|
|
369
|
+
if (anonymous > 0) {
|
|
370
|
+
if (baUsersFields !== null && !baUsersFields.has('isAnonymous')) {
|
|
371
|
+
blockers.push({
|
|
372
|
+
code: 'target-cannot-hold-anonymous',
|
|
373
|
+
detail: `${anonymous} anonymous user(s) on the source, but this config's ba-users collection declares no 'isAnonymous' field — add Better Auth's anonymous() plugin to the engine, or delete the anonymous users before importing`,
|
|
374
|
+
})
|
|
375
|
+
} else {
|
|
376
|
+
warnings.push({
|
|
377
|
+
code: 'anonymous-users',
|
|
378
|
+
detail: `${anonymous} anonymous user(s) (Better Auth anonymous plugin): they import with isAnonymous only when the target engine declares anonymous(), and the plugin refuses anonymous sessions by design — they never JIT-link to a Payload document`,
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if ((found.oauthTables ?? []).length > 0) {
|
|
383
|
+
warnings.push({
|
|
384
|
+
code: 'oauth-tables-not-carried',
|
|
385
|
+
detail: `OAuth-provider tables present (${(found.oauthTables ?? []).join(', ')}): the importer does NOT copy them — registered OAuth clients, consents, and issued tokens stay in the legacy tables. Re-register the clients on @better-auth/oauth-provider (a separate schema migration) before dropping the legacy tables`,
|
|
386
|
+
})
|
|
387
|
+
}
|
|
319
388
|
} catch (error) {
|
|
320
389
|
warnings.push({
|
|
321
390
|
code: 'database-unreachable',
|
package/src/doctor/types.ts
CHANGED
|
@@ -80,6 +80,18 @@ export type DatabaseFinding = {
|
|
|
80
80
|
readonly missingTables?: readonly { readonly model: string; readonly table: string }[]
|
|
81
81
|
/** Orphan checks that could not run, with the reason (issue #68). */
|
|
82
82
|
readonly skippedChecks?: readonly string[]
|
|
83
|
+
/**
|
|
84
|
+
* Users banned by Better Auth's admin plugin (`banned = true`), or
|
|
85
|
+
* `null` when the source has no `banned` column (issue #88).
|
|
86
|
+
*/
|
|
87
|
+
readonly bannedUsers?: number | null
|
|
88
|
+
/** Anonymous-plugin users (`is_anonymous = true`), or `null` (issue #88). */
|
|
89
|
+
readonly anonymousUsers?: number | null
|
|
90
|
+
/**
|
|
91
|
+
* OAuth-provider plugin tables present (`oauth_*`, issue #88). The
|
|
92
|
+
* importer does NOT carry them; the doctor says so explicitly.
|
|
93
|
+
*/
|
|
94
|
+
readonly oauthTables?: readonly string[]
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
/** Default-behaviour changes in payload-auth-link 0.7.0 that bite. */
|
package/src/doctor/users.ts
CHANGED
|
@@ -98,6 +98,15 @@ export function localStrategyOn(collection: Record<string, unknown>): boolean {
|
|
|
98
98
|
return isRecord(auth) ? auth.disableLocalStrategy !== true : false
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The payload-auth-link plugin's own auth strategy name
|
|
103
|
+
* (`packages/plugin/src/strategy.ts`). A users collection carrying it
|
|
104
|
+
* has ALREADY been through `authLink` — its identities/linkable/…
|
|
105
|
+
* fields are the plugin's own injections, not host collisions
|
|
106
|
+
* (issue #88: the doctor run against the TARGET config).
|
|
107
|
+
*/
|
|
108
|
+
const pluginStrategyName = 'auth-link'
|
|
109
|
+
|
|
101
110
|
export function analyzeUsersCollection(
|
|
102
111
|
collection: Record<string, unknown> | null,
|
|
103
112
|
rolesFieldName: string
|
|
@@ -114,10 +123,20 @@ export function analyzeUsersCollection(
|
|
|
114
123
|
const issues: DoctorIssue[] = []
|
|
115
124
|
const notes: string[] = []
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
// On the target config the plugin already injected these fields:
|
|
127
|
+
// they are its own, never a collision (issue #88).
|
|
128
|
+
const pluginInstalled = strategiesOf(collection).includes(pluginStrategyName)
|
|
129
|
+
const collisions = pluginInstalled
|
|
130
|
+
? []
|
|
131
|
+
: fields
|
|
132
|
+
.map(fieldName)
|
|
133
|
+
.filter((name): name is string => name !== null)
|
|
134
|
+
.filter((name) => injectedFieldNames.has(name))
|
|
135
|
+
if (pluginInstalled) {
|
|
136
|
+
notes.push(
|
|
137
|
+
'payload-auth-link is already installed on this collection (its auth-link strategy is present): the injected fields are the plugin’s own'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
121
140
|
|
|
122
141
|
let roleField: UsersFinding['roleField'] = null
|
|
123
142
|
const roleCandidates = fields.filter((field) => {
|