@payload-auth-link/migrate 0.4.2 → 0.5.0

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