@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,148 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { extname, join, relative } from 'node:path'
3
+ import type { SourceFinding } from './types.js'
4
+
5
+ /**
6
+ * Read-only source scan (issue #52): the call sites a payload-auth app
7
+ * must migrate, found by pattern over `--src`. Regex-level on
8
+ * purpose — the doctor must run before any code changes, on apps the
9
+ * compiler cannot type-check against payload-auth-link yet.
10
+ */
11
+
12
+ const scanExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'])
13
+ const skipDirs = new Set([
14
+ 'node_modules',
15
+ 'dist',
16
+ '.next',
17
+ '.turbo',
18
+ 'coverage',
19
+ 'test-results',
20
+ 'playwright-report',
21
+ ])
22
+
23
+ const payloadAuthImport =
24
+ /from\s+['"]payload-auth(?:\/[A-Za-z0-9_./-]+)?['"]|import\s*\(\s*['"]payload-auth(?:\/[A-Za-z0-9_./-]+)?['"]/g
25
+ const betterAuthAccessor = /\bpayload\.betterAuth\b|\bbetterAuth\.api\b/g
26
+ const getPayloadAuth = /\bgetPayloadAuth\s*\(/g
27
+ // Holding the Better Auth context at all (`$context`) means reaching
28
+ // past the supported surface — hand-hashed keys, hand-signed cookies —
29
+ // whether or not the call sits adjacent on one line.
30
+ const baInternals = /\$context\b|\binternalAdapter\b/g
31
+ // `req` and `request` are both idiomatic parameter names.
32
+ const reqUserField = /\b(?:req|request)\.user\.([A-Za-z_$][A-Za-z0-9_$]*)/g
33
+
34
+ /**
35
+ * The fields the auth-link strategy projects onto `req.user` by
36
+ * default (`reqUser: 'projection'`). Anything else a payload-auth app
37
+ * reads off `req.user` is `undefined` under the projection — silent
38
+ * access-control drift — until `reqUser: 'document'` lands (#57).
39
+ */
40
+ export const projectedUserFields: ReadonlySet<string> = new Set([
41
+ 'id',
42
+ 'collection',
43
+ 'email',
44
+ 'roles',
45
+ 'mfaRequired',
46
+ 'assurance',
47
+ 'actor',
48
+ 'organizationId',
49
+ ])
50
+
51
+ function walk(dir: string, into: string[]): void {
52
+ let entries: string[]
53
+ try {
54
+ entries = readdirSync(dir)
55
+ } catch {
56
+ return
57
+ }
58
+ for (const entry of entries) {
59
+ if (skipDirs.has(entry) || entry.startsWith('.')) {
60
+ continue
61
+ }
62
+ const full = join(dir, entry)
63
+ let stats
64
+ try {
65
+ stats = statSync(full)
66
+ } catch {
67
+ continue
68
+ }
69
+ if (stats.isDirectory()) {
70
+ walk(full, into)
71
+ } else if (scanExtensions.has(extname(entry))) {
72
+ into.push(full)
73
+ }
74
+ }
75
+ }
76
+
77
+ export function scanSource(srcDir: string): SourceFinding[] {
78
+ const files: string[] = []
79
+ walk(srcDir, files)
80
+ const importFiles = new Set<string>()
81
+ const accessorFiles = new Set<string>()
82
+ const getPayloadAuthFiles = new Set<string>()
83
+ const internalsFiles = new Set<string>()
84
+ const reqUserFiles = new Set<string>()
85
+ const reqUserFields = new Set<string>()
86
+ for (const file of files) {
87
+ let content: string
88
+ try {
89
+ content = readFileSync(file, 'utf8')
90
+ } catch {
91
+ continue
92
+ }
93
+ const track = (pattern: RegExp, into: Set<string>): void => {
94
+ pattern.lastIndex = 0
95
+ if (pattern.test(content)) {
96
+ into.add(file)
97
+ }
98
+ }
99
+ track(payloadAuthImport, importFiles)
100
+ track(betterAuthAccessor, accessorFiles)
101
+ track(getPayloadAuth, getPayloadAuthFiles)
102
+ track(baInternals, internalsFiles)
103
+ for (const match of content.matchAll(reqUserField)) {
104
+ const field = match[1]
105
+ if (field !== undefined && !projectedUserFields.has(field)) {
106
+ reqUserFields.add(field)
107
+ reqUserFiles.add(file)
108
+ }
109
+ }
110
+ }
111
+ const rel = (file: string): string => relative(srcDir, file)
112
+ const findings: SourceFinding[] = [
113
+ {
114
+ kind: 'payload-auth-import',
115
+ files: [...importFiles].map(rel).sort(),
116
+ fields: [],
117
+ detail: 'imports from payload-auth; each becomes a payload-auth-link import (codemod, #53)',
118
+ },
119
+ {
120
+ kind: 'get-payload-auth',
121
+ files: [...getPayloadAuthFiles].map(rel).sort(),
122
+ fields: [],
123
+ detail: 'getPayloadAuth(config) call sites; become getPayload({ config }) (codemod, #53)',
124
+ },
125
+ {
126
+ kind: 'better-auth-accessor',
127
+ files: [...accessorFiles].map(rel).sort(),
128
+ fields: [],
129
+ detail:
130
+ 'payload.betterAuth accessors; migrate to the supported session API (#54), with the opt-in shim (#53) in between',
131
+ },
132
+ {
133
+ kind: 'ba-internals',
134
+ files: [...internalsFiles].map(rel).sort(),
135
+ fields: [],
136
+ detail:
137
+ 'Better Auth internals ($context.internalAdapter / $context.adapter) — unsupported; the supported server API replaces each bridge (#54)',
138
+ },
139
+ {
140
+ kind: 'req-user-field',
141
+ files: [...reqUserFiles].map(rel).sort(),
142
+ fields: [...reqUserFields].sort(),
143
+ detail:
144
+ "req.user.<field> reads outside the session projection; under the default projection they read undefined — audit each or opt into reqUser: 'document' (#57)",
145
+ },
146
+ ]
147
+ return findings.filter((finding) => finding.files.length > 0)
148
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The doctor's report shape (issue #52): read-only findings, every
3
+ * one carrying a stable machine code so tests (and the guide) assert
4
+ * exact blocker lists instead of prose.
5
+ */
6
+
7
+ /** A finding the doctor is confident about: fix it before migrating. */
8
+ export type DoctorIssue = {
9
+ /** Stable machine code — e.g. `better-auth-out-of-band`. */
10
+ readonly code: string
11
+ /** Human detail: what was found, what closes it. */
12
+ readonly detail: string
13
+ }
14
+
15
+ /** One upstream dependency against its supported band. */
16
+ export type VersionFinding = {
17
+ readonly name: string
18
+ /** `null` when no manifest resolved (exotic bundling). */
19
+ readonly installed: string | null
20
+ /** The supported band, human-readable. */
21
+ readonly band: string
22
+ readonly status: 'ok' | 'warning' | 'blocker' | 'unknown'
23
+ readonly detail: string
24
+ }
25
+
26
+ /** One Better Auth plugin's migration posture. */
27
+ export type PluginFinding = {
28
+ /** The plugin's `id` (or the satellite package name). */
29
+ readonly id: string
30
+ readonly status: 'supported' | 'auto-declarable' | 'unprobed' | 'unsupported' | 'removed-upstream'
31
+ readonly detail: string
32
+ }
33
+
34
+ /** The users collection, as the migration will meet it. */
35
+ export type UsersFinding = {
36
+ /** Collection slug (payload-auth's `users` by default). */
37
+ readonly slug: string
38
+ readonly roleField: {
39
+ readonly name: string
40
+ readonly type: string
41
+ readonly hasMany: boolean
42
+ /** Declared select values, when the field is a select. */
43
+ readonly values: readonly string[]
44
+ /** Values that are not canonical lowercase slugs. */
45
+ readonly nonCanonical: readonly string[]
46
+ } | null
47
+ /** Payload's own email+password local strategy is active. */
48
+ readonly localStrategy: boolean
49
+ /** Host fields colliding with the plugin's injected field names. */
50
+ readonly collisions: readonly string[]
51
+ readonly notes: readonly string[]
52
+ }
53
+
54
+ /** One source-scan category with the files that matched. */
55
+ export type SourceFinding = {
56
+ readonly kind:
57
+ | 'payload-auth-import'
58
+ | 'better-auth-accessor'
59
+ | 'get-payload-auth'
60
+ | 'ba-internals'
61
+ | 'req-user-field'
62
+ readonly files: readonly string[]
63
+ /** For `req-user-field`: every field read off `req.user`. */
64
+ readonly fields: readonly string[]
65
+ readonly detail: string
66
+ }
67
+
68
+ /** Database state, as far as a read-only connection can see. */
69
+ export type DatabaseFinding = {
70
+ readonly tables: readonly { readonly name: string; readonly rows: number }[]
71
+ /** Users with `emailVerified = false` — they will not JIT-link. */
72
+ readonly unverifiedUsers: number | null
73
+ readonly orphans: readonly { readonly kind: string; readonly count: number }[]
74
+ }
75
+
76
+ /** Default-behaviour changes in payload-auth-link 0.7.0 that bite. */
77
+ export type BehaviourChange = {
78
+ readonly title: string
79
+ readonly detail: string
80
+ }
81
+
82
+ /** The full read-only doctor report (`--json` prints this object). */
83
+ export type DoctorReport = {
84
+ readonly versions: readonly VersionFinding[]
85
+ readonly plugins: readonly PluginFinding[]
86
+ readonly users: UsersFinding | null
87
+ readonly source: readonly SourceFinding[]
88
+ readonly database: DatabaseFinding | null
89
+ readonly behaviourChanges: readonly BehaviourChange[]
90
+ readonly blockers: readonly DoctorIssue[]
91
+ readonly warnings: readonly DoctorIssue[]
92
+ }
@@ -0,0 +1,207 @@
1
+ import { isRecord } from '@payload-auth-link/core'
2
+ import type { DoctorIssue, UsersFinding } from './types.js'
3
+
4
+ /**
5
+ * The users collection analysis (issue #52): collisions with the
6
+ * plugin's injected fields, the existing role field and its values,
7
+ * and whether Payload's local strategy is on — the three things that
8
+ * decide whether a payload-auth app's user table can be adopted as-is.
9
+ */
10
+
11
+ /**
12
+ * The plugin's injected users-collection field names (see
13
+ * `packages/plugin/src/collections.ts`). The `roles` field is special:
14
+ * the plugin ADOPTS a compatible host select instead of throwing, so a
15
+ * host `roles`/`role` field is analyzed as the role field, not
16
+ * reported as a collision.
17
+ */
18
+ const injectedFieldNames: ReadonlySet<string> = new Set([
19
+ 'identities',
20
+ 'phone',
21
+ 'suspendedUntil',
22
+ 'suspendReason',
23
+ 'linkable',
24
+ 'linkableEmail',
25
+ ])
26
+
27
+ /** Canonical roles are lowercase slugs (`staff`, `front-plugin`). */
28
+ const canonicalRole = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
29
+
30
+ /** Narrow a config field list; configs are untyped at this boundary. */
31
+ function fieldList(collection: Record<string, unknown>): Record<string, unknown>[] {
32
+ const fields: unknown = collection.fields
33
+ if (!Array.isArray(fields)) {
34
+ return []
35
+ }
36
+ return fields.filter(isRecord)
37
+ }
38
+
39
+ function fieldName(field: Record<string, unknown>): string | null {
40
+ return typeof field.name === 'string' && field.name !== '' ? field.name : null
41
+ }
42
+
43
+ function fieldType(field: Record<string, unknown>): string {
44
+ return typeof field.type === 'string' ? field.type : 'unknown'
45
+ }
46
+
47
+ function selectValues(field: Record<string, unknown>): string[] {
48
+ const options: unknown = field.options
49
+ if (!Array.isArray(options)) {
50
+ return []
51
+ }
52
+ return options
53
+ .map((option) => (typeof option === 'string' ? option : isRecord(option) ? option.value : null))
54
+ .filter((value): value is string => typeof value === 'string')
55
+ }
56
+
57
+ /** Find the payload-auth-shaped users collection (or the app's own users slug). */
58
+ export function findUsersCollection(
59
+ config: unknown,
60
+ usersSlug: string
61
+ ): Record<string, unknown> | null {
62
+ if (!isRecord(config) || !Array.isArray(config.collections)) {
63
+ return null
64
+ }
65
+ for (const collection of config.collections) {
66
+ if (isRecord(collection) && collection.slug === usersSlug) {
67
+ return collection
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ /**
74
+ * Payload's local email+password strategy for a collection. payload-auth
75
+ * leaves it on (its own Better Auth strategy rides alongside), which is
76
+ * exactly the surface 0.7.0 closes for provider-linked documents.
77
+ */
78
+ /** Names of the custom auth strategies a collection declares. */
79
+ function strategiesOf(collection: Record<string, unknown>): string[] {
80
+ const auth: unknown = collection.auth
81
+ if (!isRecord(auth) || !Array.isArray(auth.strategies)) {
82
+ return []
83
+ }
84
+ return auth.strategies
85
+ .filter(isRecord)
86
+ .map((strategy) => strategy.name)
87
+ .filter((name): name is string => typeof name === 'string')
88
+ }
89
+
90
+ export function localStrategyOn(collection: Record<string, unknown>): boolean {
91
+ const auth: unknown = collection.auth
92
+ if (auth === undefined || auth === null) {
93
+ return false
94
+ }
95
+ if (auth === true) {
96
+ return true
97
+ }
98
+ return isRecord(auth) ? auth.disableLocalStrategy !== true : false
99
+ }
100
+
101
+ export function analyzeUsersCollection(
102
+ collection: Record<string, unknown> | null,
103
+ rolesFieldName: string
104
+ ): { finding: UsersFinding | null; issues: DoctorIssue[] } {
105
+ if (collection === null) {
106
+ return {
107
+ finding: null,
108
+ issues: [
109
+ { code: 'users-collection-missing', detail: 'no users collection found in the config' },
110
+ ],
111
+ }
112
+ }
113
+ const fields = fieldList(collection)
114
+ const issues: DoctorIssue[] = []
115
+ const notes: string[] = []
116
+
117
+ const collisions = fields
118
+ .map(fieldName)
119
+ .filter((name): name is string => name !== null)
120
+ .filter((name) => injectedFieldNames.has(name))
121
+
122
+ let roleField: UsersFinding['roleField'] = null
123
+ const roleCandidates = fields.filter((field) => {
124
+ const name = fieldName(field)
125
+ return name === rolesFieldName || name === 'role'
126
+ })
127
+ for (const field of roleCandidates) {
128
+ const name = fieldName(field) ?? 'role'
129
+ if (fieldType(field) === 'select') {
130
+ const values = selectValues(field)
131
+ const nonCanonical = values.filter((value) => !canonicalRole.test(value))
132
+ roleField = {
133
+ name,
134
+ type: fieldType(field),
135
+ hasMany: field.hasMany === true,
136
+ values,
137
+ nonCanonical,
138
+ }
139
+ if (nonCanonical.length > 0) {
140
+ issues.push({
141
+ code: 'non-canonical-role-value',
142
+ detail: `the '${name}' field stores non-canonical value(s) ${nonCanonical
143
+ .map((value) => `'${value}'`)
144
+ .join(
145
+ ', '
146
+ )}; the role value map (#56) keeps them stored as-is while roles stay canonical inside the plugin`,
147
+ })
148
+ }
149
+ if (field.hasMany !== true) {
150
+ issues.push({
151
+ code: 'roles-field-single-select',
152
+ detail: `the '${name}' field is a single-select; the plugin can only adopt a multi-select (hasMany) covering every configured role`,
153
+ })
154
+ }
155
+ } else if (name === rolesFieldName) {
156
+ issues.push({
157
+ code: 'roles-field-not-select',
158
+ detail: `the '${rolesFieldName}' field is a '${fieldType(field)}', not a select; rename it and let the plugin inject its governed field`,
159
+ })
160
+ }
161
+ }
162
+
163
+ if (collisions.length > 0) {
164
+ issues.push({
165
+ code: 'injected-field-collision',
166
+ detail: `field(s) ${collisions
167
+ .map((name) => `'${name}'`)
168
+ .join(
169
+ ', '
170
+ )} collide with the plugin's injected fields; rename them before booting payload-auth-link (its config throws on collisions)`,
171
+ })
172
+ }
173
+
174
+ const local = localStrategyOn(collection)
175
+ if (local) {
176
+ notes.push(
177
+ 'Payload local strategy is ON (payload-auth leaves it active): since 0.7.0, provider-linked documents refuse local sign-in and forgot/reset by default — break-glass operator accounts keep theirs; allowLocalCredentialsForLinkedDocs: true reopens it only for a migration window'
178
+ )
179
+ } else {
180
+ const strategies = Array.isArray(collection.auth) ? [] : strategiesOf(collection)
181
+ notes.push(
182
+ strategies.length > 0
183
+ ? `Payload local strategy is OFF; authentication rides custom strategy/ies: ${strategies.join(', ')}`
184
+ : 'Payload local strategy is OFF for this collection'
185
+ )
186
+ }
187
+ if (isRecord(collection.auth) && collection.auth.useAPIKey === true) {
188
+ notes.push(
189
+ 'useAPIKey is enabled: API keys are refused for suspended users at request time since 0.7.0 and never carry MFA-gated roles'
190
+ )
191
+ }
192
+
193
+ return {
194
+ finding: {
195
+ slug: typeof collection.slug === 'string' ? collection.slug : 'users',
196
+ roleField,
197
+ localStrategy: local,
198
+ collisions,
199
+ notes,
200
+ },
201
+ issues,
202
+ }
203
+ }
204
+
205
+ export function injectedFieldNamesForDocs(): readonly string[] {
206
+ return [...injectedFieldNames, 'roles', 'email']
207
+ }