@payload-auth-link/migrate 0.0.0-stage → 0.2.1

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 (72) hide show
  1. package/README.md +49 -2
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +257 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/doctor/bands.d.ts +16 -0
  7. package/dist/doctor/bands.d.ts.map +1 -0
  8. package/dist/doctor/bands.js +49 -0
  9. package/dist/doctor/bands.js.map +1 -0
  10. package/dist/doctor/database.d.ts +3 -0
  11. package/dist/doctor/database.d.ts.map +1 -0
  12. package/dist/doctor/database.js +110 -0
  13. package/dist/doctor/database.js.map +1 -0
  14. package/dist/doctor/plugins.d.ts +12 -0
  15. package/dist/doctor/plugins.d.ts.map +1 -0
  16. package/dist/doctor/plugins.js +202 -0
  17. package/dist/doctor/plugins.js.map +1 -0
  18. package/dist/doctor/run-doctor.d.ts +48 -0
  19. package/dist/doctor/run-doctor.d.ts.map +1 -0
  20. package/dist/doctor/run-doctor.js +236 -0
  21. package/dist/doctor/run-doctor.js.map +1 -0
  22. package/dist/doctor/source-scan.d.ts +10 -0
  23. package/dist/doctor/source-scan.d.ts.map +1 -0
  24. package/dist/doctor/source-scan.js +142 -0
  25. package/dist/doctor/source-scan.js.map +1 -0
  26. package/dist/doctor/types.d.ts +86 -0
  27. package/dist/doctor/types.d.ts.map +1 -0
  28. package/dist/doctor/types.js +7 -0
  29. package/dist/doctor/types.js.map +1 -0
  30. package/dist/doctor/users.d.ts +10 -0
  31. package/dist/doctor/users.d.ts.map +1 -0
  32. package/dist/doctor/users.js +176 -0
  33. package/dist/doctor/users.js.map +1 -0
  34. package/dist/importer/apply.d.ts +39 -0
  35. package/dist/importer/apply.d.ts.map +1 -0
  36. package/dist/importer/apply.js +588 -0
  37. package/dist/importer/apply.js.map +1 -0
  38. package/dist/importer/lifecycle.d.ts +52 -0
  39. package/dist/importer/lifecycle.d.ts.map +1 -0
  40. package/dist/importer/lifecycle.js +265 -0
  41. package/dist/importer/lifecycle.js.map +1 -0
  42. package/dist/importer/run-import.d.ts +40 -0
  43. package/dist/importer/run-import.d.ts.map +1 -0
  44. package/dist/importer/run-import.js +176 -0
  45. package/dist/importer/run-import.js.map +1 -0
  46. package/dist/importer/source.d.ts +49 -0
  47. package/dist/importer/source.d.ts.map +1 -0
  48. package/dist/importer/source.js +147 -0
  49. package/dist/importer/source.js.map +1 -0
  50. package/dist/importer/types.d.ts +69 -0
  51. package/dist/importer/types.d.ts.map +1 -0
  52. package/dist/importer/types.js +13 -0
  53. package/dist/importer/types.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +17 -0
  57. package/dist/index.js.map +1 -0
  58. package/package.json +70 -4
  59. package/src/cli.ts +287 -0
  60. package/src/doctor/bands.ts +66 -0
  61. package/src/doctor/database.ts +130 -0
  62. package/src/doctor/plugins.ts +216 -0
  63. package/src/doctor/run-doctor.ts +278 -0
  64. package/src/doctor/source-scan.ts +148 -0
  65. package/src/doctor/types.ts +92 -0
  66. package/src/doctor/users.ts +207 -0
  67. package/src/importer/apply.ts +731 -0
  68. package/src/importer/lifecycle.ts +328 -0
  69. package/src/importer/run-import.ts +237 -0
  70. package/src/importer/source.ts +211 -0
  71. package/src/importer/types.ts +96 -0
  72. package/src/index.ts +53 -0
