@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
@@ -31,10 +31,22 @@ export type SourceUser = {
31
31
  /** Stored role values from the `users_role` child table. */
32
32
  readonly roles: readonly string[]
33
33
  readonly accounts: readonly SourceAccount[]
34
+ /**
35
+ * Better Auth admin-plugin ban state (issue #88), `null` when the
36
+ * source layout has no such column (the plugin was never installed).
37
+ */
38
+ readonly banned: boolean | null
39
+ readonly banReason: string | null
40
+ readonly banExpires: Date | null
41
+ /** Better Auth anonymous-plugin marker; `null` when no column exists. */
42
+ readonly isAnonymous: boolean | null
34
43
  }
35
44
 
36
45
  export type SourceAccount = {
37
46
  readonly id: string
47
+ /** Source row timestamps, carried onto the target row (issue #88). */
48
+ readonly createdAt: Date | null
49
+ readonly updatedAt: Date | null
38
50
  readonly accountId: string
39
51
  readonly providerId: string
40
52
  readonly password: string | null
@@ -76,14 +88,24 @@ const requiredColumns: Readonly<Record<SourceModel, readonly string[]>> = {
76
88
  users_role: ['parent_id', 'value'],
77
89
  }
78
90
 
79
- export async function openSource(connectionString: string): Promise<Client> {
91
+ /**
92
+ * The slice of a `pg` client the read side uses. Public signatures take
93
+ * this structural type (a `pg.Client` satisfies it) so the published
94
+ * `.d.ts` never references `pg` — consumers need no `@types/pg`.
95
+ */
96
+ export interface SourceClient {
97
+ query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>
98
+ end(): Promise<void>
99
+ }
100
+
101
+ export async function openSource(connectionString: string): Promise<SourceClient> {
80
102
  const client = new Client({ connectionString })
81
103
  await client.connect()
82
104
  return client
83
105
  }
84
106
 
85
107
  /** The columns one table actually has (customized layouts drop columns). */
86
- async function columnsOf(client: Client, table: string): Promise<Set<string>> {
108
+ async function columnsOf(client: SourceClient, table: string): Promise<Set<string>> {
87
109
  const result = await client.query(
88
110
  `SELECT column_name FROM information_schema.columns
89
111
  WHERE table_schema = 'public' AND table_name = $1`,
@@ -92,7 +114,7 @@ async function columnsOf(client: Client, table: string): Promise<Set<string>> {
92
114
  return new Set(result.rows.map((row) => String((row as { column_name: unknown }).column_name)))
93
115
  }
94
116
 
95
- async function tableExists(client: Client, table: string): Promise<boolean> {
117
+ async function tableExists(client: SourceClient, table: string): Promise<boolean> {
96
118
  const result = await client.query(
97
119
  `SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
98
120
  [table]
@@ -108,7 +130,7 @@ async function tableExists(client: Client, table: string): Promise<boolean> {
108
130
  * read, planned, or written past a failed probe.
109
131
  */
110
132
  export async function probeSourceLayout(
111
- client: Client,
133
+ client: SourceClient,
112
134
  tables: ResolvedSourceTables
113
135
  ): Promise<readonly SourceLayoutIssue[]> {
114
136
  const issues: SourceLayoutIssue[] = []
@@ -146,7 +168,7 @@ export async function probeSourceLayout(
146
168
  * throw here means the probe was skipped.
147
169
  */
148
170
  export async function countTables(
149
- client: Client,
171
+ client: SourceClient,
150
172
  tables: readonly string[]
151
173
  ): Promise<Record<string, number>> {
152
174
  const counts: Record<string, number> = {}
@@ -170,7 +192,7 @@ function text(value: unknown): string | null {
170
192
 
171
193
  /** Every source user, with role rows and accounts attached. */
172
194
  export async function readSourceUsers(
173
- client: Client,
195
+ client: SourceClient,
174
196
  tables: ResolvedSourceTables = defaultSourceTables
175
197
  ): Promise<SourceUser[]> {
176
198
  const usersTable = tables['users']
@@ -179,8 +201,14 @@ export async function readSourceUsers(
179
201
  if (usersTable === null) {
180
202
  return []
181
203
  }
204
+ // Plugin columns (admin bans, anonymous marker) exist only when the
205
+ // payload-auth app installed those plugins — read them when present.
206
+ const userColumns = await columnsOf(client, usersTable)
207
+ const optionalUserColumns = ['banned', 'ban_reason', 'ban_expires', 'is_anonymous'].filter(
208
+ (column) => userColumns.has(column)
209
+ )
182
210
  const users = await client.query(
183
- `SELECT id, email, name, email_verified, image, created_at, updated_at FROM ${usersTable} ORDER BY id`
211
+ `SELECT ${['id', 'email', 'name', 'email_verified', 'image', 'created_at', 'updated_at', ...optionalUserColumns].join(', ')} FROM ${usersTable} ORDER BY id`
184
212
  )
185
213
  const rolesTable = tables['users_role']
186
214
  const roleRows =
@@ -193,19 +221,37 @@ export async function readSourceUsers(
193
221
  list.push(row.value)
194
222
  rolesByUser.set(String(row.parent_id), list)
195
223
  }
224
+ const accountColumns =
225
+ accountsTable === null ? new Set<string>() : await columnsOf(client, accountsTable)
226
+ const accountTimestamps = ['created_at', 'updated_at'].filter((column) =>
227
+ accountColumns.has(column)
228
+ )
196
229
  const accounts =
197
230
  accountsTable === null
198
231
  ? { rows: [] as unknown[] }
199
232
  : await client.query(
200
- `SELECT id, account_id, provider_id, password, access_token, refresh_token, id_token,
201
- scope, access_token_expires_at, refresh_token_expires_at, user_id
202
- FROM ${accountsTable} ORDER BY id`
233
+ `SELECT ${[
234
+ 'id',
235
+ 'account_id',
236
+ 'provider_id',
237
+ 'password',
238
+ 'access_token',
239
+ 'refresh_token',
240
+ 'id_token',
241
+ 'scope',
242
+ 'access_token_expires_at',
243
+ 'refresh_token_expires_at',
244
+ 'user_id',
245
+ ...accountTimestamps,
246
+ ].join(', ')} FROM ${accountsTable} ORDER BY id`
203
247
  )
204
248
  const accountsByUser = new Map<string, SourceAccount[]>()
205
249
  for (const row of accounts.rows as Record<string, unknown>[]) {
206
250
  const userId = String(row.user_id)
207
251
  const account: SourceAccount = {
208
252
  id: String(row.id),
253
+ createdAt: iso(row.created_at),
254
+ updatedAt: iso(row.updated_at),
209
255
  accountId: String(row.account_id),
210
256
  providerId: String(row.provider_id),
211
257
  password: text(row.password),
@@ -233,6 +279,10 @@ export async function readSourceUsers(
233
279
  updatedAt: iso(row.updated_at) ?? new Date(),
234
280
  roles: rolesByUser.get(id) ?? [],
235
281
  accounts: accountsByUser.get(id) ?? [],
282
+ banned: userColumns.has('banned') ? row.banned === true : null,
283
+ banReason: userColumns.has('ban_reason') ? text(row.ban_reason) : null,
284
+ banExpires: userColumns.has('ban_expires') ? iso(row.ban_expires) : null,
285
+ isAnonymous: userColumns.has('is_anonymous') ? row.is_anonymous === true : null,
236
286
  })
237
287
  }
238
288
  return out
@@ -244,14 +294,22 @@ export async function readSourceUsers(
244
294
  * asking for it, and an unmapped missing table aborts at the probe.
245
295
  */
246
296
  export async function readFlatRows(
247
- client: Client,
297
+ client: SourceClient,
248
298
  table: string,
249
- columns: readonly string[]
299
+ columns: readonly string[],
300
+ /**
301
+ * Columns read only when the layout has them (issue #88: row
302
+ * timestamps) — an absent optional column is simply not selected,
303
+ * and the row carries no key for it.
304
+ */
305
+ optionalColumns: readonly string[] = []
250
306
  ): Promise<SourceRow[]> {
251
307
  if (!(await tableExists(client, table))) {
252
308
  throw new Error(`source table '${table}' is missing — run probeSourceLayout first`)
253
309
  }
254
- const result = await client.query(`SELECT ${columns.join(', ')} FROM ${table} ORDER BY id`)
310
+ const existing = optionalColumns.length > 0 ? await columnsOf(client, table) : new Set<string>()
311
+ const selected = [...columns, ...optionalColumns.filter((column) => existing.has(column))]
312
+ const result = await client.query(`SELECT ${selected.join(', ')} FROM ${table} ORDER BY id`)
255
313
  return result.rows as SourceRow[]
256
314
  }
257
315
 
@@ -274,7 +332,7 @@ export type OrphanScan = {
274
332
  * of passing silently.
275
333
  */
276
334
  export async function findOrphans(
277
- client: Client,
335
+ client: SourceClient,
278
336
  tables: ResolvedSourceTables = defaultSourceTables
279
337
  ): Promise<OrphanScan> {
280
338
  const orphans: Unmappable[] = []
@@ -2,6 +2,15 @@
2
2
  * Importer manifest (issue #50): every row created and every field
3
3
  * changed, so `rollback` is exact. The manifest is the run's contract —
4
4
  * `verify`, `rollback`, and `finalize` all read it.
5
+ *
6
+ * Privacy (issue #88): a version-2 manifest holds NO email address or
7
+ * other field value in the clear. Created rows are named by ids only,
8
+ * and every captured prior value is sealed (AES-256-GCM, keyed from the
9
+ * target app's Payload `secret`), so only the same app can restore it.
10
+ * The file is written with mode 0600.
11
+ *
12
+ * Integrity: a version-3 manifest is signed (`mac`), and rollback or a
13
+ * resumed run refuses one that does not verify.
5
14
  */
6
15
  export type CreatedRow = {
7
16
  /** Target collection slug. */
@@ -16,12 +25,27 @@ export type FieldWrite = {
16
25
  readonly collection: string
17
26
  readonly id: string | number
18
27
  readonly field: string
19
- /** The prior value, JSON-serialized, restored by rollback. */
20
- readonly before: string | null
28
+ /**
29
+ * The prior value, JSON-serialized — version-1 manifests only.
30
+ * Version 2 never stores values in the clear; see `sealedBefore`.
31
+ */
32
+ readonly before?: string | null | undefined
33
+ /**
34
+ * The prior value, JSON-serialized and sealed with the target app's
35
+ * Payload secret (version 2, issue #88), restored by rollback. Only
36
+ * the FIRST capture per document field is kept, so a rollback after
37
+ * any number of `--apply` passes restores the pre-import state.
38
+ */
39
+ readonly sealedBefore?: string | undefined
21
40
  }
22
41
 
42
+ /** Which command produced the manifest (issue #88). */
43
+ export type ManifestKind = 'import' | 'adopt-prelink'
44
+
23
45
  export type ImportManifest = {
24
- readonly version: 1
46
+ readonly version: 1 | 2 | 3
47
+ /** The producing command; absent on version-1 manifests (imports). */
48
+ readonly kind?: ManifestKind | undefined
25
49
  readonly createdAt: string
26
50
  readonly sourceDatabase: string
27
51
  readonly options: {
@@ -32,8 +56,15 @@ export type ImportManifest = {
32
56
  readonly finalized: boolean
33
57
  /** Counts per source table at plan time (the rollback baseline). */
34
58
  readonly sourceCounts: Readonly<Record<string, number>>
35
- /** Cumulative plan totals (rows created + skipped across resumes). */
59
+ /** Rows the LATEST pass planned per model — never summed across passes (#88). */
36
60
  readonly totals: Readonly<Record<string, number>>
61
+ /** When the last rollback reset this manifest (issue #88). */
62
+ readonly rolledBackAt?: string | undefined
63
+ /**
64
+ * Version 3: HMAC over the whole manifest (except `finalized`), keyed
65
+ * from the target app's Payload secret. See `manifest-mac.ts`.
66
+ */
67
+ readonly mac?: string | undefined
37
68
  }
38
69
 
39
70
  /** Per-model plan row for the dry run. */
package/src/index.ts CHANGED
@@ -16,7 +16,16 @@ export { classifyPlugins, pluginsFromSourceTree } from './doctor/plugins.js'
16
16
  export { scanSource, projectedUserFields, listSourceFiles } from './doctor/source-scan.js'
17
17
  export { analyzeUsersCollection, findUsersCollection, localStrategyOn } from './doctor/users.js'
18
18
  export { introspectDatabase } from './doctor/database.js'
19
- export { runFromPayloadAuth, runVerify, runRollback, runFinalize } from './importer/run-import.js'
19
+ export {
20
+ runFromPayloadAuth,
21
+ runVerify,
22
+ runRollback,
23
+ runFinalize,
24
+ runAdoptPrelink,
25
+ type ImportCommandOptions,
26
+ type PrelinkCommandOptions,
27
+ } from './importer/run-import.js'
28
+ export { prelinkUsers, type PrelinkOptions, type PrelinkResult } from './importer/prelink.js'
20
29
  export { applyImport, summarizePlanRows } from './importer/apply.js'
21
30
  export {
22
31
  countTables,
@@ -27,6 +36,7 @@ export {
27
36
  readSourceUsers,
28
37
  unverifiedEmails,
29
38
  type OrphanScan,
39
+ type SourceClient,
30
40
  type SourceLayoutIssue,
31
41
  type SourceAccount,
32
42
  type SourceRow,
@@ -39,6 +49,7 @@ export {
39
49
  rollbackImport,
40
50
  finalizeManifest,
41
51
  defaultManifestPath,
52
+ defaultPrelinkManifestPath,
42
53
  type VerifyIssue,
43
54
  type VerifyResult,
44
55
  } from './importer/lifecycle.js'
@@ -57,6 +68,7 @@ export {
57
68
  type TargetSlugMap,
58
69
  type FieldWrite,
59
70
  type ImportManifest,
71
+ type ManifestKind,
60
72
  type ImportPlan,
61
73
  type ImportResult,
62
74
  type PlanRow,
@@ -0,0 +1,18 @@
1
+ import { closeSync, fchmodSync, ftruncateSync, openSync, writeSync } from 'node:fs'
2
+
3
+ /**
4
+ * Write `content` to `path` readable by the owner only (0600). The
5
+ * mode is tightened on the open descriptor BEFORE any content lands, so
6
+ * a pre-existing world-readable file (an older kit's manifest, a
7
+ * reused `--out` path) never holds the new content with its old mode.
8
+ */
9
+ export function writePrivateFile(path: string, content: string): void {
10
+ const fd = openSync(path, 'a', 0o600)
11
+ try {
12
+ fchmodSync(fd, 0o600)
13
+ ftruncateSync(fd, 0)
14
+ writeSync(fd, content, 0)
15
+ } finally {
16
+ closeSync(fd)
17
+ }
18
+ }