@payload-auth-link/migrate 0.4.3 → 0.6.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +109 -19
  5. package/dist/cli.js.map +1 -1
  6. package/dist/doctor/database.d.ts.map +1 -1
  7. package/dist/doctor/database.js +31 -4
  8. package/dist/doctor/database.js.map +1 -1
  9. package/dist/doctor/run-doctor.d.ts.map +1 -1
  10. package/dist/doctor/run-doctor.js +67 -0
  11. package/dist/doctor/run-doctor.js.map +1 -1
  12. package/dist/doctor/types.d.ts +12 -0
  13. package/dist/doctor/types.d.ts.map +1 -1
  14. package/dist/doctor/users.d.ts.map +1 -1
  15. package/dist/doctor/users.js +20 -4
  16. package/dist/doctor/users.js.map +1 -1
  17. package/dist/importer/apply.d.ts +25 -5
  18. package/dist/importer/apply.d.ts.map +1 -1
  19. package/dist/importer/apply.js +164 -180
  20. package/dist/importer/apply.js.map +1 -1
  21. package/dist/importer/host-writes.d.ts +47 -0
  22. package/dist/importer/host-writes.d.ts.map +1 -0
  23. package/dist/importer/host-writes.js +198 -0
  24. package/dist/importer/host-writes.js.map +1 -0
  25. package/dist/importer/lifecycle.d.ts +26 -0
  26. package/dist/importer/lifecycle.d.ts.map +1 -1
  27. package/dist/importer/lifecycle.js +107 -22
  28. package/dist/importer/lifecycle.js.map +1 -1
  29. package/dist/importer/manifest-mac.d.ts +15 -0
  30. package/dist/importer/manifest-mac.d.ts.map +1 -0
  31. package/dist/importer/manifest-mac.js +73 -0
  32. package/dist/importer/manifest-mac.js.map +1 -0
  33. package/dist/importer/prelink.d.ts +57 -0
  34. package/dist/importer/prelink.d.ts.map +1 -0
  35. package/dist/importer/prelink.js +159 -0
  36. package/dist/importer/prelink.js.map +1 -0
  37. package/dist/importer/run-import.d.ts +15 -0
  38. package/dist/importer/run-import.d.ts.map +1 -1
  39. package/dist/importer/run-import.js +155 -14
  40. package/dist/importer/run-import.js.map +1 -1
  41. package/dist/importer/source.d.ts +35 -7
  42. package/dist/importer/source.d.ts.map +1 -1
  43. package/dist/importer/source.js +37 -6
  44. package/dist/importer/source.js.map +1 -1
  45. package/dist/importer/types.d.ts +34 -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 +5 -4
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/private-file.d.ts +8 -0
  53. package/dist/private-file.d.ts.map +1 -0
  54. package/dist/private-file.js +19 -0
  55. package/dist/private-file.js.map +1 -0
  56. package/package.json +10 -10
  57. package/src/cli.ts +117 -9
  58. package/src/doctor/database.ts +36 -4
  59. package/src/doctor/run-doctor.ts +69 -0
  60. package/src/doctor/types.ts +12 -0
  61. package/src/doctor/users.ts +23 -4
  62. package/src/importer/apply.ts +198 -182
  63. package/src/importer/host-writes.ts +231 -0
  64. package/src/importer/lifecycle.ts +131 -24
  65. package/src/importer/manifest-mac.ts +90 -0
  66. package/src/importer/prelink.ts +237 -0
  67. package/src/importer/run-import.ts +207 -29
  68. package/src/importer/source.ts +72 -14
  69. package/src/importer/types.ts +35 -4
  70. package/src/index.ts +13 -1
  71. package/src/private-file.ts +18 -0