@@ -0,0 +1,66 @@
1
+ import {
2
+ describeBand,
3
+ isSupportedVersion,
4
+ readInstalledVersion,
5
+ type VersionRange,
6
+ } from '@payload-auth-link/core'
7
+ import type { VersionFinding } from './types.js'
8
+
9
+ /**
10
+ * The bands the doctor checks against. These mirror the authoritative
11
+ * constants — `supportedPayloadBand` (packages/plugin) and
12
+ * `supportedBetterAuthBand` (packages/provider-better-auth) — and a
13
+ * unit test asserts the copies never drift from them, so the doctor
14
+ * cannot promise a band the runtime would refuse.
15
+ */
16
+ export const payloadBand: readonly VersionRange[] = [
17
+ { min: '3.83.0', maxExclusive: '4.0.0' },
18
+ { exact: '4.0.0-beta.0' },
19
+ ]
20
+ export const betterAuthBand: readonly VersionRange[] = [{ min: '1.7.6', maxExclusive: '1.8.0' }]
21
+ /** Next is a peer of the Next adapter; the doctor reports it informationally. */
22
+ export const nextBand: readonly VersionRange[] = [{ min: '15.0.0', maxExclusive: '17.0.0' }]
23
+
24
+ /**
25
+ * Better Auth minors this doctor knows the payload-auth app must move
26
+ * off: 1.6 removed `oidcProvider` in 1.7 and payload-auth's peer range
27
+ * still admits 1.4+, so an app can sit far below the supported band
28
+ * without anything telling it.
29
+ */
30
+ function versionFinding(
31
+ name: string,
32
+ band: readonly VersionRange[],
33
+ fromUrl: string,
34
+ note?: (installed: string | null) => string | undefined
35
+ ): VersionFinding {
36
+ const installed = readInstalledVersion(name, fromUrl)
37
+ const status =
38
+ installed === null ? 'unknown' : isSupportedVersion(installed, band) ? 'ok' : 'blocker'
39
+ const base =
40
+ installed === null
41
+ ? 'could not read the installed manifest; resolve the version manually'
42
+ : isSupportedVersion(installed, band)
43
+ ? `installed ${installed} is inside the supported band`
44
+ : `installed ${installed} is OUTSIDE the supported band; upgrade before migrating`
45
+ return {
46
+ name,
47
+ installed,
48
+ band: describeBand(band),
49
+ status,
50
+ detail: [base, note?.(installed)].filter((part) => part !== undefined).join(' — '),
51
+ }
52
+ }
53
+
54
+ /** Band checks for the three upstreams that gate a migration. */
55
+ export function checkVersions(fromUrl: string): VersionFinding[] {
56
+ return [
57
+ versionFinding('payload', payloadBand, fromUrl),
58
+ versionFinding('better-auth', betterAuthBand, fromUrl, (installed) =>
59
+ installed !== null &&
60
+ (installed.startsWith('1.4.') || installed.startsWith('1.5.') || installed.startsWith('1.6.'))
61
+ ? 'better-auth 1.7 removed `oidcProvider` and the `mcp` plugin; an OAuth 2.1 server moves to @better-auth/oauth-provider (a schema migration — see the journey notes in issue #60)'
62
+ : undefined
63
+ ),
64
+ versionFinding('next', nextBand, fromUrl),
65
+ ]
66
+ }
@@ -0,0 +1,130 @@
1
+ import { Client } from 'pg'
2
+ import type { DatabaseFinding } from './types.js'
3
+
4
+ /**
5
+ * Read-only database introspection (issue #52): row counts for the
6
+ * payload-auth layout's auth tables, unverified users (who will not
7
+ * JIT-link), and orphans. Only an honest SELECT — the doctor never
8
+ * writes.
9
+ */
10
+
11
+ /**
12
+ * The payload-auth 1.9.x Postgres layout (snake_case columns; Payload's
13
+ * drizzle adapter stores hasMany selects as child tables like
14
+ * `users_role`). Tables that don't exist are simply absent from the
15
+ * report — apps customize slugs and drop plugins.
16
+ */
17
+ const knownTables: readonly string[] = [
18
+ 'users',
19
+ 'accounts',
20
+ 'sessions',
21
+ 'verifications',
22
+ 'api_keys',
23
+ 'organizations',
24
+ 'members',
25
+ 'invitations',
26
+ 'jwks',
27
+ 'admin_invitations',
28
+ 'users_role',
29
+ 'two_factors',
30
+ 'passkeys',
31
+ 'oauth_applications',
32
+ 'oauth_access_tokens',
33
+ 'oauth_consents',
34
+ 'teams',
35
+ 'team_members',
36
+ 'subscriptions',
37
+ 'device_code',
38
+ 'sso_providers',
39
+ ]
40
+
41
+ async function tableExists(client: Client, table: string): Promise<boolean> {
42
+ const result = await client.query(
43
+ `SELECT 1 FROM information_schema.tables
44
+ WHERE table_schema = 'public' AND table_name = $1`,
45
+ [table]
46
+ )
47
+ return result.rows.length > 0
48
+ }
49
+
50
+ /**
51
+ * Discover the columns a table actually has, so checks adapt to
52
+ * customized layouts instead of assuming.
53
+ */
54
+ async function columnsOf(client: Client, table: string): Promise<Set<string>> {
55
+ const result = await client.query(
56
+ `SELECT column_name FROM information_schema.columns
57
+ WHERE table_schema = 'public' AND table_name = $1`,
58
+ [table]
59
+ )
60
+ return new Set(result.rows.map((row) => String((row as { column_name: unknown }).column_name)))
61
+ }
62
+
63
+ export async function introspectDatabase(connectionString: string): Promise<DatabaseFinding> {
64
+ const client = new Client({ connectionString })
65
+ await client.connect()
66
+ try {
67
+ const tables: { name: string; rows: number }[] = []
68
+ for (const table of knownTables) {
69
+ if (!(await tableExists(client, table))) {
70
+ continue
71
+ }
72
+ const counted = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
73
+ const first = counted.rows[0] as { n?: number } | undefined
74
+ tables.push({ name: table, rows: first?.n ?? 0 })
75
+ }
76
+
77
+ let unverifiedUsers: number | null = null
78
+ if (await tableExists(client, 'users')) {
79
+ const columns = await columnsOf(client, 'users')
80
+ if (columns.has('email_verified')) {
81
+ const counted = await client.query(
82
+ 'SELECT count(*)::int AS n FROM users WHERE email_verified = false'
83
+ )
84
+ const first = counted.rows[0] as { n?: number } | undefined
85
+ unverifiedUsers = first?.n ?? null
86
+ }
87
+ }
88
+
89
+ const orphans: { kind: string; count: number }[] = []
90
+ const orphanChecks: readonly { kind: string; table: string; column: string; target: string }[] =
91
+ [
92
+ { kind: 'accounts-without-user', table: 'accounts', column: 'user_id', target: 'users' },
93
+ {
94
+ kind: 'members-without-org',
95
+ table: 'members',
96
+ column: 'organization_id',
97
+ target: 'organizations',
98
+ },
99
+ { kind: 'members-without-user', table: 'members', column: 'user_id', target: 'users' },
100
+ {
101
+ kind: 'invitations-without-org',
102
+ table: 'invitations',
103
+ column: 'organization_id',
104
+ target: 'organizations',
105
+ },
106
+ ]
107
+ for (const check of orphanChecks) {
108
+ if (
109
+ (await tableExists(client, check.table)) &&
110
+ (await tableExists(client, check.target)) &&
111
+ (await columnsOf(client, check.table)).has(check.column)
112
+ ) {
113
+ const counted = await client.query(
114
+ `SELECT count(*)::int AS n FROM ${check.table}
115
+ WHERE ${check.column} IS NOT NULL
116
+ AND NOT EXISTS (SELECT 1 FROM ${check.target} t WHERE t.id = ${check.table}.${check.column})`
117
+ )
118
+ const first = counted.rows[0] as { n?: number } | undefined
119
+ const count = first?.n ?? 0
120
+ if (count > 0) {
121
+ orphans.push({ kind: check.kind, count })
122
+ }
123
+ }
124
+ }
125
+
126
+ return { tables, unverifiedUsers, orphans }
127
+ } finally {
128
+ await client.end()
129
+ }
130
+ }
@@ -0,0 +1,216 @@
1
+ import type { PluginFinding } from './types.js'
2
+
3
+ /**
4
+ * Better Auth plugin classification (issue #52): for each plugin the
5
+ * app's `betterAuthOptions.plugins` declares, the doctor says whether
6
+ * payload-auth-link's engine supports it out of the box
7
+ * (`autoDeclare` derives its models from Better Auth's own
8
+ * `getAuthTables`), whether it is a probed satellite, or whether it
9
+ * blocks migration.
10
+ */
11
+
12
+ /**
13
+ * Core + first-party plugins the engine derives confidently. Mirrors
14
+ * what the derive-schema snapshot tests pin in
15
+ * packages/provider-better-auth; new entries land together with a
16
+ * derived-schema probe.
17
+ */
18
+ const autoDeclared: ReadonlySet<string> = new Set([
19
+ 'magic-link',
20
+ 'email-otp',
21
+ 'anonymous',
22
+ 'two-factor',
23
+ 'admin',
24
+ 'organization',
25
+ 'jwt',
26
+ 'username',
27
+ 'phone-number',
28
+ 'generic-oauth',
29
+ 'one-time-token',
30
+ 'multi-session',
31
+ 'bearer',
32
+ 'expo',
33
+ 'siwe',
34
+ 'next-cookies',
35
+ 'tanstack-start-cookies',
36
+ 'svelte-kit-cookies',
37
+ 'solid-start-cookies',
38
+ ])
39
+
40
+ /** Satellites with a committed schema probe in this repo (compat matrix). */
41
+ const probedSatellites: ReadonlySet<string> = new Set([
42
+ '@better-auth/passkey',
43
+ '@better-auth/api-key',
44
+ ])
45
+
46
+ /** Satellites explicitly unsupported until probed (see compatibility.md). */
47
+ const unsupportedSatellites: ReadonlyMap<string, string> = new Map([
48
+ [
49
+ '@better-auth/sso',
50
+ 'not probed by payload-auth-link; adding it meets UnsupportedSchemaError at wiring time',
51
+ ],
52
+ ])
53
+
54
+ /**
55
+ * Payload-auth's own plugin additions the engine has no equivalent
56
+ * for. `oidcProvider` is the headline: Better Auth 1.7 removed it, and
57
+ * the replacement (@better-auth/oauth-provider) uses different tables,
58
+ * so the data move is the app's phase-1 prerequisite.
59
+ */
60
+ const removedUpstream: ReadonlyMap<string, string> = new Map([
61
+ [
62
+ 'oidc-provider',
63
+ 'Better Auth 1.7 removed `oidcProvider`; moving to @better-auth/oauth-provider is a schema migration (oauthApplication → oauthClient, token splits, jwks gains alg/crv) — plan it before the auth migration',
64
+ ],
65
+ [
66
+ 'mcp',
67
+ 'Better Auth 1.7 removed the `mcp` plugin; move its surface to @better-auth/oauth-provider',
68
+ ],
69
+ ])
70
+
71
+ /** The satellite's own id convention: package name or its short id. */
72
+ function satelliteId(id: string): string {
73
+ return id.startsWith('@better-auth/') ? id : `@better-auth/${id}`
74
+ }
75
+
76
+ export function classifyPlugins(ids: readonly string[]): PluginFinding[] {
77
+ const findings: PluginFinding[] = []
78
+ for (const raw of ids) {
79
+ const id = raw.trim()
80
+ if (id === '') {
81
+ continue
82
+ }
83
+ const satellite = satelliteId(id)
84
+ if (removedUpstream.has(id) || removedUpstream.has(satellite)) {
85
+ findings.push({
86
+ id,
87
+ status: 'removed-upstream',
88
+ detail: removedUpstream.get(id) ?? removedUpstream.get(satellite) ?? '',
89
+ })
90
+ continue
91
+ }
92
+ if (id === 'oidcProvider') {
93
+ const detail = removedUpstream.get('oidc-provider') ?? ''
94
+ findings.push({ id, status: 'removed-upstream', detail })
95
+ continue
96
+ }
97
+ if (unsupportedSatellites.has(satellite)) {
98
+ findings.push({
99
+ id,
100
+ status: 'unsupported',
101
+ detail: unsupportedSatellites.get(satellite) ?? '',
102
+ })
103
+ continue
104
+ }
105
+ if (probedSatellites.has(satellite)) {
106
+ findings.push({
107
+ id,
108
+ status: 'supported',
109
+ detail: 'probed satellite — schema covered by the committed probe and the compat matrix',
110
+ })
111
+ continue
112
+ }
113
+ if (autoDeclared.has(id)) {
114
+ findings.push({
115
+ id,
116
+ status: 'auto-declarable',
117
+ detail: 'derived from Better Auth’s own getAuthTables via autoDeclare',
118
+ })
119
+ continue
120
+ }
121
+ if (satellite !== id) {
122
+ findings.push({
123
+ id,
124
+ status: 'unprobed',
125
+ detail:
126
+ 'Better Auth satellite without a committed probe; the engine will derive its models but nothing here has validated them',
127
+ })
128
+ continue
129
+ }
130
+ findings.push({
131
+ id,
132
+ status: 'unprobed',
133
+ detail:
134
+ 'unknown plugin id; the engine derives what Better Auth’s schema reports, but treat it as unvalidated until probed',
135
+ })
136
+ }
137
+ return findings
138
+ }
139
+
140
+ /**
141
+ * payload-auth does not keep the builder's Better Auth options on the
142
+ * built config (the plugin closure holds them), so the doctor derives
143
+ * the plugin set from what IS visible: the collections payload-auth
144
+ * built (each plugin model gets a slug) and the config source's plugin
145
+ * calls (for plugins without dedicated tables — magic link, cookies).
146
+ * Both probes feed the same classifier.
147
+ */
148
+
149
+ /** payload-auth slug (baPluginSlugs) → Better Auth plugin id. */
150
+ const slugToPlugin: Readonly<Record<string, string>> = {
151
+ apiKeys: 'api-key',
152
+ jwks: 'jwt',
153
+ twoFactors: 'two-factor',
154
+ passkeys: 'passkey',
155
+ oauthApplications: 'oidcProvider',
156
+ oauthAccessTokens: 'oidcProvider',
157
+ oauthConsents: 'oidcProvider',
158
+ ssoProviders: 'sso',
159
+ organizations: 'organization',
160
+ members: 'organization',
161
+ invitations: 'organization',
162
+ organizationRoles: 'organization',
163
+ teams: 'organization',
164
+ teamMembers: 'organization',
165
+ deviceCode: 'device-authorization',
166
+ subscriptions: 'subscriptions',
167
+ }
168
+
169
+ /** Plugin ids proven live by the collections payload-auth built. */
170
+ export function pluginsFromCollectionSlugs(slugs: readonly string[]): string[] {
171
+ const ids = new Set<string>()
172
+ for (const slug of slugs) {
173
+ const id: string | undefined = slugToPlugin[slug]
174
+ if (id !== undefined) {
175
+ ids.add(id)
176
+ }
177
+ }
178
+ return [...ids].sort()
179
+ }
180
+
181
+ /** Plugin call names a config source can name, mapped to plugin ids. */
182
+ const callNameToPlugin: Readonly<Record<string, string>> = {
183
+ magicLink: 'magic-link',
184
+ emailOTP: 'email-otp',
185
+ anonymous: 'anonymous',
186
+ twoFactor: 'two-factor',
187
+ admin: 'admin',
188
+ organization: 'organization',
189
+ jwt: 'jwt',
190
+ username: 'username',
191
+ phoneNumber: 'phone-number',
192
+ genericOAuth: 'generic-oauth',
193
+ nextCookies: 'next-cookies',
194
+ tanstackStartCookies: 'tanstack-start-cookies',
195
+ oidcProvider: 'oidcProvider',
196
+ passkey: 'passkey',
197
+ apiKey: 'api-key',
198
+ sso: 'sso',
199
+ }
200
+
201
+ /**
202
+ * Plugin ids found by scanning the config source for plugin factory
203
+ * calls. Regex on purpose: the loaded config cannot reveal these
204
+ * (closure-held options), and the doctor must not execute app code
205
+ * beyond loading its config module.
206
+ */
207
+ export function pluginsFromConfigSource(configSource: string): string[] {
208
+ const ids = new Set<string>()
209
+ for (const [call, id] of Object.entries(callNameToPlugin)) {
210
+ const pattern = new RegExp(`\\b${call}\\s*\\(`)
211
+ if (pattern.test(configSource)) {
212
+ ids.add(id)
213
+ }
214
+ }
215
+ return [...ids].sort()
216
+ }
@@ -0,0 +1,278 @@
1
+ import { isRecord } from '@payload-auth-link/core'
2
+ import { readFileSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { checkVersions } from './bands.js'
6
+ import { introspectDatabase } from './database.js'
7
+ import { classifyPlugins, pluginsFromCollectionSlugs, pluginsFromConfigSource } from './plugins.js'
8
+ import { scanSource } from './source-scan.js'
9
+ import { analyzeUsersCollection, findUsersCollection } from './users.js'
10
+ import type { BehaviourChange, DoctorIssue, DoctorReport } from './types.js'
11
+
12
+ /**
13
+ * The read-only doctor (issue #52): point it at a payload-auth app and
14
+ * get the exact list of what breaks before touching anything. It loads
15
+ * the app's config module, inspects the users collection and Better
16
+ * Auth plugins, scans the source tree for call sites that must
17
+ * migrate, and (with a database URL) reports row counts, unverified
18
+ * users, and orphans. It never writes.
19
+ */
20
+
21
+ export type DoctorOptions = {
22
+ /** Absolute or cwd-relative path to the app's payload config. */
23
+ readonly configPath: string
24
+ /** Source directory to scan. Defaults to the config's directory. */
25
+ readonly srcDir?: string | undefined
26
+ /** Postgres connection string. Defaults to `DATABASE_URL`. */
27
+ readonly databaseUrl?: string | undefined
28
+ /** Config loader, injectable for tests. Defaults to dynamic import. */
29
+ readonly loadModule?: ((path: string) => Promise<unknown>) | undefined
30
+ }
31
+
32
+ /**
33
+ * Behaviour changes in payload-auth-link 0.7.0 that a payload-auth app
34
+ * feels on day one. Static by nature; kept next to the doctor so the
35
+ * report is one artifact.
36
+ */
37
+ export const behaviourChanges: readonly BehaviourChange[] = [
38
+ {
39
+ title: 'Provider-linked documents lose local sign-in and forgot/reset',
40
+ detail:
41
+ '0.7.0 makes documents with linked identities provider-owned: Payload local login and the self-service forgot/reset flow are refused for them (GHSA-f4hj). Break-glass operator accounts (no linked identities) keep everything. Set allowLocalCredentialsForLinkedDocs: true only for a migration window.',
42
+ },
43
+ {
44
+ title: 'Suspension is enforced on every strategy',
45
+ detail:
46
+ 'suspendedUntil now refuses provider sessions, local sign-ins, existing local JWTs and API keys on the next request — including refresh-token and GraphQL.',
47
+ },
48
+ {
49
+ title: 'Collection access defaults tighten',
50
+ detail:
51
+ 'Engine collections default to deny-all API access, and the users collection ships governed defaults for update/delete/unlock. Hosts opening access do so explicitly.',
52
+ },
53
+ {
54
+ title: 'req.user is a projection by default',
55
+ detail:
56
+ "req.user carries { id, collection, email, roles } plus assurance/actor fields, not the full document. Access functions reading other fields see undefined — audit them or opt into reqUser: 'document' (#57).",
57
+ },
58
+ ]
59
+
60
+ /**
61
+ * Detect payload-auth in the built config. The plugin stamps
62
+ * `config.custom.hasBetterAuthPlugin = true` when it runs; older or
63
+ * future builds that instead expose `pluginOptions` on the plugin
64
+ * function are honored too.
65
+ */
66
+ export function findPayloadAuthPlugin(config: unknown): {
67
+ pluginOptions: Record<string, unknown>
68
+ } | null {
69
+ if (!isRecord(config)) {
70
+ return null
71
+ }
72
+ if (isRecord(config.custom) && config.custom.hasBetterAuthPlugin === true) {
73
+ return { pluginOptions: {} }
74
+ }
75
+ if (!Array.isArray(config.plugins)) {
76
+ return null
77
+ }
78
+ for (const plugin of config.plugins) {
79
+ if (typeof plugin === 'function' && 'pluginOptions' in plugin) {
80
+ const options: unknown = (plugin as { pluginOptions: unknown }).pluginOptions
81
+ if (isRecord(options)) {
82
+ return { pluginOptions: options }
83
+ }
84
+ }
85
+ }
86
+ return null
87
+ }
88
+
89
+ /**
90
+ * The Better Auth plugin ids the app declared. Reads them off
91
+ * `betterAuthOptions.plugins` when a pluginOptions object is visible;
92
+ * callers otherwise derive them from the built collections and the
93
+ * config source (see {@link pluginsFromCollectionSlugs} and
94
+ * {@link pluginsFromConfigSource}).
95
+ */
96
+ export function betterAuthPluginIds(pluginOptions: Record<string, unknown>): string[] {
97
+ const baOptions: unknown = pluginOptions.betterAuthOptions
98
+ if (!isRecord(baOptions) || !Array.isArray(baOptions.plugins)) {
99
+ return []
100
+ }
101
+ const ids: string[] = []
102
+ for (const plugin of baOptions.plugins) {
103
+ if (isRecord(plugin) && typeof plugin.id === 'string' && plugin.id !== '') {
104
+ ids.push(plugin.id)
105
+ }
106
+ }
107
+ return ids
108
+ }
109
+
110
+ /** The users collection slug the payload-auth app configured. */
111
+ export function usersSlugOf(pluginOptions: Record<string, unknown>): string {
112
+ const users: unknown = pluginOptions.users
113
+ return isRecord(users) && typeof users.slug === 'string' && users.slug !== ''
114
+ ? users.slug
115
+ : 'users'
116
+ }
117
+
118
+ /** Collection slugs of the built config. */
119
+ export function collectionSlugs(config: unknown): string[] {
120
+ if (!isRecord(config) || !Array.isArray(config.collections)) {
121
+ return []
122
+ }
123
+ return config.collections
124
+ .filter(isRecord)
125
+ .map((collection) => collection.slug)
126
+ .filter((slug): slug is string => typeof slug === 'string')
127
+ }
128
+
129
+ /** Default config loader: a dynamic import of the TS/JS config module. */
130
+ async function defaultLoadModule(path: string): Promise<unknown> {
131
+ const mod: unknown = await import(pathToFileURL(path).href)
132
+ let config: unknown = isRecord(mod) && 'default' in mod ? mod.default : mod
133
+ // Payload configs may be async (buildConfig returns a Promise when the
134
+ // config awaits) — the doctor reads the resolved object either way.
135
+ if (typeof (config as Partial<Promise<unknown>> | undefined)?.then === 'function') {
136
+ config = await config
137
+ }
138
+ return config
139
+ }
140
+
141
+ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
142
+ const configPath = resolve(options.configPath)
143
+ const loadModule = options.loadModule ?? defaultLoadModule
144
+ const config = await loadModule(configPath)
145
+ const configDir = dirname(configPath)
146
+ const srcDir = options.srcDir === undefined ? configDir : resolve(options.srcDir)
147
+
148
+ const versions = checkVersions(pathToFileURL(configPath).href)
149
+ const blockers: DoctorIssue[] = []
150
+ const warnings: DoctorIssue[] = []
151
+ for (const finding of versions) {
152
+ if (finding.status === 'blocker') {
153
+ blockers.push({
154
+ code: `${finding.name}-out-of-band`,
155
+ detail: `${finding.name}: ${finding.detail}`,
156
+ })
157
+ } else if (finding.status === 'unknown') {
158
+ warnings.push({
159
+ code: `${finding.name}-version-unknown`,
160
+ detail: `${finding.name}: ${finding.detail}`,
161
+ })
162
+ }
163
+ }
164
+
165
+ const plugin = findPayloadAuthPlugin(config)
166
+ if (plugin === null) {
167
+ warnings.push({
168
+ code: 'payload-auth-not-detected',
169
+ detail:
170
+ 'no payload-auth plugin found in the config; if this app runs payload-auth elsewhere, point --config at that config',
171
+ })
172
+ }
173
+ if (plugin !== null) {
174
+ blockers.push({
175
+ code: 'payload-auth-installed',
176
+ detail:
177
+ 'payload-auth is installed and configured: migrate with adopt (#49, zero row moves) or the importer (#50, reversible copy). Its admin views, payload.betterAuth accessor, and user-table layout all change.',
178
+ })
179
+ }
180
+
181
+ // payload-auth keeps the builder's options in its closure, so the
182
+ // plugin set comes from what the built config proves: the collections
183
+ // payload-auth created and the plugin calls in the config source.
184
+ let configSource: string
185
+ try {
186
+ configSource = readFileSync(configPath, 'utf8')
187
+ } catch {
188
+ configSource = ''
189
+ }
190
+ const pluginIds = [
191
+ ...new Set([
192
+ ...betterAuthPluginIds(plugin?.pluginOptions ?? {}),
193
+ ...pluginsFromCollectionSlugs(collectionSlugs(config)),
194
+ ...pluginsFromConfigSource(configSource),
195
+ ]),
196
+ ]
197
+ const pluginFindings = classifyPlugins(pluginIds)
198
+ for (const finding of pluginFindings) {
199
+ if (finding.status === 'removed-upstream') {
200
+ blockers.push({
201
+ code: 'ba-plugin-removed-upstream',
202
+ detail: `${finding.id}: ${finding.detail}`,
203
+ })
204
+ } else if (finding.status === 'unsupported' || finding.status === 'unprobed') {
205
+ warnings.push({ code: 'ba-plugin-unvalidated', detail: `${finding.id}: ${finding.detail}` })
206
+ }
207
+ }
208
+
209
+ const usersSlug = usersSlugOf(plugin?.pluginOptions ?? {})
210
+ const { finding: users, issues: userIssues } = analyzeUsersCollection(
211
+ findUsersCollection(config, usersSlug),
212
+ 'roles'
213
+ )
214
+ for (const issue of userIssues) {
215
+ if (
216
+ issue.code === 'injected-field-collision' ||
217
+ issue.code === 'roles-field-not-select' ||
218
+ issue.code === 'users-collection-missing'
219
+ ) {
220
+ blockers.push(issue)
221
+ } else {
222
+ warnings.push(issue)
223
+ }
224
+ }
225
+
226
+ const source = scanSource(srcDir)
227
+ for (const finding of source) {
228
+ if (finding.kind === 'ba-internals') {
229
+ blockers.push({
230
+ code: 'ba-internals-usage',
231
+ detail: `${finding.files.length} file(s) use Better Auth internals (${finding.files.join(', ')}): these break across Better Auth minors and hand-roll session cookies — rewrite on the supported server API (#54) before migrating`,
232
+ })
233
+ }
234
+ }
235
+
236
+ const databaseUrl = options.databaseUrl ?? process.env.DATABASE_URL
237
+ let database: DoctorReport['database'] = null
238
+ if (databaseUrl !== undefined && databaseUrl !== '') {
239
+ try {
240
+ const found = await introspectDatabase(databaseUrl)
241
+ database = found
242
+ if (found.unverifiedUsers !== null && found.unverifiedUsers > 0) {
243
+ warnings.push({
244
+ code: 'unverified-users',
245
+ detail: `${found.unverifiedUsers} user(s) have emailVerified = false: they will not JIT-link on a provider sign-in; they need a verified email (or a role manager's manual link) after migration`,
246
+ })
247
+ }
248
+ for (const orphan of found.orphans) {
249
+ blockers.push({
250
+ code: 'orphan-rows',
251
+ detail: `${orphan.count} orphaned row(s) of kind '${orphan.kind}': the importer refuses unmappable references rather than silently skipping them`,
252
+ })
253
+ }
254
+ } catch (error) {
255
+ warnings.push({
256
+ code: 'database-unreachable',
257
+ detail: `could not inspect the database: ${error instanceof Error ? error.message : String(error)}`,
258
+ })
259
+ }
260
+ } else {
261
+ warnings.push({
262
+ code: 'database-not-inspected',
263
+ detail:
264
+ 'no database URL (pass --database-url or set DATABASE_URL); row counts, unverified users, and orphans were not checked',
265
+ })
266
+ }
267
+
268
+ return {
269
+ versions,
270
+ plugins: pluginFindings,
271
+ users,
272
+ source,
273
+ database,
274
+ behaviourChanges,
275
+ blockers,
276
+ warnings,
277
+ }
278
+ }