@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.
- package/README.md +20 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +85 -30
- package/dist/cli.js.map +1 -1
- package/dist/doctor/plugins.d.ts.map +1 -1
- package/dist/doctor/plugins.js +6 -2
- package/dist/doctor/plugins.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +27 -2
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/users.d.ts +13 -1
- package/dist/doctor/users.d.ts.map +1 -1
- package/dist/doctor/users.js +17 -7
- package/dist/doctor/users.js.map +1 -1
- package/dist/exporters.d.ts +43 -5
- package/dist/exporters.d.ts.map +1 -1
- package/dist/exporters.js +88 -5
- package/dist/exporters.js.map +1 -1
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +79 -23
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/guards.d.ts +49 -0
- package/dist/importer/guards.d.ts.map +1 -0
- package/dist/importer/guards.js +175 -0
- package/dist/importer/guards.js.map +1 -0
- package/dist/importer/host-writes.d.ts +6 -2
- package/dist/importer/host-writes.d.ts.map +1 -1
- package/dist/importer/host-writes.js +9 -4
- package/dist/importer/host-writes.js.map +1 -1
- package/dist/importer/lifecycle.d.ts +59 -14
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +123 -20
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/manifest-mac.d.ts +15 -3
- package/dist/importer/manifest-mac.d.ts.map +1 -1
- package/dist/importer/manifest-mac.js +47 -27
- package/dist/importer/manifest-mac.js.map +1 -1
- package/dist/importer/prelink.d.ts.map +1 -1
- package/dist/importer/prelink.js +11 -1
- package/dist/importer/prelink.js.map +1 -1
- package/dist/importer/run-import.d.ts +22 -5
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +27 -6
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/types.d.ts +7 -4
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/cli.ts +84 -30
- package/src/doctor/plugins.ts +8 -3
- package/src/doctor/run-doctor.ts +30 -3
- package/src/doctor/users.ts +25 -7
- package/src/exporters.ts +113 -12
- package/src/importer/apply.ts +92 -22
- package/src/importer/guards.ts +223 -0
- package/src/importer/host-writes.ts +15 -4
- package/src/importer/lifecycle.ts +185 -29
- package/src/importer/manifest-mac.ts +65 -30
- package/src/importer/prelink.ts +13 -1
- package/src/importer/run-import.ts +52 -11
- package/src/importer/types.ts +7 -4
- 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`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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[
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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, {
|
|
519
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
/**
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (`
|
|
9
|
-
* manifest therefore carries an HMAC-SHA256 over its whole
|
|
10
|
-
* from the target app's Payload `secret`. Rollback
|
|
11
|
-
* `--apply` refuse a manifest whose MAC does
|
|
12
|
-
* file can neither delete arbitrary rows
|
|
13
|
-
* a
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 = '
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
):
|
|
101
|
+
): ManifestTrust {
|
|
70
102
|
if (manifest.version === 3) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/importer/prelink.ts
CHANGED
|
@@ -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`
|
|
43
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
527
|
-
export
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
555
|
+
throw new Error(`no manifest at ${manifestPath}; run --apply first`)
|
|
535
556
|
}
|
|
536
|
-
const
|
|
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
|
-
|
|
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
|
|
581
|
+
return finalized
|
|
541
582
|
}
|
|
542
583
|
|
|
543
584
|
/** Options for `adopt-prelink` (issue #88). */
|
package/src/importer/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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`.
|
|
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 {
|
|
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,
|