@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.
- package/LICENSE +21 -0
- package/README.md +7 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +109 -19
- package/dist/cli.js.map +1 -1
- package/dist/doctor/database.d.ts.map +1 -1
- package/dist/doctor/database.js +31 -4
- package/dist/doctor/database.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +67 -0
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/types.d.ts +12 -0
- package/dist/doctor/types.d.ts.map +1 -1
- package/dist/doctor/users.d.ts.map +1 -1
- package/dist/doctor/users.js +20 -4
- package/dist/doctor/users.js.map +1 -1
- package/dist/importer/apply.d.ts +25 -5
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +164 -180
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/host-writes.d.ts +47 -0
- package/dist/importer/host-writes.d.ts.map +1 -0
- package/dist/importer/host-writes.js +198 -0
- package/dist/importer/host-writes.js.map +1 -0
- package/dist/importer/lifecycle.d.ts +26 -0
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +107 -22
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/manifest-mac.d.ts +15 -0
- package/dist/importer/manifest-mac.d.ts.map +1 -0
- package/dist/importer/manifest-mac.js +73 -0
- package/dist/importer/manifest-mac.js.map +1 -0
- package/dist/importer/prelink.d.ts +57 -0
- package/dist/importer/prelink.d.ts.map +1 -0
- package/dist/importer/prelink.js +159 -0
- package/dist/importer/prelink.js.map +1 -0
- package/dist/importer/run-import.d.ts +15 -0
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +155 -14
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/source.d.ts +35 -7
- package/dist/importer/source.d.ts.map +1 -1
- package/dist/importer/source.js +37 -6
- package/dist/importer/source.js.map +1 -1
- package/dist/importer/types.d.ts +34 -4
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/private-file.d.ts +8 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +19 -0
- package/dist/private-file.js.map +1 -0
- package/package.json +10 -10
- package/src/cli.ts +117 -9
- package/src/doctor/database.ts +36 -4
- package/src/doctor/run-doctor.ts +69 -0
- package/src/doctor/types.ts +12 -0
- package/src/doctor/users.ts +23 -4
- package/src/importer/apply.ts +198 -182
- package/src/importer/host-writes.ts +231 -0
- package/src/importer/lifecycle.ts +131 -24
- package/src/importer/manifest-mac.ts +90 -0
- package/src/importer/prelink.ts +237 -0
- package/src/importer/run-import.ts +207 -29
- package/src/importer/source.ts +72 -14
- package/src/importer/types.ts +35 -4
- package/src/index.ts +13 -1
- package/src/private-file.ts +18 -0
package/src/importer/source.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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:
|
|
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
|
|
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:
|
|
335
|
+
client: SourceClient,
|
|
278
336
|
tables: ResolvedSourceTables = defaultSourceTables
|
|
279
337
|
): Promise<OrphanScan> {
|
|
280
338
|
const orphans: Unmappable[] = []
|
package/src/importer/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
+
}
|