@payload-auth-link/migrate 0.6.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 (66) hide show
  1. package/README.md +20 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +85 -30
  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.map +1 -1
  20. package/dist/importer/apply.js +79 -23
  21. package/dist/importer/apply.js.map +1 -1
  22. package/dist/importer/guards.d.ts +49 -0
  23. package/dist/importer/guards.d.ts.map +1 -0
  24. package/dist/importer/guards.js +175 -0
  25. package/dist/importer/guards.js.map +1 -0
  26. package/dist/importer/host-writes.d.ts +6 -2
  27. package/dist/importer/host-writes.d.ts.map +1 -1
  28. package/dist/importer/host-writes.js +9 -4
  29. package/dist/importer/host-writes.js.map +1 -1
  30. package/dist/importer/lifecycle.d.ts +59 -14
  31. package/dist/importer/lifecycle.d.ts.map +1 -1
  32. package/dist/importer/lifecycle.js +123 -20
  33. package/dist/importer/lifecycle.js.map +1 -1
  34. package/dist/importer/manifest-mac.d.ts +15 -3
  35. package/dist/importer/manifest-mac.d.ts.map +1 -1
  36. package/dist/importer/manifest-mac.js +47 -27
  37. package/dist/importer/manifest-mac.js.map +1 -1
  38. package/dist/importer/prelink.d.ts.map +1 -1
  39. package/dist/importer/prelink.js +11 -1
  40. package/dist/importer/prelink.js.map +1 -1
  41. package/dist/importer/run-import.d.ts +22 -5
  42. package/dist/importer/run-import.d.ts.map +1 -1
  43. package/dist/importer/run-import.js +27 -6
  44. package/dist/importer/run-import.js.map +1 -1
  45. package/dist/importer/types.d.ts +7 -4
  46. package/dist/importer/types.d.ts.map +1 -1
  47. package/dist/importer/types.js.map +1 -1
  48. package/dist/index.d.ts +3 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/package.json +6 -6
  53. package/src/cli.ts +84 -30
  54. package/src/doctor/plugins.ts +8 -3
  55. package/src/doctor/run-doctor.ts +30 -3
  56. package/src/doctor/users.ts +25 -7
  57. package/src/exporters.ts +113 -12
  58. package/src/importer/apply.ts +92 -22
  59. package/src/importer/guards.ts +223 -0
  60. package/src/importer/host-writes.ts +15 -4
  61. package/src/importer/lifecycle.ts +185 -29
  62. package/src/importer/manifest-mac.ts +65 -30
  63. package/src/importer/prelink.ts +13 -1
  64. package/src/importer/run-import.ts +52 -11
  65. package/src/importer/types.ts +7 -4
  66. package/src/index.ts +10 -1
@@ -1,10 +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'
5
13
  import { authenticateManifest, signManifest } from './manifest-mac.js'
