@payload-auth-link/migrate 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -2
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor/bands.d.ts +16 -0
- package/dist/doctor/bands.d.ts.map +1 -0
- package/dist/doctor/bands.js +49 -0
- package/dist/doctor/bands.js.map +1 -0
- package/dist/doctor/database.d.ts +3 -0
- package/dist/doctor/database.d.ts.map +1 -0
- package/dist/doctor/database.js +110 -0
- package/dist/doctor/database.js.map +1 -0
- package/dist/doctor/plugins.d.ts +12 -0
- package/dist/doctor/plugins.d.ts.map +1 -0
- package/dist/doctor/plugins.js +202 -0
- package/dist/doctor/plugins.js.map +1 -0
- package/dist/doctor/run-doctor.d.ts +48 -0
- package/dist/doctor/run-doctor.d.ts.map +1 -0
- package/dist/doctor/run-doctor.js +236 -0
- package/dist/doctor/run-doctor.js.map +1 -0
- package/dist/doctor/source-scan.d.ts +10 -0
- package/dist/doctor/source-scan.d.ts.map +1 -0
- package/dist/doctor/source-scan.js +142 -0
- package/dist/doctor/source-scan.js.map +1 -0
- package/dist/doctor/types.d.ts +86 -0
- package/dist/doctor/types.d.ts.map +1 -0
- package/dist/doctor/types.js +7 -0
- package/dist/doctor/types.js.map +1 -0
- package/dist/doctor/users.d.ts +10 -0
- package/dist/doctor/users.d.ts.map +1 -0
- package/dist/doctor/users.js +176 -0
- package/dist/doctor/users.js.map +1 -0
- package/dist/importer/apply.d.ts +39 -0
- package/dist/importer/apply.d.ts.map +1 -0
- package/dist/importer/apply.js +588 -0
- package/dist/importer/apply.js.map +1 -0
- package/dist/importer/lifecycle.d.ts +52 -0
- package/dist/importer/lifecycle.d.ts.map +1 -0
- package/dist/importer/lifecycle.js +265 -0
- package/dist/importer/lifecycle.js.map +1 -0
- package/dist/importer/run-import.d.ts +40 -0
- package/dist/importer/run-import.d.ts.map +1 -0
- package/dist/importer/run-import.js +176 -0
- package/dist/importer/run-import.js.map +1 -0
- package/dist/importer/source.d.ts +49 -0
- package/dist/importer/source.d.ts.map +1 -0
- package/dist/importer/source.js +147 -0
- package/dist/importer/source.js.map +1 -0
- package/dist/importer/types.d.ts +69 -0
- package/dist/importer/types.d.ts.map +1 -0
- package/dist/importer/types.js +13 -0
- package/dist/importer/types.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -4
- package/src/cli.ts +287 -0
- package/src/doctor/bands.ts +66 -0
- package/src/doctor/database.ts +130 -0
- package/src/doctor/plugins.ts +216 -0
- package/src/doctor/run-doctor.ts +278 -0
- package/src/doctor/source-scan.ts +148 -0
- package/src/doctor/types.ts +92 -0
- package/src/doctor/users.ts +207 -0
- package/src/importer/apply.ts +731 -0
- package/src/importer/lifecycle.ts +328 -0
- package/src/importer/run-import.ts +237 -0
- package/src/importer/source.ts +211 -0
- package/src/importer/types.ts +96 -0
- 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
|
+
}
|