@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.
- 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,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
|
+
}
|