@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,9 +1,19 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { writePrivateFile } from '../private-file.js'
3
3
  import type { Payload } from 'payload'
4
+ import {
5
+ assertCreatedRowsScoped,
6
+ baIdMismatch,
7
+ finalizeMarkerState,
8
+ hostRolesField,
9
+ recordFinalizeMarker,
10
+ targetSlugsOf,
11
+ } from './guards.js'
4
12
  import { restoreCaptured } from './host-writes.js'
13
+ import { authenticateManifest, signManifest } from './manifest-mac.js'
5
14
  import {
6
15
  defaultTargetSlugs,
16
+ type CreatedRow,
7
17
  resolveSourceTables,
8
18
  sourceModelNames,
9
19
  type ResolvedSourceTables,
@@ -12,9 +22,10 @@ import {
12
22
 
13
23
  /**
14
24
  * Manifest lifecycle (issue #50): `verify` checks both sides, `rollback`
15
- * undoes exactly what the manifest records, and `finalize` marks the
16
- * manifest so the source tables may be dropped — by the operator, as a
17
- * separate later step.
25
+ * undoes exactly what the manifest records, and `finalize` records the
26
+ * point of no return (a database marker plus the re-signed manifest) so
27
+ * the source tables may be dropped — by the operator, as a separate
28
+ * later step.
18
29
  */
19
30
 
20
31
  /**
@@ -38,8 +49,8 @@ export function readManifest(path: string): ImportManifest | null {
38
49
  typeof parsed === 'object' && parsed !== null
39
50
  ? (parsed as { version?: unknown }).version
40
51
  : undefined
41
- if (version !== 1 && version !== 2) {
42
- throw new Error(`manifest at ${path} is not a version-1/2 import manifest`)
52
+ if (version !== 1 && version !== 2 && version !== 3) {
53
+ throw new Error(`manifest at ${path} is not a version-1/2/3 import manifest`)
43
54
  }
44
55
  return parsed as ImportManifest
45
56
  }
@@ -261,6 +272,9 @@ export async function verifyImport(options: {
261
272
  // lookup findByProviderIdentity issues on the next provider
262
273
  // sign-in. The host docs also carry the stored roles (issue #68's
263
274
  // users_role accounting) whenever the host declares the field.
275
+ // The roles field the app configured on authLink (issue #110: a
276
+ // hard-coded `roles` silently skipped the check on `role` layouts).
277
+ const rolesField = hostRolesField(payload)
264
278
  let identityIssues = 0
265
279
  let roleMismatchDocs = 0
266
280
  let hostDocsWithRoles = 0
@@ -300,7 +314,7 @@ export async function verifyImport(options: {
300
314
  if (identities.length !== 1 || resolving.length !== 1) {
301
315
  identityIssues += 1
302
316
  }
303
- const roles = record['roles']
317
+ const roles = record[rolesField]
304
318
  if (Array.isArray(roles)) {
305
319
  hostDocsWithRoles += 1
306
320
  const stored = new Set(roles.map((role) => String(role)))
@@ -318,11 +332,11 @@ export async function verifyImport(options: {
318
332
  check(
319
333
  'source roles carried onto the host docs',
320
334
  roleMismatchDocs === 0,
321
- `${roleMismatchDocs} host doc(s) missing stored role values`
335
+ `${roleMismatchDocs} host doc(s) missing stored role values in '${rolesField}'`
322
336
  )
323
337
  } else if (sourceTables['users_role'] !== null && users.some((user) => user.roles.length > 0)) {
324
338
  checks.push({
325
- name: 'source roles carried onto the host docs (skipped: the host users carry no roles field)',
339
+ name: `source roles carried onto the host docs (skipped: the host users carry no '${rolesField}' field)`,
326
340
  passed: true,
327
341
  })
328
342
  }
@@ -485,13 +499,8 @@ export async function verifyImport(options: {
485
499
  return { ok: issues.length === 0, issues, checks }
486
500
  }
487
501
 
488
- /**
489
- * Rollback (issue #50): delete the created rows in reverse order and
490
- * restore every captured field value. The manifest records exactly
491
- * what to undo — nothing else is touched, and the source tables were
492
- * never modified.
493
- */
494
- export async function rollbackImport(options: {
502
+ /** Options of {@link rollbackImport}. */
503
+ export type RollbackOptions = {
495
504
  readonly payload: Payload
496
505
  readonly manifest: ImportManifest
497
506
  readonly log?: (message: string) => void
@@ -503,48 +512,200 @@ export async function rollbackImport(options: {
503
512
  */
504
513
  readonly manifestPath?: string | undefined
505
514
  readonly now?: (() => Date) | undefined
506
- }): Promise<{ deleted: number; restored: number }> {
515
+ /** Target slugs the import ran with (`--slugs`); defaults to the ba-* set. */
516
+ readonly slugs?: Readonly<Record<string, string>> | undefined
517
+ /**
518
+ * Accept a manifest without a full signature (`--allow-unsigned-manifest`):
519
+ * a pre-0.6.0 manifest without a MAC, or a 0.6.0 manifest whose MAC
520
+ * does not cover `finalized`.
521
+ */
522
+ readonly allowUnsignedManifest?: boolean | undefined
523
+ /**
524
+ * Let a rollback driven by an UNSIGNED (pre-0.6.0) manifest delete the
525
+ * rows it lists and restore plaintext (version-1) values
526
+ * (`--allow-unsigned-deletes`). Without it, an unsigned manifest may
527
+ * only restore sealed field values.
528
+ */
529
+ readonly allowUnsignedDeletes?: boolean | undefined
530
+ }
531
+
532
+ /**
533
+ * Rollback (issue #50): delete the created rows in reverse order and
534
+ * restore every captured field value. The manifest records exactly
535
+ * what to undo — nothing else is touched, and the source tables were
536
+ * never modified.
537
+ *
538
+ * Before anything is written, rollback refuses: a finalized manifest,
539
+ * or a migration the DATABASE records as finalized (the marker
540
+ * `finalize` writes — a manifest file alone cannot reopen it); a
541
+ * manifest that fails its signature; created rows outside the import's
542
+ * own target collections (signed or not), or whose row no longer
543
+ * carries the importer's deterministic `baId`; and any delete or
544
+ * plaintext restore driven by an unsigned manifest without
545
+ * `allowUnsignedDeletes`. A row already absent is fine (the goal is
546
+ * "absent"); a delete that FAILS (a foreign key, a hook, a permission)
547
+ * aborts the rollback with the rows named, before any field is
548
+ * restored or the manifest reset — so it can be re-run once fixed.
549
+ */
550
+ export async function rollbackImport(
551
+ options: RollbackOptions
552
+ ): Promise<{ deleted: number; restored: number }> {
507
553
  const { payload, manifest, log = () => {} } = options
508
554
  if (manifest.finalized) {
509
555
  // The point of no return: after `finalize`, the manifest's rows are
510
556
  // the migration's history, not a TODO to undo.
511
557
  throw new Error('the migration is finalized; rollback is no longer possible')
512
558
  }
513
- let deleted = 0
559
+ // Every row below is deleted, and every field restored, with
560
+ // overrideAccess: only a manifest this app signed may drive that.
561
+ const trust = authenticateManifest(payload.secret, manifest, {
562
+ allowUnsigned: options.allowUnsignedManifest,
563
+ })
564
+ if (trust === 'signed-legacy' && options.allowUnsignedManifest !== true) {
565
+ throw new Error(
566
+ "the manifest was signed by migrate 0.6.0, whose signature does not cover the 'finalized' flag. If you are sure this migration was never finalized and nobody has edited the manifest, re-run with --allow-unsigned-manifest; the rollback re-signs what it leaves behind"
567
+ )
568
+ }
569
+ // The database's own record of `finalize`, independent of the file.
570
+ // A manifest whose `createdAt` is not covered by a full signature is
571
+ // checked against every finalized migration of its kind.
572
+ const marker = await finalizeMarkerState(payload, manifest, { anyOfKind: trust !== 'signed' })
573
+ if (marker === 'finalized') {
574
+ throw new Error(
575
+ 'the database records this migration as finalized; rollback is no longer possible (the manifest file does not override that)'
576
+ )
577
+ }
578
+ if (marker === 'unavailable') {
579
+ log(
580
+ "rollback: Payload's key-value store is unavailable, so the database finalize marker could not be checked; relying on the manifest's own flag"
581
+ )
582
+ }
583
+ assertCreatedRowsScoped(manifest, targetSlugsOf(options.slugs))
584
+ if (trust === 'unsigned' && options.allowUnsignedDeletes !== true) {
585
+ const plaintext = manifest.fieldWrites.filter((write) => write.before !== undefined).length
586
+ if (manifest.created.length > 0 || plaintext > 0) {
587
+ throw new Error(
588
+ `the manifest is unsigned: --allow-unsigned-manifest lets it restore sealed field values only, and it lists ${manifest.created.length} row(s) to delete and ${plaintext} plaintext value(s) to restore. Review the manifest's created rows, then re-run with --allow-unsigned-deletes as well — nothing was changed`
589
+ )
590
+ }
591
+ }
592
+
593
+ // Pre-flight every created row BEFORE the first delete: a present row
594
+ // must still carry the importer's deterministic baId for its source
595
+ // key (the imported row, not one that took its id since).
596
+ const present: CreatedRow[] = []
597
+ const foreign: string[] = []
598
+ let absent = 0
514
599
  for (const row of [...manifest.created].reverse()) {
600
+ const doc = await findRow(payload, row)
601
+ if (doc === null) {
602
+ absent += 1
603
+ } else if (baIdMismatch(row, doc)) {
604
+ foreign.push(`${row.collection}#${String(row.id)} (${row.sourceKey})`)
605
+ } else {
606
+ present.push(row)
607
+ }
608
+ }
609
+ if (foreign.length > 0) {
610
+ throw new Error(
611
+ `${foreign.length} created row(s) no longer carry the baId the importer gave them, so they are not the imported rows: ${listSome(foreign, ', ')}. Refusing to delete anything`
612
+ )
613
+ }
614
+
615
+ let deleted = 0
616
+ const failed: string[] = []
617
+ for (const row of present) {
515
618
  try {
516
619
  await payload.delete({ collection: row.collection, id: row.id, overrideAccess: true })
517
620
  deleted += 1
518
- } catch {
519
- // Already gone (a crash after delete, a manual cleanup) — the
520
- // goal state is "row absent", so absence is success.
521
- log(`rollback: ${row.collection}#${row.id} already absent`)
621
+ } catch (error) {
622
+ if ((await findRow(payload, row)) === null) {
623
+ // Removed concurrently: the goal state is "row absent".
624
+ absent += 1
625
+ continue
626
+ }
627
+ failed.push(
628
+ `${row.collection}#${String(row.id)}: ${error instanceof Error ? error.message : String(error)}`
629
+ )
522
630
  }
523
631
  }
632
+ if (failed.length > 0) {
633
+ for (const line of failed) {
634
+ log(`rollback: FAILED to delete ${line}`)
635
+ }
636
+ throw new Error(
637
+ `rollback deleted ${deleted} row(s) but could not delete ${failed.length}: ${listSome(failed, '; ')}. No field value was restored and the manifest was kept — fix the cause (e.g. rows that reference them) and re-run rollback`
638
+ )
639
+ }
640
+ if (absent > 0) {
641
+ log(`rollback: ${absent} created row(s) were already absent`)
642
+ }
524
643
  // Captured values go back through the adapter, one write per
525
644
  // document, `updatedAt` included — see host-writes.ts for why.
526
- const restored = await restoreCaptured(payload, manifest)
645
+ const restored = await restoreCaptured(payload, manifest, {
646
+ rolesField: hostRolesField(payload),
647
+ })
527
648
  if (options.manifestPath !== undefined) {
528
- const reset: ImportManifest = {
649
+ const reset: ImportManifest = signManifest(payload.secret, {
529
650
  ...manifest,
530
- version: 2,
531
651
  created: [],
532
652
  fieldWrites: [],
533
653
  totals: {},
534
654
  rolledBackAt: (options.now ?? (() => new Date()))().toISOString(),
535
- }
655
+ })
536
656
  writeManifest(reset, options.manifestPath)
537
657
  }
538
658
  log(`rollback: ${deleted} row(s) deleted, ${restored} field value(s) restored`)
539
659
  return { deleted, restored }
540
660
  }
541
661
 
542
- /** Finalize (issue #50): the point of no return — mark the manifest. */
543
- export function finalizeManifest(manifest: ImportManifest, path: string): ImportManifest {
544
- if (manifest.finalized) {
545
- return manifest
546
- }
547
- const finalized: ImportManifest = { ...manifest, finalized: true }
548
- writeManifest(finalized, path)
662
+ /** The created row as stored, or `null` when it does not exist. */
663
+ async function findRow(payload: Payload, row: CreatedRow): Promise<unknown> {
664
+ const doc: unknown = await payload.findByID({
665
+ collection: row.collection,
666
+ id: row.id,
667
+ depth: 0,
668
+ overrideAccess: true,
669
+ disableErrors: true,
670
+ })
671
+ return doc ?? null
672
+ }
673
+
674
+ function listSome(items: readonly string[], separator: string): string {
675
+ return `${items.slice(0, 10).join(separator)}${items.length > 10 ? `${separator}…` : ''}`
676
+ }
677
+
678
+ /** Options of {@link finalizeManifest}. */
679
+ export type FinalizeOptions = {
680
+ readonly payload: Payload
681
+ readonly manifest: ImportManifest
682
+ /** Where the finalized, re-signed manifest is written. */
683
+ readonly path: string
684
+ readonly now?: (() => Date) | undefined
685
+ /** Accept a manifest without a full signature (`--allow-unsigned-manifest`). */
686
+ readonly allowUnsignedManifest?: boolean | undefined
687
+ }
688
+
689
+ /**
690
+ * Finalize (issue #50): the point of no return. Records a one-way
691
+ * marker in the target database (Payload's key-value store) that every
692
+ * later rollback checks independently of the file, then marks the
693
+ * manifest `finalized` and re-signs it — the signature covers the flag.
694
+ * Idempotent: finalizing a finalized manifest re-records the marker and
695
+ * upgrades its signature.
696
+ */
697
+ export async function finalizeManifest(options: FinalizeOptions): Promise<ImportManifest> {
698
+ const { payload, manifest } = options
699
+ authenticateManifest(payload.secret, manifest, { allowUnsigned: options.allowUnsignedManifest })
700
+ const finalizedAt = manifest.finalizedAt ?? (options.now ?? (() => new Date()))().toISOString()
701
+ // The database marker first: a finalize that cannot record it fails
702
+ // with the manifest unchanged.
703
+ await recordFinalizeMarker(payload, manifest, finalizedAt)
704
+ const finalized: ImportManifest = signManifest(payload.secret, {
705
+ ...manifest,
706
+ finalized: true,
707
+ finalizedAt,
708
+ })
709
+ writeManifest(finalized, options.path)
549
710
  return finalized
550
711
  }
@@ -0,0 +1,125 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
2
+ import type { ImportManifest } from './types.js'
3
+
4
+ /**
5
+ * Manifest integrity. Sealing each captured value is not enough: the
6
+ * manifest also says WHICH rows rollback deletes (`created`), WHICH
7
+ * fields it restores (`fieldWrites`), and WHETHER rollback is still
8
+ * allowed (`finalized`), and those writes run with `overrideAccess`. A
9
+ * version-3 manifest therefore carries an HMAC-SHA256 over its whole
10
+ * body, keyed from the target app's Payload `secret`. Rollback, every
11
+ * resumed `--apply`, and `finalize` refuse a manifest whose MAC does
12
+ * not verify, so an edited file can neither delete arbitrary rows, set
13
+ * arbitrary fields, nor reopen a finalized migration — and a re-run
14
+ * never re-signs an edited file.
15
+ *
16
+ * Two MAC schemes exist:
17
+ *
18
+ * - `v2.` covers EVERY field, `finalized` included: `finalize` boots
19
+ * the target app, so it holds the secret and signs its own write.
20
+ * - `v1.` (migrate 0.6.0) left `finalized` out, because `finalize`
21
+ * then loaded no Payload instance. Such a manifest still verifies —
22
+ * its rows and field writes are covered — but its `finalized` flag
23
+ * is not, so it authenticates as `signed-legacy` and destructive
24
+ * callers gate on that. The next write re-signs it under `v2.`.
25
+ */
26
+
27
+ const macPrefix = 'v2.'
28
+ const legacyMacPrefix = 'v1.'
29
+
30
+ type MacScheme = 'v1' | 'v2'
31
+
32
+ function macKey(secret: string, scheme: MacScheme): Buffer {
33
+ return createHash('sha256')
34
+ .update(`payload-auth-link/migrate/manifest-mac/${scheme}\u0000`)
35
+ .update(secret)
36
+ .digest()
37
+ }
38
+
39
+ /** JSON with object keys sorted at every level (stable across rewrites). */
40
+ function canonical(value: unknown): string {
41
+ if (Array.isArray(value)) {
42
+ return `[${value.map((item) => (item === undefined ? 'null' : canonical(item))).join(',')}]`
43
+ }
44
+ if (typeof value === 'object' && value !== null) {
45
+ const entries = Object.entries(value as Record<string, unknown>)
46
+ .filter(([, item]) => item !== undefined)
47
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
48
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`
49
+ }
50
+ return JSON.stringify(value)
51
+ }
52
+
53
+ function macOf(secret: string, manifest: ImportManifest, scheme: MacScheme): string {
54
+ const body: Record<string, unknown> = { ...manifest }
55
+ delete body['mac']
56
+ if (scheme === 'v1') {
57
+ // The 0.6.0 scheme kept `finalized` outside the MAC.
58
+ delete body['finalized']
59
+ }
60
+ const digest = createHmac('sha256', macKey(secret, scheme)).update(canonical(body)).digest()
61
+ return `${scheme === 'v1' ? legacyMacPrefix : macPrefix}${digest.toString('base64url')}`
62
+ }
63
+
64
+ /** Stamp `manifest` as version 3 and sign every field under the app's secret. */
65
+ export function signManifest(secret: string, manifest: ImportManifest): ImportManifest {
66
+ const unsigned: ImportManifest = { ...manifest, version: 3, mac: undefined }
67
+ return { ...unsigned, mac: macOf(secret, unsigned, 'v2') }
68
+ }
69
+
70
+ /**
71
+ * How far a manifest is vouched for:
72
+ *
73
+ * - `signed`: a `v2.` MAC verified; every field, `finalized` included,
74
+ * is the app's own.
75
+ * - `signed-legacy`: a 0.6.0 `v1.` MAC verified; rows and field writes
76
+ * are the app's own, `finalized` is NOT covered.
77
+ * - `unsigned`: a version-1/2 manifest (pre-0.6.0) the operator vouched
78
+ * for with `--allow-unsigned-manifest`; nothing is covered.
79
+ */
80
+ export type ManifestTrust = 'signed' | 'signed-legacy' | 'unsigned'
81
+
82
+ function macMatches(expected: string, actual: string): boolean {
83
+ const want = Buffer.from(expected, 'utf8')
84
+ const got = Buffer.from(actual, 'utf8')
85
+ return got.length === want.length && timingSafeEqual(got, want)
86
+ }
87
+
88
+ /**
89
+ * Refuse a manifest this app did not write. A version-3 manifest must
90
+ * carry a MAC that verifies under `secret`. A version-1/2 manifest
91
+ * (written before 0.6.0) has no MAC: it is accepted only when the
92
+ * operator passes `allowUnsigned` (`--allow-unsigned-manifest`) to vouch
93
+ * for the file, and even then a version-2 manifest may not hold
94
+ * plaintext values, which that version never wrote. Returns how far the
95
+ * manifest is vouched for; destructive callers gate on it.
96
+ */
97
+ export function authenticateManifest(
98
+ secret: string,
99
+ manifest: ImportManifest,
100
+ options: { readonly allowUnsigned?: boolean | undefined } = {}
101
+ ): ManifestTrust {
102
+ if (manifest.version === 3) {
103
+ const mac = typeof manifest.mac === 'string' ? manifest.mac : ''
104
+ if (mac.startsWith(macPrefix) && macMatches(macOf(secret, manifest, 'v2'), mac)) {
105
+ return 'signed'
106
+ }
107
+ if (mac.startsWith(legacyMacPrefix) && macMatches(macOf(secret, manifest, 'v1'), mac)) {
108
+ return 'signed-legacy'
109
+ }
110
+ throw new Error(
111
+ 'the manifest failed its integrity check: it was edited, or written by an app with a different PAYLOAD_SECRET. Refusing to use it — run against the same app that wrote it, with the manifest it wrote'
112
+ )
113
+ }
114
+ if (options.allowUnsigned !== true) {
115
+ throw new Error(
116
+ `the manifest is version ${String(manifest.version)} (written before 0.6.0) and carries no integrity check. If you are sure nobody has edited it, re-run with --allow-unsigned-manifest; the next write signs it`
117
+ )
118
+ }
119
+ if (manifest.version === 2 && manifest.fieldWrites.some((write) => write.before !== undefined)) {
120
+ throw new Error(
121
+ 'the version-2 manifest holds a plaintext prior value, which version 2 never writes (edited manifest?); refusing to use it'
122
+ )
123
+ }
124
+ return 'unsigned'
125
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Payload } from 'payload'
2
+ import { assertCreatedRowsScoped, finalizeMarkerState, targetSlugsOf } from './guards.js'
2
3
  import { capturedKeys, captureBefore } from './host-writes.js'
4
+ import { authenticateManifest, signManifest } from './manifest-mac.js'
3
5
  import type { FieldWrite, ImportManifest, Unmappable } from './types.js'
4
6
 
5
7
  /**
@@ -40,6 +42,8 @@ export type PrelinkOptions = {
40
42
  readonly emailField?: string | undefined
41
43
  readonly now?: () => Date
42
44
  readonly log?: (message: string) => void
45
+ /** Resume from a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
46
+ readonly allowUnsignedManifest?: boolean | undefined
43
47
  }
44
48
 
45
49
  export type PrelinkResult = {
@@ -76,6 +80,23 @@ export async function prelinkUsers(options: PrelinkOptions): Promise<PrelinkResu
76
80
  const usersSlug = options.usersSlug ?? 'users'
77
81
  const verifiedField = options.verifiedField ?? 'emailVerified'
78
82
  const emailField = options.emailField ?? 'email'
83
+ if (options.manifest !== null) {
84
+ // Resuming re-signs the old entries: verify them first.
85
+ const trust = authenticateManifest(payload.secret, options.manifest, {
86
+ allowUnsigned: options.allowUnsignedManifest,
87
+ })
88
+ // A pre-link creates no rows: never re-sign a manifest that lists some.
89
+ assertCreatedRowsScoped({ ...options.manifest, kind: 'adopt-prelink' }, targetSlugsOf())
90
+ if (
91
+ apply &&
92
+ (options.manifest.finalized ||
93
+ (await finalizeMarkerState(payload, options.manifest, {
94
+ anyOfKind: trust !== 'signed',
95
+ })) === 'finalized')
96
+ ) {
97
+ throw new Error('the pre-link is finalized; nothing to pre-link')
98
+ }
99
+ }
79
100
  const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
80
101
  const captured = capturedKeys(fieldWrites)
81
102
 
@@ -201,8 +222,8 @@ export async function prelinkUsers(options: PrelinkOptions): Promise<PrelinkResu
201
222
  }
202
223
  await payload.update({ collection: usersSlug, id: row.id, data, overrideAccess: true })
203
224
  }
204
- const manifest: ImportManifest = {
205
- version: 2,
225
+ const manifest: ImportManifest = signManifest(payload.secret, {
226
+ version: 3,
206
227
  kind: 'adopt-prelink',
207
228
  createdAt: options.manifest?.createdAt ?? now().toISOString(),
208
229
  sourceDatabase: '(adopt mode: the target app database)',
@@ -212,7 +233,7 @@ export async function prelinkUsers(options: PrelinkOptions): Promise<PrelinkResu
212
233
  finalized: options.manifest?.finalized ?? false,
213
234
  sourceCounts: {},
214
235
  totals: { user: users, linked, proofGranted },
215
- }
236
+ })
216
237
  const { writeManifest } = await import('./lifecycle.js')
217
238
  writeManifest(manifest, options.manifestPath)
218
239
  }
@@ -39,8 +39,9 @@ import {
39
39
  * Dry run prints planned row counts per model, collisions, and
40
40
  * anything unmappable; unmappable data aborts `--apply` before any
41
41
  * write. The manifest records every created row and field change;
42
- * source tables are never modified — `finalize` only marks the
43
- * manifest, and dropping them stays the operator's later step.
42
+ * source tables are never modified — `finalize` records the point of
43
+ * no return (in the database and the re-signed manifest), and dropping
44
+ * them stays the operator's later step.
44
45
  */
45
46
 
46
47
  export type ImportCommandOptions = {
@@ -59,6 +60,18 @@ export type ImportCommandOptions = {
59
60
  readonly json?: boolean
60
61
  readonly log?: (message: string) => void
61
62
  readonly loadPayload?: (configPath: string) => Promise<import('payload').Payload>
63
+ /**
64
+ * Accept a manifest without a full signature (`--allow-unsigned-manifest`):
65
+ * a pre-0.6.0 manifest without a MAC, or a 0.6.0 one whose MAC does
66
+ * not cover `finalized`.
67
+ */
68
+ readonly allowUnsignedManifest?: boolean | undefined
69
+ /**
70
+ * `rollback` only (`--allow-unsigned-deletes`): let an UNSIGNED
71
+ * manifest delete its created rows and restore plaintext values.
72
+ * Without it, `--allow-unsigned-manifest` never deletes anything.
73
+ */
74
+ readonly allowUnsignedDeletes?: boolean | undefined
62
75
  }
63
76
 
64
77
  const log_ = (options: Pick<ImportCommandOptions, 'log'>): ((message: string) => void) =>
@@ -405,6 +418,7 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
405
418
  slugs,
406
419
  sourceTables,
407
420
  log,
421
+ allowUnsignedManifest: options.allowUnsignedManifest,
408
422
  })
409
423
  )
410
424
  const plan: ImportPlan = {
@@ -508,32 +522,69 @@ export async function runRollback(
508
522
  const requireTargets = manifest.kind !== 'adopt-prelink'
509
523
  return withTargetPayload(
510
524
  options,
511
- (payload) => rollbackImport({ payload, manifest, log, manifestPath }),
525
+ (payload) =>
526
+ rollbackImport({
527
+ payload,
528
+ manifest,
529
+ log,
530
+ manifestPath,
531
+ slugs: slugsOf(options),
532
+ allowUnsignedManifest: options.allowUnsignedManifest,
533
+ allowUnsignedDeletes: options.allowUnsignedDeletes,
534
+ }),
512
535
  requireTargets
513
536
  )
514
537
  }
515
538
 
516
- /** The `finalize` command — manifest-only; no Payload instance is loaded. */
517
- export function runFinalize(
518
- options: Pick<ImportCommandOptions, 'manifestPath' | 'log'>
519
- ): Promise<ImportManifest> {
539
+ /** Options for `finalize`: the target app (for the secret and the database marker). */
540
+ export type FinalizeCommandOptions = Pick<
541
+ ImportCommandOptions,
542
+ 'configPath' | 'manifestPath' | 'log' | 'loadPayload' | 'allowUnsignedManifest' | 'slugsJson'
543
+ >
544
+
545
+ /**
546
+ * The `finalize` command: boots the target app (like `rollback`), records
547
+ * the one-way finalize marker in its database, and re-signs the manifest
548
+ * with `finalized` covered by the signature.
549
+ */
550
+ export async function runFinalize(options: FinalizeCommandOptions): Promise<ImportManifest> {
520
551
  const log = log_(options)
521
552
  const manifestPath = options.manifestPath ?? defaultManifestPath
522
553
  const manifest = readManifest(manifestPath)
523
554
  if (manifest === null) {
524
- return Promise.reject(new Error(`no manifest at ${manifestPath}; run --apply first`))
555
+ throw new Error(`no manifest at ${manifestPath}; run --apply first`)
556
+ }
557
+ const importOptions: ImportCommandOptions = {
558
+ configPath: options.configPath,
559
+ databaseUrl: '',
560
+ apply: true,
561
+ sessions: 'carry',
562
+ ...(options.slugsJson === undefined ? {} : { slugsJson: options.slugsJson }),
563
+ ...(options.loadPayload === undefined ? {} : { loadPayload: options.loadPayload }),
525
564
  }
526
- const finalized = finalizeManifest(manifest, manifestPath)
565
+ const finalized = await withTargetPayload(
566
+ importOptions,
567
+ (payload) =>
568
+ finalizeManifest({
569
+ payload,
570
+ manifest,
571
+ path: manifestPath,
572
+ allowUnsignedManifest: options.allowUnsignedManifest,
573
+ }),
574
+ manifest.kind !== 'adopt-prelink'
575
+ )
527
576
  log(
528
- `finalized: the manifest at ${manifestPath} is now read-only. Legacy tables may be dropped in a separate, later migration — nothing is dropped for you.`
577
+ manifest.kind === 'adopt-prelink'
578
+ ? `finalized: the pre-link manifest at ${manifestPath} is now read-only, and the database records the finalize. Rollback is no longer possible. Adopt mode serves Better Auth from your existing tables — they are the LIVE tables; do not drop them.`
579
+ : `finalized: the manifest at ${manifestPath} is now read-only, and the database records the finalize. Rollback is no longer possible. The legacy payload-auth tables may be dropped in a separate, later migration — nothing is dropped for you.`
529
580
  )
530
- return Promise.resolve(finalized)
581
+ return finalized
531
582
  }
532
583
 
533
584
  /** Options for `adopt-prelink` (issue #88). */
534
585
  export type PrelinkCommandOptions = Pick<
535
586
  ImportCommandOptions,
536
- 'configPath' | 'apply' | 'manifestPath' | 'json' | 'log' | 'loadPayload'
587
+ 'configPath' | 'apply' | 'manifestPath' | 'json' | 'log' | 'loadPayload' | 'allowUnsignedManifest'
537
588
  > & {
538
589
  readonly usersSlug?: string | undefined
539
590
  readonly verifiedField?: string | undefined
@@ -575,6 +626,7 @@ export async function runAdoptPrelink(options: PrelinkCommandOptions): Promise<P
575
626
  usersSlug: options.usersSlug,
576
627
  verifiedField: options.verifiedField,
577
628
  log,
629
+ allowUnsignedManifest: options.allowUnsignedManifest,
578
630
  }),
579
631
  false
580
632
  )
@@ -8,6 +8,9 @@
8
8
  * and every captured prior value is sealed (AES-256-GCM, keyed from the
9
9
  * target app's Payload `secret`), so only the same app can restore it.
10
10
  * The file is written with mode 0600.
11
+ *
12
+ * Integrity: a version-3 manifest is signed (`mac`), and rollback,
13
+ * finalize, or a resumed run refuses one that does not verify.
11
14
  */
12
15
  export type CreatedRow = {
13
16
  /** Target collection slug. */
@@ -40,7 +43,7 @@ export type FieldWrite = {
40
43
  export type ManifestKind = 'import' | 'adopt-prelink'
41
44
 
42
45
  export type ImportManifest = {
43
- readonly version: 1 | 2
46
+ readonly version: 1 | 2 | 3
44
47
  /** The producing command; absent on version-1 manifests (imports). */
45
48
  readonly kind?: ManifestKind | undefined
46
49
  readonly createdAt: string
@@ -57,6 +60,14 @@ export type ImportManifest = {
57
60
  readonly totals: Readonly<Record<string, number>>
58
61
  /** When the last rollback reset this manifest (issue #88). */
59
62
  readonly rolledBackAt?: string | undefined
63
+ /** When `finalize` ran (recorded alongside the database marker). */
64
+ readonly finalizedAt?: string | undefined
65
+ /**
66
+ * Version 3: HMAC over the whole manifest, keyed from the target
67
+ * app's Payload secret. `v2.` MACs cover every field; 0.6.0's `v1.`
68
+ * MACs left `finalized` out. See `manifest-mac.ts`.
69
+ */
70
+ readonly mac?: string | undefined
60
71
  }
61
72
 
62
73
  /** Per-model plan row for the dry run. */