@@ -0,0 +1,231 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
2
+ import type { Payload } from 'payload'
3
+ import type { FieldWrite, ImportManifest } from './types.js'
4
+
5
+ /**
6
+ * Host-document field writes with exact rollback (issues #50, #88).
7
+ *
8
+ * Capture: before the importer (or `adopt-prelink`) writes a host
9
+ * field, its CURRENT value is captured into the manifest — once per
10
+ * document field, the FIRST capture wins, so a second `--apply` can
11
+ * never overwrite the pre-import value with imported state. The
12
+ * document's `updatedAt` is captured the same way, because the Local
13
+ * API write bumps it.
14
+ *
15
+ * Privacy: captured values (identity rows and `linkableEmail` carry
16
+ * email addresses) are sealed with AES-256-GCM under a key derived
17
+ * from the target app's Payload `secret`; the manifest stores no value
18
+ * in the clear. Rollback needs the same secret — it boots the same
19
+ * app.
20
+ *
21
+ * Restore: rollback writes the captured values back through the
22
+ * DATABASE ADAPTER (`payload.db.updateOne`), not the Local API
23
+ * operation: the operation would stamp a fresh `updatedAt` and re-run
24
+ * hooks, so the "restored" row would differ from the original. The
25
+ * adapter write sets exactly the captured fields — `updatedAt`
26
+ * included — on every adapter (Postgres, SQLite, Mongo). What it
27
+ * cannot preserve is documented in docs/migrating-from-payload-auth.md
28
+ * (the forward Local API write re-creates hasMany child rows such as
29
+ * `users_role` with new row ids; their values and order are intact).
30
+ */
31
+
32
+ const sealPrefix = 'v1.'
33
+
34
+ const writeKey = (collection: string, id: string | number, field: string): string =>
35
+ `${collection}\u0000${String(id)}\u0000${field}`
36
+
37
+ function keyFor(secret: string): Buffer {
38
+ return createHash('sha256')
39
+ .update('payload-auth-link/migrate/manifest-seal/v1\u0000')
40
+ .update(secret)
41
+ .digest()
42
+ }
43
+
44
+ /**
45
+ * Seal one JSON-serialized value under the app's Payload secret. The
46
+ * entry it belongs to (`collection\0id\0field`) is bound as GCM
47
+ * associated data, so a sealed value cannot be moved onto another
48
+ * entry of the manifest.
49
+ */
50
+ export function sealValue(secret: string, plaintext: string, entry: string): string {
51
+ const iv = randomBytes(12)
52
+ const cipher = createCipheriv('aes-256-gcm', keyFor(secret), iv)
53
+ cipher.setAAD(Buffer.from(entry, 'utf8'))
54
+ const body = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
55
+ const tag = cipher.getAuthTag()
56
+ return `${sealPrefix}${Buffer.concat([iv, tag, body]).toString('base64url')}`
57
+ }
58
+
59
+ /** Open a sealed value; throws when the secret is not the sealing one. */
60
+ export function unsealValue(secret: string, sealed: string, entry: string): string {
61
+ if (!sealed.startsWith(sealPrefix)) {
62
+ throw new Error('manifest value is not a sealed v1 value')
63
+ }
64
+ const raw = Buffer.from(sealed.slice(sealPrefix.length), 'base64url')
65
+ const iv = raw.subarray(0, 12)
66
+ const tag = raw.subarray(12, 28)
67
+ const body = raw.subarray(28)
68
+ const decipher = createDecipheriv('aes-256-gcm', keyFor(secret), iv)
69
+ decipher.setAuthTag(tag)
70
+ decipher.setAAD(Buffer.from(entry, 'utf8'))
71
+ try {
72
+ return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8')
73
+ } catch {
74
+ throw new Error(
75
+ 'cannot open the manifest: a value was sealed with a different PAYLOAD_SECRET or for a different entry (edited manifest) — run rollback against the same app (same secret) that ran the import, with the manifest it wrote'
76
+ )
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The only host fields the kit ever writes (importer and
82
+ * `adopt-prelink`), plus the `updatedAt` it captures alongside them.
83
+ * Rollback restores nothing else: a manifest is a file on disk, and an
84
+ * edited one must not become a way to set arbitrary fields (roles are
85
+ * restored only because the importer writes them).
86
+ */
87
+ export const restorableFields: ReadonlySet<string> = new Set([
88
+ 'identities',
89
+ 'roles',
90
+ 'linkable',
91
+ 'linkableEmail',
92
+ 'updatedAt',
93
+ ])
94
+
95
+ /** The captured prior value of one manifest entry, JSON-parsed. */
96
+ export function priorValue(secret: string, write: FieldWrite): unknown {
97
+ if (!restorableFields.has(write.field)) {
98
+ throw new Error(
99
+ `manifest entry for '${write.collection}.${write.field}' is not a field this kit writes; refusing to restore it (edited manifest?)`
100
+ )
101
+ }
102
+ if (write.sealedBefore !== undefined) {
103
+ return JSON.parse(
104
+ unsealValue(secret, write.sealedBefore, writeKey(write.collection, write.id, write.field))
105
+ ) as unknown
106
+ }
107
+ // Version-1 manifests stored the value in the clear.
108
+ return write.before === null || write.before === undefined
109
+ ? null
110
+ : (JSON.parse(write.before) as unknown)
111
+ }
112
+
113
+ /** Keys of the field writes a manifest already holds (first capture wins). */
114
+ export function capturedKeys(writes: readonly FieldWrite[]): Set<string> {
115
+ return new Set(writes.map((write) => writeKey(write.collection, write.id, write.field)))
116
+ }
117
+
118
+ /**
119
+ * Capture `fields` (plus `updatedAt`) of one document before it is
120
+ * written. Fields the document does not carry are skipped — they are
121
+ * neither written nor restored. Returns the fields the document DOES
122
+ * carry, so the caller writes only those.
123
+ */
124
+ export async function captureBefore(options: {
125
+ readonly payload: Payload
126
+ readonly collection: string
127
+ readonly id: string | number
128
+ readonly fields: readonly string[]
129
+ readonly captured: Set<string>
130
+ readonly into: FieldWrite[]
131
+ }): Promise<{ readonly present: ReadonlySet<string>; readonly newlyCaptured: number }> {
132
+ const { payload, collection, id, fields, captured, into } = options
133
+ const doc: unknown = await payload.findByID({
134
+ collection,
135
+ id,
136
+ depth: 0,
137
+ overrideAccess: true,
138
+ })
139
+ const record =
140
+ typeof doc === 'object' && doc !== null ? (doc as Record<string, unknown>) : undefined
141
+ const present = new Set<string>()
142
+ let newlyCaptured = 0
143
+ for (const field of [...fields, 'updatedAt']) {
144
+ if (record === undefined || !(field in record)) {
145
+ continue
146
+ }
147
+ present.add(field)
148
+ const key = writeKey(collection, id, field)
149
+ if (captured.has(key)) {
150
+ // The FIRST capture is the pre-import value; a later pass would
151
+ // only capture this tool's own earlier write (issue #88).
152
+ continue
153
+ }
154
+ captured.add(key)
155
+ into.push({
156
+ collection,
157
+ id,
158
+ field,
159
+ sealedBefore: sealValue(payload.secret, JSON.stringify(record[field] ?? null), key),
160
+ })
161
+ newlyCaptured += 1
162
+ }
163
+ return { present, newlyCaptured }
164
+ }
165
+
166
+ /**
167
+ * Restore every captured field, one adapter write per document so
168
+ * `updatedAt` lands exactly as captured. Returns the number of field
169
+ * values restored.
170
+ */
171
+ export async function restoreCaptured(
172
+ payload: Payload,
173
+ manifest: Pick<ImportManifest, 'fieldWrites'>
174
+ ): Promise<number> {
175
+ const byDoc = new Map<
176
+ string,
177
+ { collection: string; id: string | number; data: Record<string, unknown> }
178
+ >()
179
+ for (const write of manifest.fieldWrites) {
180
+ const key = `${write.collection}\u0000${String(write.id)}`
181
+ const entry = byDoc.get(key) ?? { collection: write.collection, id: write.id, data: {} }
182
+ entry.data[write.field] = priorValue(payload.secret, write)
183
+ byDoc.set(key, entry)
184
+ }
185
+ let restored = 0
186
+ for (const entry of byDoc.values()) {
187
+ const fields = payload.collections[entry.collection]?.config.flattenedFields ?? []
188
+ const isChildRowField = (name: string): boolean => {
189
+ const field = fields.find((candidate) => candidate.name === name)
190
+ return (
191
+ field !== undefined &&
192
+ (field.type === 'array' ||
193
+ field.type === 'blocks' ||
194
+ ('hasMany' in field && field.hasMany === true))
195
+ )
196
+ }
197
+ const childRows: Record<string, unknown> = {}
198
+ const columns: Record<string, unknown> = {}
199
+ for (const [field, value] of Object.entries(entry.data)) {
200
+ if (isChildRowField(field)) {
201
+ childRows[field] = value
202
+ } else {
203
+ columns[field] = value
204
+ }
205
+ }
206
+ // Child-row fields (arrays, hasMany) go through the Local API: the
207
+ // SQL adapters rewrite child tables only through a full-document
208
+ // upsert, which the operation provides.
209
+ if (Object.keys(childRows).length > 0) {
210
+ await payload.update({
211
+ collection: entry.collection,
212
+ id: entry.id,
213
+ data: childRows,
214
+ overrideAccess: true,
215
+ })
216
+ }
217
+ // Column fields — `updatedAt` LAST-WRITTEN, exactly as captured —
218
+ // go straight through the adapter: a plain UPDATE of just these
219
+ // columns, no fresh timestamp, no hooks.
220
+ if (Object.keys(columns).length > 0) {
221
+ await payload.db.updateOne({
222
+ collection: entry.collection,
223
+ id: entry.id,
224
+ data: columns,
225
+ returning: false,
226
+ })
227
+ }
228
+ restored += Object.keys(entry.data).length
229
+ }
230
+ return restored
231
+ }
@@ -1,5 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { writePrivateFile } from '../private-file.js'
2
3
  import type { Payload } from 'payload'
4
+ import { restoreCaptured } from './host-writes.js'
5
+ import { authenticateManifest, signManifest } from './manifest-mac.js'
3
6
  import {
4
7
  defaultTargetSlugs,
5
8
  resolveSourceTables,
@@ -15,25 +18,40 @@ import {
15
18
  * separate later step.
16
19
  */
17
20
 
21
+ /**
22
+ * The importer's default manifest location: the CURRENT WORKING
23
+ * DIRECTORY. Pass `--manifest` to put it somewhere access-controlled;
24
+ * it holds ids and sealed values only (no emails in the clear) and is
25
+ * written with mode 0600. Keep it until `finalize` + your soak period
26
+ * ends, then delete it (docs/migrating-from-payload-auth.md).
27
+ */
18
28
  export const defaultManifestPath = 'payload-auth-link-migration.json'
19
29
 
30
+ /** The `adopt-prelink` default manifest location (cwd; same rules). */
31
+ export const defaultPrelinkManifestPath = 'payload-auth-link-prelink.json'
32
+
20
33
  export function readManifest(path: string): ImportManifest | null {
21
34
  if (!existsSync(path)) {
22
35
  return null
23
36
  }
24
37
  const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
25
- if (
26
- typeof parsed !== 'object' ||
27
- parsed === null ||
28
- (parsed as { version?: unknown }).version !== 1
29
- ) {
30
- throw new Error(`manifest at ${path} is not a version-1 import manifest`)
38
+ const version =
39
+ typeof parsed === 'object' && parsed !== null
40
+ ? (parsed as { version?: unknown }).version
41
+ : undefined
42
+ if (version !== 1 && version !== 2 && version !== 3) {
43
+ throw new Error(`manifest at ${path} is not a version-1/2/3 import manifest`)
31
44
  }
32
45
  return parsed as ImportManifest
33
46
  }
34
47
 
48
+ /**
49
+ * Write the manifest owner-read/write only (0600, issue #88) — on
50
+ * create AND on every rewrite, so a manifest created by an older kit
51
+ * is tightened too.
52
+ */
35
53
  export function writeManifest(manifest: ImportManifest, path: string): void {
36
- writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`)
54
+ writePrivateFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
37
55
  }
38
56
 
39
57
  export type VerifyIssue = {
@@ -67,6 +85,8 @@ export async function verifyImport(options: {
67
85
  readonly sourceTables?: Readonly<Record<string, string | null>> | undefined
68
86
  /** Whether the import carried sessions; `drop` skips the session count. */
69
87
  readonly sessions?: 'carry' | 'drop' | undefined
88
+ /** Injectable clock: what counts as an expired source session. */
89
+ readonly now?: (() => Date) | undefined
70
90
  }): Promise<VerifyResult> {
71
91
  const { payload, sourceConnectionString } = options
72
92
  const slugs: Record<string, string> = {
@@ -118,11 +138,6 @@ export async function verifyImport(options: {
118
138
  }[] = [
119
139
  { label: 'users', table: sourceTables['users'], collection: String(slugs['user']) },
120
140
  { label: 'accounts', table: sourceTables['accounts'], collection: String(slugs['account']) },
121
- {
122
- label: 'sessions',
123
- table: sessions === 'carry' ? sourceTables['sessions'] : null,
124
- collection: String(slugs['session']),
125
- },
126
141
  {
127
142
  label: 'verifications',
128
143
  table: sourceTables['verifications'],
@@ -163,6 +178,58 @@ export async function verifyImport(options: {
163
178
  name: 'count sessions (skipped: the import dropped them)',
164
179
  passed: true,
165
180
  })
181
+ } else if (sourceTables['sessions'] === null) {
182
+ checks.push({
183
+ name: 'count sessions (skipped: null-mapped — not installed on the source)',
184
+ passed: true,
185
+ })
186
+ } else {
187
+ // Sessions are LIVE data after go-live (issue #88): sign-ins add
188
+ // ba-sessions rows, and Better Auth deletes expired rows when a
189
+ // lookup meets them. So the check is scoped to the IMPORTED rows
190
+ // (deterministic `sess-<source id>` baIds): every unexpired source
191
+ // session must be present; sessions created after cutover and
192
+ // expired sessions already cleaned up are reported, never failed.
193
+ const nowMs = (options.now ?? (() => new Date()))().getTime()
194
+ const sourceSessions = await client.query(
195
+ `SELECT id, expires_at FROM ${sourceTables['sessions']}`
196
+ )
197
+ const target = await payload.find({
198
+ collection: String(slugs['session']),
199
+ depth: 0,
200
+ pagination: false,
201
+ overrideAccess: true,
202
+ select: { baId: true },
203
+ })
204
+ const targetBaIds = new Set(
205
+ target.docs.map((doc) => String((doc as Record<string, unknown>)['baId']))
206
+ )
207
+ let missingLive = 0
208
+ let cleanedExpired = 0
209
+ const imported = new Set<string>()
210
+ for (const row of sourceSessions.rows as { id: unknown; expires_at: unknown }[]) {
211
+ const baId = `sess-${String(row.id)}`
212
+ imported.add(baId)
213
+ if (targetBaIds.has(baId)) {
214
+ continue
215
+ }
216
+ const expires = row.expires_at instanceof Date ? row.expires_at.getTime() : Number.NaN
217
+ if (Number.isFinite(expires) && expires <= nowMs) {
218
+ cleanedExpired += 1
219
+ } else {
220
+ missingLive += 1
221
+ }
222
+ }
223
+ const created = [...targetBaIds].filter((baId) => !imported.has(baId)).length
224
+ check(
225
+ 'imported sessions present (unexpired source sessions)',
226
+ missingLive === 0,
227
+ `${missingLive} unexpired source session(s) missing from ${String(slugs['session'])}`
228
+ )
229
+ checks.push({
230
+ name: `sessions after cutover (info): ${created} created since the import, ${cleanedExpired} expired imported session(s) already cleaned up`,
231
+ passed: true,
232
+ })
166
233
  }
167
234
 
168
235
  // 2. every source user has exactly one target row and its host doc.
@@ -307,6 +374,33 @@ export async function verifyImport(options: {
307
374
  'no credential account carries a password hash'
308
375
  )
309
376
 
377
+ // 3b. bans carried (issue #88): a banned source user stays banned.
378
+ // The importer refuses to run when banned users exist and the
379
+ // target declares no `banned` field, so here the field is present
380
+ // whenever it matters.
381
+ const bannedSource = users.filter((user) => user.banned === true)
382
+ if (bannedSource.length > 0) {
383
+ let notBanned = 0
384
+ for (const user of bannedSource) {
385
+ const found = await payload.find({
386
+ collection: String(slugs['user']),
387
+ where: { baId: { equals: user.id } },
388
+ limit: 1,
389
+ depth: 0,
390
+ overrideAccess: true,
391
+ })
392
+ const doc = found.docs[0] as Record<string, unknown> | undefined
393
+ if (doc?.['banned'] !== true) {
394
+ notBanned += 1
395
+ }
396
+ }
397
+ check(
398
+ 'banned source users stay banned',
399
+ notBanned === 0,
400
+ `${notBanned} banned source user(s) are not banned on ${String(slugs['user'])}`
401
+ )
402
+ }
403
+
310
404
  // 4. API keys: hash present, owner resolves, rate-limit state kept.
311
405
  const keys = await payload.find({
312
406
  collection: String(slugs['apikey']),
@@ -402,6 +496,16 @@ export async function rollbackImport(options: {
402
496
  readonly payload: Payload
403
497
  readonly manifest: ImportManifest
404
498
  readonly log?: (message: string) => void
499
+ /**
500
+ * When given, the manifest at this path is RESET after a successful
501
+ * rollback (issue #88): no created rows, no field writes, no totals —
502
+ * so a later `--apply` starts from the restored state instead of
503
+ * building on stale entries.
504
+ */
505
+ readonly manifestPath?: string | undefined
506
+ readonly now?: (() => Date) | undefined
507
+ /** Accept a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
508
+ readonly allowUnsignedManifest?: boolean | undefined
405
509
  }): Promise<{ deleted: number; restored: number }> {
406
510
  const { payload, manifest, log = () => {} } = options
407
511
  if (manifest.finalized) {
@@ -409,6 +513,9 @@ export async function rollbackImport(options: {
409
513
  // the migration's history, not a TODO to undo.
410
514
  throw new Error('the migration is finalized; rollback is no longer possible')
411
515
  }
516
+ // Every row below is deleted, and every field restored, with
517
+ // overrideAccess: only a manifest this app signed may drive that.
518
+ authenticateManifest(payload.secret, manifest, { allowUnsigned: options.allowUnsignedManifest })
412
519
  let deleted = 0
413
520
  for (const row of [...manifest.created].reverse()) {
414
521
  try {
@@ -420,18 +527,18 @@ export async function rollbackImport(options: {
420
527
  log(`rollback: ${row.collection}#${row.id} already absent`)
421
528
  }
422
529
  }
423
- let restored = 0
424
- for (const write of manifest.fieldWrites) {
425
- const data: Record<string, unknown> = {
426
- [write.field]: write.before === null ? null : (JSON.parse(write.before) as unknown),
427
- }
428
- await payload.update({
429
- collection: write.collection,
430
- id: write.id,
431
- data,
432
- overrideAccess: true,
530
+ // Captured values go back through the adapter, one write per
531
+ // document, `updatedAt` included — see host-writes.ts for why.
532
+ const restored = await restoreCaptured(payload, manifest)
533
+ if (options.manifestPath !== undefined) {
534
+ const reset: ImportManifest = signManifest(payload.secret, {
535
+ ...manifest,
536
+ created: [],
537
+ fieldWrites: [],
538
+ totals: {},
539
+ rolledBackAt: (options.now ?? (() => new Date()))().toISOString(),
433
540
  })
434
- restored += 1
541
+ writeManifest(reset, options.manifestPath)
435
542
  }
436
543
  log(`rollback: ${deleted} row(s) deleted, ${restored} field value(s) restored`)
437
544
  return { deleted, restored }
@@ -0,0 +1,90 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
2
+ import type { ImportManifest } from './types.js'
3
+
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.
14
+ *
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.
18
+ */
19
+
20
+ const macPrefix = 'v1.'
21
+
22
+ function macKey(secret: string): Buffer {
23
+ return createHash('sha256')
24
+ .update('payload-auth-link/migrate/manifest-mac/v1\u0000')
25
+ .update(secret)
26
+ .digest()
27
+ }
28
+
29
+ /** JSON with object keys sorted at every level (stable across rewrites). */
30
+ function canonical(value: unknown): string {
31
+ if (Array.isArray(value)) {
32
+ return `[${value.map((item) => (item === undefined ? 'null' : canonical(item))).join(',')}]`
33
+ }
34
+ if (typeof value === 'object' && value !== null) {
35
+ const entries = Object.entries(value as Record<string, unknown>)
36
+ .filter(([, item]) => item !== undefined)
37
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
38
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`
39
+ }
40
+ return JSON.stringify(value)
41
+ }
42
+
43
+ function macOf(secret: string, manifest: ImportManifest): string {
44
+ const body: Record<string, unknown> = { ...manifest }
45
+ delete body['mac']
46
+ delete body['finalized']
47
+ const digest = createHmac('sha256', macKey(secret)).update(canonical(body)).digest()
48
+ return `${macPrefix}${digest.toString('base64url')}`
49
+ }
50
+
51
+ /** Stamp `manifest` as version 3 and sign it under the app's secret. */
52
+ export function signManifest(secret: string, manifest: ImportManifest): ImportManifest {
53
+ const unsigned: ImportManifest = { ...manifest, version: 3, mac: undefined }
54
+ return { ...unsigned, mac: macOf(secret, unsigned) }
55
+ }
56
+
57
+ /**
58
+ * Refuse a manifest this app did not write. A version-3 manifest must
59
+ * carry a MAC that verifies under `secret`. A version-1/2 manifest
60
+ * (written before 0.6.0) has no MAC: it is accepted only when the
61
+ * operator passes `allowUnsigned` (`--allow-unsigned-manifest`) to vouch
62
+ * for the file, and even then a version-2 manifest may not hold
63
+ * plaintext values, which that version never wrote.
64
+ */
65
+ export function authenticateManifest(
66
+ secret: string,
67
+ manifest: ImportManifest,
68
+ options: { readonly allowUnsigned?: boolean | undefined } = {}
69
+ ): void {
70
+ 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
+ )
77
+ }
78
+ return
79
+ }
80
+ if (options.allowUnsigned !== true) {
81
+ throw new Error(
82
+ `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`
83
+ )
84
+ }
85
+ if (manifest.version === 2 && manifest.fieldWrites.some((write) => write.before !== undefined)) {
86
+ throw new Error(
87
+ 'the version-2 manifest holds a plaintext prior value, which version 2 never writes (edited manifest?); refusing to use it'
88
+ )
89
+ }
90
+ }