@payload-auth-link/migrate 0.5.0 → 0.7.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 (68) hide show
  1. package/README.md +20 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +88 -27
  4. package/dist/cli.js.map +1 -1
  5. package/dist/doctor/plugins.d.ts.map +1 -1
  6. package/dist/doctor/plugins.js +6 -2
  7. package/dist/doctor/plugins.js.map +1 -1
  8. package/dist/doctor/run-doctor.d.ts.map +1 -1
  9. package/dist/doctor/run-doctor.js +27 -2
  10. package/dist/doctor/run-doctor.js.map +1 -1
  11. package/dist/doctor/users.d.ts +13 -1
  12. package/dist/doctor/users.d.ts.map +1 -1
  13. package/dist/doctor/users.js +17 -7
  14. package/dist/doctor/users.js.map +1 -1
  15. package/dist/exporters.d.ts +43 -5
  16. package/dist/exporters.d.ts.map +1 -1
  17. package/dist/exporters.js +88 -5
  18. package/dist/exporters.js.map +1 -1
  19. package/dist/importer/apply.d.ts +2 -0
  20. package/dist/importer/apply.d.ts.map +1 -1
  21. package/dist/importer/apply.js +88 -24
  22. package/dist/importer/apply.js.map +1 -1
  23. package/dist/importer/guards.d.ts +49 -0
  24. package/dist/importer/guards.d.ts.map +1 -0
  25. package/dist/importer/guards.js +175 -0
  26. package/dist/importer/guards.js.map +1 -0
  27. package/dist/importer/host-writes.d.ts +6 -2
  28. package/dist/importer/host-writes.d.ts.map +1 -1
  29. package/dist/importer/host-writes.js +9 -4
  30. package/dist/importer/host-writes.js.map +1 -1
  31. package/dist/importer/lifecycle.d.ts +60 -13
  32. package/dist/importer/lifecycle.d.ts.map +1 -1
  33. package/dist/importer/lifecycle.js +130 -24
  34. package/dist/importer/lifecycle.js.map +1 -1
  35. package/dist/importer/manifest-mac.d.ts +27 -0
  36. package/dist/importer/manifest-mac.d.ts.map +1 -0
  37. package/dist/importer/manifest-mac.js +93 -0
  38. package/dist/importer/manifest-mac.js.map +1 -0
  39. package/dist/importer/prelink.d.ts +2 -0
  40. package/dist/importer/prelink.d.ts.map +1 -1
  41. package/dist/importer/prelink.js +20 -3
  42. package/dist/importer/prelink.js.map +1 -1
  43. package/dist/importer/run-import.d.ts +24 -5
  44. package/dist/importer/run-import.d.ts.map +1 -1
  45. package/dist/importer/run-import.js +36 -7
  46. package/dist/importer/run-import.js.map +1 -1
  47. package/dist/importer/types.d.ts +12 -1
  48. package/dist/importer/types.d.ts.map +1 -1
  49. package/dist/importer/types.js.map +1 -1
  50. package/dist/index.d.ts +3 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -1
  53. package/dist/index.js.map +1 -1
  54. package/package.json +10 -10
  55. package/src/cli.ts +87 -27
  56. package/src/doctor/plugins.ts +8 -3
  57. package/src/doctor/run-doctor.ts +30 -3
  58. package/src/doctor/users.ts +25 -7
  59. package/src/exporters.ts +113 -12
  60. package/src/importer/apply.ts +103 -23
  61. package/src/importer/guards.ts +223 -0
  62. package/src/importer/host-writes.ts +15 -4
  63. package/src/importer/lifecycle.ts +193 -32
  64. package/src/importer/manifest-mac.ts +125 -0
  65. package/src/importer/prelink.ts +24 -3
  66. package/src/importer/run-import.ts +64 -12
  67. package/src/importer/types.ts +12 -1
  68. package/src/index.ts +10 -1
@@ -1,5 +1,8 @@
1
+ import { isRecord } from '@payload-auth-link/core'
1
2
  import type { Payload } from 'payload'