6
14
  import {
7
15
  defaultTargetSlugs,
16
+ type CreatedRow,
8
17
  resolveSourceTables,
9
18
  sourceModelNames,
10
19
  type ResolvedSourceTables,
@@ -13,9 +22,10 @@ import {
13
22
 
14
23
  /**
15
24
  * Manifest lifecycle (issue #50): `verify` checks both sides, `rollback`
16
- * undoes exactly what the manifest records, and `finalize` marks the
17
- * manifest so the source tables may be dropped — by the operator, as a
18
- * 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.
19
29
  */
20
30
 
21
31
  /**
@@ -262,6 +272,9 @@ export async function verifyImport(options: {
262
272
  // lookup findByProviderIdentity issues on the next provider
263
273
  // sign-in. The host docs also carry the stored roles (issue #68's
264
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)
265
278
  let identityIssues = 0
266
279
  let roleMismatchDocs = 0
267
280
  let hostDocsWithRoles = 0
@@ -301,7 +314,7 @@ export async function verifyImport(options: {
301
314
  if (identities.length !== 1 || resolving.length !== 1) {
302
315
  identityIssues += 1
303
316
  }
304
- const roles = record['roles']
317
+ const roles = record[rolesField]
305
318
  if (Array.isArray(roles)) {
306
319
  hostDocsWithRoles += 1
307
320
  const stored = new Set(roles.map((role) => String(role)))
@@ -319,11 +332,11 @@ export async function verifyImport(options: {
319
332
  check(
320
333
  'source roles carried onto the host docs',
321
334
  roleMismatchDocs === 0,
322
- `${roleMismatchDocs} host doc(s) missing stored role values`
335
+ `${roleMismatchDocs} host doc(s) missing stored role values in '${rolesField}'`
323
336
  )
324
337
  } else if (sourceTables['users_role'] !== null && users.some((user) => user.roles.length > 0)) {
325
338
  checks.push({
326
- 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)`,
327
340
  passed: true,
328
341
  })
329
342
  }
@@ -486,13 +499,8 @@ export async function verifyImport(options: {
486
499
  return { ok: issues.length === 0, issues, checks }
487
500
  }
488
501
 
489
- /**
490
- * Rollback (issue #50): delete the created rows in reverse order and
491
- * restore every captured field value. The manifest records exactly
492
- * what to undo — nothing else is touched, and the source tables were
493
- * never modified.
494
- */
495
- export async function rollbackImport(options: {
502
+ /** Options of {@link rollbackImport}. */
503
+ export type RollbackOptions = {
496
504
  readonly payload: Payload
497
505
  readonly manifest: ImportManifest
498
506
  readonly log?: (message: string) => void
@@ -504,9 +512,44 @@ export async function rollbackImport(options: {
504
512
  */
505
513
  readonly manifestPath?: string | undefined
506
514
  readonly now?: (() => Date) | undefined
507
- /** Accept a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
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
+ */
508
522
  readonly allowUnsignedManifest?: boolean | undefined
509
- }): Promise<{ deleted: number; restored: number }> {
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 }> {
510
553
  const { payload, manifest, log = () => {} } = options
511
554
  if (manifest.finalized) {
512
555
  // The point of no return: after `finalize`, the manifest's rows are
@@ -515,21 +558,93 @@ export async function rollbackImport(options: {
515
558
  }
516
559
  // Every row below is deleted, and every field restored, with
517
560
  // overrideAccess: only a manifest this app signed may drive that.
518
- authenticateManifest(payload.secret, manifest, { allowUnsigned: options.allowUnsignedManifest })
519
- let deleted = 0
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
520
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) {
521
618
  try {
522
619
  await payload.delete({ collection: row.collection, id: row.id, overrideAccess: true })
523
620
  deleted += 1
524
- } catch {
525
- // Already gone (a crash after delete, a manual cleanup) — the
526
- // goal state is "row absent", so absence is success.
527
- 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
+ )
528
630
  }
529
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
+ }
530
643
  // Captured values go back through the adapter, one write per
531
644
  // document, `updatedAt` included — see host-writes.ts for why.
532
- const restored = await restoreCaptured(payload, manifest)
645
+ const restored = await restoreCaptured(payload, manifest, {
646
+ rolesField: hostRolesField(payload),
647
+ })
533
648
  if (options.manifestPath !== undefined) {
534
649
  const reset: ImportManifest = signManifest(payload.secret, {
535
650
  ...manifest,
@@ -544,12 +659,53 @@ export async function rollbackImport(options: {
544
659
  return { deleted, restored }
545
660
  }
546
661
 
547
- /** Finalize (issue #50): the point of no return — mark the manifest. */
548
- export function finalizeManifest(manifest: ImportManifest, path: string): ImportManifest {
549
- if (manifest.finalized) {
550
- return manifest
551
- }
552
- const finalized: ImportManifest = { ...manifest, finalized: true }
553
- 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)
554
710
  return finalized
555
711
  }
@@ -2,26 +2,36 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
2
2
  import type { ImportManifest } from './types.js'
3
3
 
4
4
  /**
5
- * Manifest integrity (GHSA-p76j-5qwx-rpwx follow-up). Sealing each
6
- * captured value is not enough: the manifest also says WHICH rows
7
- * rollback deletes (`created`) and WHICH fields it restores
8
- * (`fieldWrites`), and both run with `overrideAccess`. A version-3
9
- * manifest therefore carries an HMAC-SHA256 over its whole body, keyed
10
- * from the target app's Payload `secret`. Rollback and every resumed
11
- * `--apply` refuse a manifest whose MAC does not verify, so an edited
12
- * file can neither delete arbitrary rows nor set arbitrary fields, and
13
- * a re-run never re-signs an edited file.
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.
14
15
  *
15
- * `finalized` is left out of the MAC: `finalize` is manifest-only and
16
- * loads no Payload instance (so it has no secret). The flag only ever
17
- * blocks rollback, and the rows a rollback would touch stay covered.
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.`.
18
25
  */
19
26
 
20
- const macPrefix = 'v1.'
27
+ const macPrefix = 'v2.'
28
+ const legacyMacPrefix = 'v1.'
29
+
30
+ type MacScheme = 'v1' | 'v2'
21
31
 
22
- function macKey(secret: string): Buffer {
32
+ function macKey(secret: string, scheme: MacScheme): Buffer {
23
33
  return createHash('sha256')
24
- .update('payload-auth-link/migrate/manifest-mac/v1\u0000')
34
+ .update(`payload-auth-link/migrate/manifest-mac/${scheme}\u0000`)
25
35
  .update(secret)
26
36
  .digest()
27
37
  }
@@ -40,18 +50,39 @@ function canonical(value: unknown): string {
40
50
  return JSON.stringify(value)
41
51
  }
42
52
 
43
- function macOf(secret: string, manifest: ImportManifest): string {
53
+ function macOf(secret: string, manifest: ImportManifest, scheme: MacScheme): string {
44
54
  const body: Record<string, unknown> = { ...manifest }
45
55
  delete body['mac']
46
- delete body['finalized']
47
- const digest = createHmac('sha256', macKey(secret)).update(canonical(body)).digest()
48
- return `${macPrefix}${digest.toString('base64url')}`
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')}`
49
62
  }
50
63
 
51
- /** Stamp `manifest` as version 3 and sign it under the app's secret. */
64
+ /** Stamp `manifest` as version 3 and sign every field under the app's secret. */
52
65
  export function signManifest(secret: string, manifest: ImportManifest): ImportManifest {
53
66
  const unsigned: ImportManifest = { ...manifest, version: 3, mac: undefined }
54
- return { ...unsigned, mac: macOf(secret, unsigned) }
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)
55
86
  }
56
87
 
57
88
  /**
@@ -60,22 +91,25 @@ export function signManifest(secret: string, manifest: ImportManifest): ImportMa
60
91
  * (written before 0.6.0) has no MAC: it is accepted only when the
61
92
  * operator passes `allowUnsigned` (`--allow-unsigned-manifest`) to vouch
62
93
  * for the file, and even then a version-2 manifest may not hold
63
- * plaintext values, which that version never wrote.
94
+ * plaintext values, which that version never wrote. Returns how far the
95
+ * manifest is vouched for; destructive callers gate on it.
64
96
  */
65
97
  export function authenticateManifest(
66
98
  secret: string,
67
99
  manifest: ImportManifest,
68
100
  options: { readonly allowUnsigned?: boolean | undefined } = {}
69
- ): void {
101
+ ): ManifestTrust {
70
102
  if (manifest.version === 3) {
71
- const expected = Buffer.from(macOf(secret, manifest), 'utf8')
72
- const actual = Buffer.from(typeof manifest.mac === 'string' ? manifest.mac : '', 'utf8')
73
- if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
74
- throw new Error(
75
- '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'
76
- )
103
+ const mac = typeof manifest.mac === 'string' ? manifest.mac : ''
104
+ if (mac.startsWith(macPrefix) && macMatches(macOf(secret, manifest, 'v2'), mac)) {
105
+ return 'signed'
77
106
  }
78
- return
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
+ )
79
113
  }
80
114
  if (options.allowUnsigned !== true) {
81
115
  throw new Error(
@@ -87,4 +121,5 @@ export function authenticateManifest(
87
121
  'the version-2 manifest holds a plaintext prior value, which version 2 never writes (edited manifest?); refusing to use it'
88
122
  )
89
123
  }
124
+ return 'unsigned'
90
125
  }
@@ -1,4 +1,5 @@
1
1
  import type { Payload } from 'payload'
2
+ import { assertCreatedRowsScoped, finalizeMarkerState, targetSlugsOf } from './guards.js'
2
3
  import { capturedKeys, captureBefore } from './host-writes.js'
3
4
  import { authenticateManifest, signManifest } from './manifest-mac.js'
4
5
  import type { FieldWrite, ImportManifest, Unmappable } from './types.js'
@@ -81,9 +82,20 @@ export async function prelinkUsers(options: PrelinkOptions): Promise<PrelinkResu
81
82
  const emailField = options.emailField ?? 'email'
82
83
  if (options.manifest !== null) {
83
84
  // Resuming re-signs the old entries: verify them first.
84
- authenticateManifest(payload.secret, options.manifest, {
85
+ const trust = authenticateManifest(payload.secret, options.manifest, {
85
86
  allowUnsigned: options.allowUnsignedManifest,
86
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
+ }
87
99
  }
88
100
  const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
89
101
  const captured = capturedKeys(fieldWrites)
@@ -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,8 +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>
62
- /** Accept a pre-0.6.0 manifest without a MAC (`--allow-unsigned-manifest`). */
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
+ */
63
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
64
75
  }
65
76
 
66
77
  const log_ = (options: Pick<ImportCommandOptions, 'log'>): ((message: string) => void) =>
@@ -517,27 +528,57 @@ export async function runRollback(
517
528
  manifest,
518
529
  log,
519
530
  manifestPath,
531
+ slugs: slugsOf(options),
520
532
  allowUnsignedManifest: options.allowUnsignedManifest,
533
+ allowUnsignedDeletes: options.allowUnsignedDeletes,
521
534
  }),
522
535
  requireTargets
523
536
  )
524
537
  }
525
538
 
526
- /** The `finalize` command — manifest-only; no Payload instance is loaded. */
527
- export function runFinalize(
528
- options: Pick<ImportCommandOptions, 'manifestPath' | 'log'>
529
- ): 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> {
530
551
  const log = log_(options)
531
552
  const manifestPath = options.manifestPath ?? defaultManifestPath
532
553
  const manifest = readManifest(manifestPath)
533
554
  if (manifest === null) {
534
- return Promise.reject(new Error(`no manifest at ${manifestPath}; run --apply first`))
555
+ throw new Error(`no manifest at ${manifestPath}; run --apply first`)
535
556
  }
536
- const finalized = finalizeManifest(manifest, manifestPath)
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 }),
564
+ }
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
+ )
537
576
  log(
538
- `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.`
539
580
  )
540
- return Promise.resolve(finalized)
581
+ return finalized
541
582
  }
542
583
 
543
584
  /** Options for `adopt-prelink` (issue #88). */
@@ -9,8 +9,8 @@
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
11
  *
12
- * Integrity: a version-3 manifest is signed (`mac`), and rollback or a
13
- * resumed run refuses one that does not verify.
12
+ * Integrity: a version-3 manifest is signed (`mac`), and rollback,
13
+ * finalize, or a resumed run refuses one that does not verify.
14
14
  */
15
15
  export type CreatedRow = {
16
16
  /** Target collection slug. */
@@ -60,9 +60,12 @@ export type ImportManifest = {
60
60
  readonly totals: Readonly<Record<string, number>>
61
61
  /** When the last rollback reset this manifest (issue #88). */
62
62
  readonly rolledBackAt?: string | undefined
63
+ /** When `finalize` ran (recorded alongside the database marker). */
64
+ readonly finalizedAt?: string | undefined
63
65
  /**
64
- * Version 3: HMAC over the whole manifest (except `finalized`), keyed
65
- * from the target app's Payload secret. See `manifest-mac.ts`.
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`.
66
69
  */
67
70
  readonly mac?: string | undefined
68
71
  }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  runRollback,
23
23
  runFinalize,
24
24
  runAdoptPrelink,
25
+ type FinalizeCommandOptions,
25
26
  type ImportCommandOptions,
26
27
  type PrelinkCommandOptions,
27
28
  } from './importer/run-import.js'
@@ -50,6 +51,8 @@ export {
50
51
  finalizeManifest,
51
52
  defaultManifestPath,
52
53
  defaultPrelinkManifestPath,
54
+ type FinalizeOptions,
55
+ type RollbackOptions,
53
56
  type VerifyIssue,
54
57
  type VerifyResult,
55
58
  } from './importer/lifecycle.js'
@@ -74,7 +77,13 @@ export {
74
77
  type PlanRow,
75
78
  type Unmappable,
76
79
  } from './importer/types.js'
77
- export { exportToJsonl, parseExportTables, exportTables } from './exporters.js'
80
+ export {
81
+ exportToJsonl,
82
+ parseExportTables,
83
+ exportTables,
84
+ optionalAuthTables,
85
+ resolveAuthTables,
86
+ } from './exporters.js'
78
87
  export { cliMain } from './cli.js'
79
88
  export type {
80
89
  BehaviourChange,