3
+ import { assertCreatedRowsScoped, finalizeMarkerState, hostRolesField } from './guards.js'
2
4
  import { capturedKeys, captureBefore } from './host-writes.js'
5
+ import { authenticateManifest, signManifest } from './manifest-mac.js'
3
6
  import {
4
7
  readFlatRows,
5
8
  openSource,
@@ -69,6 +72,8 @@ export type ApplyOptions = {
69
72
  /** Injectable clock for deterministic `linkedAt` stamps. */
70
73
  readonly now?: () => Date
71
74
  readonly log?: (message: string) => void
75
+ /** Resume from a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
76
+ readonly allowUnsignedManifest?: boolean | undefined
72
77
  }
73
78
 
74
79
  const slugsOf = (overrides?: Readonly<Record<string, string>>): TargetSlugMap => ({
@@ -251,6 +256,41 @@ function identityRows(
251
256
  ]
252
257
  }
253
258
 
259
+ /** The host user document as stored (depth 0). */
260
+ async function hostDoc(payload: Payload, id: number): Promise<Record<string, unknown> | null> {
261
+ const doc: unknown = await payload.findByID({
262
+ collection: 'users',
263
+ id,
264
+ depth: 0,
265
+ overrideAccess: true,
266
+ })
267
+ return isRecord(doc) ? doc : null
268
+ }
269
+
270
+ /** The doc already carries exactly the identity the import writes. */
271
+ function hasExactIdentity(doc: Record<string, unknown> | null, user: SourceUser): boolean {
272
+ const identities = doc?.['identities']
273
+ if (!Array.isArray(identities) || identities.length !== 1) {
274
+ return false
275
+ }
276
+ const only: unknown = identities[0]
277
+ return (
278
+ isRecord(only) &&
279
+ only['providerId'] === 'better-auth' &&
280
+ String(only['providerUserId']) === String(user.id) &&
281
+ only['email'] === user.email
282
+ )
283
+ }
284
+
285
+ /** Same stored values, same order. */
286
+ function sameValues(stored: unknown, wanted: readonly string[]): boolean {
287
+ return (
288
+ Array.isArray(stored) &&
289
+ stored.length === wanted.length &&
290
+ stored.every((value, index) => String(value) === wanted[index])
291
+ )
292
+ }
293
+
254
294
  export async function applyImport(options: ApplyOptions): Promise<ImportResult> {
255
295
  const {
256
296
  payload,
@@ -262,6 +302,28 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
262
302
  } = options
263
303
  const slugs = slugsOf(options.slugs)
264
304
  const sourceTables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
305
+ if (options.manifest !== null) {
306
+ // Resuming merges the old entries and re-signs them: never launder
307
+ // an edited manifest into a signed one, never sign created rows
308
+ // outside the import's own targets, and never resume a migration
309
+ // the database records as finalized.
310
+ const trust = authenticateManifest(payload.secret, options.manifest, {
311
+ allowUnsigned: options.allowUnsignedManifest,
312
+ })
313
+ assertCreatedRowsScoped(options.manifest, slugs)
314
+ const finalized =
315
+ options.manifest.finalized ||
316
+ (apply &&
317
+ (await finalizeMarkerState(payload, options.manifest, {
318
+ anyOfKind: trust !== 'signed',
319
+ })) === 'finalized')
320
+ if (apply && finalized) {
321
+ throw new Error(
322
+ 'the migration is finalized; a finalized manifest is not resumed — keep it as the record, and pass a different --manifest for a new run'
323
+ )
324
+ }
325
+ }
326
+ const rolesField = hostRolesField(payload)
265
327
  const created: CreatedRow[] = [...(options.manifest?.created ?? [])]
266
328
  const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
267
329
  // First capture wins (issue #88): fields captured by an earlier pass
@@ -343,35 +405,53 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
343
405
  // restores the pre-import document exactly.
344
406
  const hostId = Number(baId)
345
407
  if (Number.isSafeInteger(hostId)) {
346
- const { present, newlyCaptured } = await captureBefore({
347
- payload,
348
- collection: 'users',
349
- id: hostId,
350
- fields: ['identities', 'roles', 'linkable', 'linkableEmail'],
351
- captured,
352
- into: fieldWrites,
353
- })
354
- fieldWriteCount += newlyCaptured
355
- const writes: Record<string, unknown> = {
356
- identities: identityRows(user, now),
408
+ // Only the values that DIFFER are written (issue #110): a
409
+ // second --apply over an imported user is a true no-op — no
410
+ // Local API update, so no fresh `updatedAt`, `linkedAt`, or
411
+ // re-created hasMany child rows.
412
+ const current = await hostDoc(payload, hostId)
413
+ const writes: Record<string, unknown> = {}
414
+ if (!hasExactIdentity(current, user)) {
415
+ writes.identities = identityRows(user, now)
357
416
  }
358
- if (user.roles.length > 0 && present.has('roles')) {
417
+ if (
418
+ user.roles.length > 0 &&
419
+ current !== null &&
420
+ rolesField in current &&
421
+ !sameValues(current[rolesField], user.roles)
422
+ ) {
359
423
  // Stored role values pass through unchanged (issue #56: the
360
424
  // storage edge owns translation; the map is the app's).
361
- writes.roles = user.roles
425
+ writes[rolesField] = user.roles
362
426
  }
363
- if (user.emailVerified) {
427
+ if (
428
+ user.emailVerified &&
429
+ (current?.['linkable'] !== true || current['linkableEmail'] !== user.email)
430
+ ) {
364
431
  // Grant-only: the proof is bound to the address (both
365
432
  // fields), and an existing proof is never revoked here.
366
433
  writes.linkable = true
367
434
  writes.linkableEmail = user.email
368
435
  }
369
- await payload.update({
370
- collection: 'users',
371
- id: hostId,
372
- data: writes,
373
- overrideAccess: true,
374
- })
436
+ if (Object.keys(writes).length > 0) {
437
+ // The prior values (and the doc's updatedAt) are captured
438
+ // first — once — so rollback restores the pre-import doc.
439
+ const { newlyCaptured } = await captureBefore({
440
+ payload,
441
+ collection: 'users',
442
+ id: hostId,
443
+ fields: ['identities', rolesField, 'linkable', 'linkableEmail'],
444
+ captured,
445
+ into: fieldWrites,
446
+ })
447
+ fieldWriteCount += newlyCaptured
448
+ await payload.update({
449
+ collection: 'users',
450
+ id: hostId,
451
+ data: writes,
452
+ overrideAccess: true,
453
+ })
454
+ }
375
455
  }
376
456
  }
377
457
  record({
@@ -732,8 +812,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
732
812
  if (apply) {
733
813
  // The manifest is the run's contract: merge this pass onto any
734
814
  // resumed run's rows and write it back (issue #50).
735
- const manifest: ImportManifest = {
736
- version: 2,
815
+ const manifest: ImportManifest = signManifest(payload.secret, {
816
+ version: 3,
737
817
  kind: 'import',
738
818
  createdAt: options.manifest?.createdAt ?? now().toISOString(),
739
819
  sourceDatabase: options.sourceDatabase,
@@ -743,7 +823,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
743
823
  finalized: options.manifest?.finalized ?? false,
744
824
  sourceCounts: options.sourceCounts,
745
825
  totals,
746
- }
826
+ })
747
827
  const { writeManifest } = await import('./lifecycle.js')
748
828
  writeManifest(manifest, options.manifestPath)
749
829
  }
@@ -0,0 +1,223 @@
1
+ import { isRecord } from '@payload-auth-link/core'
2
+ import type { Payload } from 'payload'
3
+ import {
4
+ defaultTargetSlugs,
5
+ type CreatedRow,
6
+ type ImportManifest,
7
+ type TargetModel,
8
+ type TargetSlugMap,
9
+ } from './types.js'
10
+
11
+ /**
12
+ * The guards every destructive manifest operation runs, independent of
13
+ * the manifest's signature:
14
+ *
15
+ * - created-row scoping: a rollback only ever deletes rows the importer
16
+ * itself creates — rows in the import's own target collections whose
17
+ * deterministic `baId` matches the source key the manifest names;
18
+ * - the finalize marker: `finalize` records the point of no return in
19
+ * the target DATABASE (Payload's key-value store), so a manifest file
20
+ * alone can never reopen a finalized migration;
21
+ * - the host roles field the app configured on `authLink`.
22
+ */
23
+
24
+ /** The `baId` prefix the importer stamps per model (see apply.ts). */
25
+ const baIdPrefix: Readonly<Record<TargetModel, string>> = {
26
+ user: '',
27
+ account: 'acct-',
28
+ session: 'sess-',
29
+ verification: 'ver-',
30
+ apikey: 'key-',
31
+ jwks: 'jwks-',
32
+ organization: 'org-',
33
+ member: 'member-',
34
+ invitation: 'inv-',
35
+ }
36
+
37
+ const isTargetModel = (value: string): value is TargetModel => value in baIdPrefix
38
+
39
+ /**
40
+ * The model and deterministic `baId` a created row's `sourceKey`
41
+ * (`<model>:<source id>`) names, or `null` when it names none.
42
+ */
43
+ export function expectedBaId(
44
+ sourceKey: string
45
+ ): { readonly model: TargetModel; readonly baId: string } | null {
46
+ const colon = sourceKey.indexOf(':')
47
+ if (colon <= 0) {
48
+ return null
49
+ }
50
+ const model = sourceKey.slice(0, colon)
51
+ const sourceId = sourceKey.slice(colon + 1)
52
+ if (!isTargetModel(model) || sourceId === '') {
53
+ return null
54
+ }
55
+ return { model, baId: `${baIdPrefix[model]}${sourceId}` }
56
+ }
57
+
58
+ /** The target slugs with the defaults filled in. */
59
+ export function targetSlugsOf(overrides?: Readonly<Record<string, string>>): TargetSlugMap {
60
+ return { ...defaultTargetSlugs, ...(overrides ?? {}) }
61
+ }
62
+
63
+ /**
64
+ * Refuse a manifest whose created rows reach outside what the importer
65
+ * creates, BEFORE anything is deleted (or re-signed): an `adopt-prelink`
66
+ * manifest creates no rows at all, and an import's rows must sit in the
67
+ * import's own target collections, under the model their source key
68
+ * names. Signed or not — the signature proves who wrote the file, this
69
+ * proves the file only names the kit's own rows.
70
+ */
71
+ export function assertCreatedRowsScoped(
72
+ manifest: Pick<ImportManifest, 'kind' | 'created'>,
73
+ slugs: TargetSlugMap
74
+ ): void {
75
+ if (manifest.kind === 'adopt-prelink') {
76
+ if (manifest.created.length > 0) {
77
+ throw new Error(
78
+ `the adopt-prelink manifest lists ${manifest.created.length} created row(s), which adopt-prelink never creates (edited manifest?); refusing to use it — nothing was deleted`
79
+ )
80
+ }
81
+ return
82
+ }
83
+ const bySlug = new Map<string, TargetModel>()
84
+ for (const model of Object.keys(baIdPrefix) as TargetModel[]) {
85
+ bySlug.set(slugs[model], model)
86
+ }
87
+ const outside: string[] = []
88
+ for (const row of manifest.created) {
89
+ const expected = expectedBaId(row.sourceKey)
90
+ const model = bySlug.get(row.collection)
91
+ if (model === undefined || expected === null || expected.model !== model) {
92
+ outside.push(`${row.collection}#${String(row.id)} (${row.sourceKey})`)
93
+ }
94
+ }
95
+ if (outside.length > 0) {
96
+ throw new Error(
97
+ `the manifest lists ${outside.length} created row(s) outside the import's target collections (${[...bySlug.keys()].join(', ')}): ${outside.slice(0, 10).join(', ')}${outside.length > 10 ? ', …' : ''}. The importer only creates rows there; refusing to use the manifest — nothing was deleted. If your engine wiring renames the targets, pass the same --slugs the import ran with`
98
+ )
99
+ }
100
+ }
101
+
102
+ /** A created row whose `baId` no longer matches its source key. */
103
+ export function baIdMismatch(row: CreatedRow, doc: unknown): boolean {
104
+ const expected = expectedBaId(row.sourceKey)
105
+ if (expected === null) {
106
+ return true
107
+ }
108
+ return !isRecord(doc) || String(doc['baId']) !== expected.baId
109
+ }
110
+
111
+ /** Payload's key-value store, reached structurally (Payload 3.6x+). */
112
+ type KeyValueStore = {
113
+ readonly get: (key: string) => Promise<unknown>
114
+ readonly set: (key: string, value: NonNullable<unknown>) => Promise<void>
115
+ readonly keys: () => Promise<string[]>
116
+ }
117
+
118
+ function keyValueStore(payload: Payload): KeyValueStore | null {
119
+ const kv: unknown = (payload as { kv?: unknown }).kv
120
+ if (
121
+ isRecord(kv) &&
122
+ typeof kv['get'] === 'function' &&
123
+ typeof kv['set'] === 'function' &&
124
+ typeof kv['keys'] === 'function'
125
+ ) {
126
+ return kv as unknown as KeyValueStore
127
+ }
128
+ return null
129
+ }
130
+
131
+ const markerPrefix = 'payload-auth-link/migrate/finalized/'
132
+
133
+ /** The finalize marker key of one manifest (its kind + first-apply stamp). */
134
+ export function finalizeMarkerKey(manifest: Pick<ImportManifest, 'kind' | 'createdAt'>): string {
135
+ return `${markerPrefix}${manifest.kind ?? 'import'}/${manifest.createdAt}`
136
+ }
137
+
138
+ /**
139
+ * Record the point of no return in the target database. One-way: the
140
+ * kit never deletes the marker. Throws when the store is unavailable —
141
+ * `finalize` must not report success it cannot back.
142
+ */
143
+ export async function recordFinalizeMarker(
144
+ payload: Payload,
145
+ manifest: Pick<ImportManifest, 'kind' | 'createdAt'>,
146
+ finalizedAt: string
147
+ ): Promise<void> {
148
+ const kv = keyValueStore(payload)
149
+ if (kv === null) {
150
+ throw new Error(
151
+ "this Payload version exposes no key-value store (payload.kv); finalize records its marker there — upgrade Payload to a release in the kit's supported band"
152
+ )
153
+ }
154
+ try {
155
+ await kv.set(finalizeMarkerKey(manifest), {
156
+ kind: manifest.kind ?? 'import',
157
+ createdAt: manifest.createdAt,
158
+ finalizedAt,
159
+ })
160
+ } catch (error) {
161
+ throw new Error(
162
+ `could not record the finalize marker in Payload's key-value store (the payload-kv collection): ${error instanceof Error ? error.message : String(error)} — create its table (run your Payload migrations) and re-run finalize; the manifest was not changed`,
163
+ { cause: error }
164
+ )
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Whether the database says this migration was finalized. `anyOfKind`
170
+ * widens the check to every finalized manifest of the same kind — used
171
+ * when the manifest's own `createdAt` is not covered by a signature.
172
+ * Returns `'unavailable'` when the store cannot be read; the caller
173
+ * decides (and logs) what that means.
174
+ */
175
+ export async function finalizeMarkerState(
176
+ payload: Payload,
177
+ manifest: Pick<ImportManifest, 'kind' | 'createdAt'>,
178
+ options: { readonly anyOfKind: boolean }
179
+ ): Promise<'finalized' | 'open' | 'unavailable'> {
180
+ const kv = keyValueStore(payload)
181
+ if (kv === null) {
182
+ return 'unavailable'
183
+ }
184
+ try {
185
+ if ((await kv.get(finalizeMarkerKey(manifest))) !== null) {
186
+ return 'finalized'
187
+ }
188
+ if (options.anyOfKind) {
189
+ const kindPrefix = `${markerPrefix}${manifest.kind ?? 'import'}/`
190
+ const keys = await kv.keys()
191
+ if (keys.some((key) => key.startsWith(kindPrefix))) {
192
+ return 'finalized'
193
+ }
194
+ }
195
+ return 'open'
196
+ } catch {
197
+ return 'unavailable'
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The host roles field the app configured (`authLink({ rolesField })`),
203
+ * read off the plugin entry in the booted config (Payload keeps each
204
+ * plugin's `slug` and `options` for cross-plugin discovery). Defaults to
205
+ * the plugin's own default, `roles`.
206
+ */
207
+ export function hostRolesField(payload: Payload): string {
208
+ const plugins: unknown = (payload.config as { plugins?: unknown }).plugins
209
+ if (Array.isArray(plugins)) {
210
+ for (const plugin of plugins as unknown[]) {
211
+ if (typeof plugin !== 'function') {
212
+ continue
213
+ }
214
+ const entry = plugin as { slug?: unknown; options?: unknown }
215
+ if (entry.slug !== 'payload-auth-link') {
216
+ continue
217
+ }
218
+ const rolesField = isRecord(entry.options) ? entry.options['rolesField'] : undefined
219
+ return typeof rolesField === 'string' && rolesField !== '' ? rolesField : 'roles'
220
+ }
221
+ }
222
+ return 'roles'
223
+ }
@@ -92,9 +92,18 @@ export const restorableFields: ReadonlySet<string> = new Set([
92
92
  'updatedAt',
93
93
  ])
94
94
 
95
+ /** {@link restorableFields} plus the app's configured roles field. */
96
+ export function restorableFieldsFor(rolesField: string): ReadonlySet<string> {
97
+ return new Set([...restorableFields, rolesField])
98
+ }
99
+
95
100
  /** The captured prior value of one manifest entry, JSON-parsed. */
96
- export function priorValue(secret: string, write: FieldWrite): unknown {
97
- if (!restorableFields.has(write.field)) {
101
+ export function priorValue(
102
+ secret: string,
103
+ write: FieldWrite,
104
+ restorable: ReadonlySet<string> = restorableFields
105
+ ): unknown {
106
+ if (!restorable.has(write.field)) {
98
107
  throw new Error(
99
108
  `manifest entry for '${write.collection}.${write.field}' is not a field this kit writes; refusing to restore it (edited manifest?)`
100
109
  )
@@ -170,8 +179,10 @@ export async function captureBefore(options: {
170
179
  */
171
180
  export async function restoreCaptured(
172
181
  payload: Payload,
173
- manifest: Pick<ImportManifest, 'fieldWrites'>
182
+ manifest: Pick<ImportManifest, 'fieldWrites'>,
183
+ options: { readonly rolesField?: string | undefined } = {}
174
184
  ): Promise<number> {
185
+ const restorable = restorableFieldsFor(options.rolesField ?? 'roles')
175
186
  const byDoc = new Map<
176
187
  string,
177
188
  { collection: string; id: string | number; data: Record<string, unknown> }
@@ -179,7 +190,7 @@ export async function restoreCaptured(
179
190
  for (const write of manifest.fieldWrites) {
180
191
  const key = `${write.collection}\u0000${String(write.id)}`
181
192
  const entry = byDoc.get(key) ?? { collection: write.collection, id: write.id, data: {} }
182
- entry.data[write.field] = priorValue(payload.secret, write)
193
+ entry.data[write.field] = priorValue(payload.secret, write, restorable)
183
194
  byDoc.set(key, entry)
184
195
  }
185
196
  let restored = 0