@payload-auth-link/migrate 0.4.3 → 0.5.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 +103 -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 +23 -5
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +154 -178
- 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 +24 -0
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +104 -22
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/prelink.d.ts +55 -0
- package/dist/importer/prelink.d.ts.map +1 -0
- package/dist/importer/prelink.js +152 -0
- package/dist/importer/prelink.js.map +1 -0
- package/dist/importer/run-import.d.ts +13 -0
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +147 -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 +26 -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 +6 -6
- package/src/cli.ts +111 -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 +186 -180
- package/src/importer/host-writes.ts +231 -0
- package/src/importer/lifecycle.ts +126 -24
- package/src/importer/prelink.ts +228 -0
- package/src/importer/run-import.ts +196 -29
- package/src/importer/source.ts +72 -14
- package/src/importer/types.ts +27 -4
- package/src/index.ts +13 -1
- 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,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
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'
|
|
3
5
|
import {
|
|
4
6
|
defaultTargetSlugs,
|
|
5
7
|
resolveSourceTables,
|
|
@@ -15,25 +17,40 @@ import {
|
|
|
15
17
|
* separate later step.
|
|
16
18
|
*/
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The importer's default manifest location: the CURRENT WORKING
|
|
22
|
+
* DIRECTORY. Pass `--manifest` to put it somewhere access-controlled;
|
|
23
|
+
* it holds ids and sealed values only (no emails in the clear) and is
|
|
24
|
+
* written with mode 0600. Keep it until `finalize` + your soak period
|
|
25
|
+
* ends, then delete it (docs/migrating-from-payload-auth.md).
|
|
26
|
+
*/
|
|
18
27
|
export const defaultManifestPath = 'payload-auth-link-migration.json'
|
|
19
28
|
|
|
29
|
+
/** The `adopt-prelink` default manifest location (cwd; same rules). */
|
|
30
|
+
export const defaultPrelinkManifestPath = 'payload-auth-link-prelink.json'
|
|
31
|
+
|
|
20
32
|
export function readManifest(path: string): ImportManifest | null {
|
|
21
33
|
if (!existsSync(path)) {
|
|
22
34
|
return null
|
|
23
35
|
}
|
|
24
36
|
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
25
|
-
|
|
26
|
-
typeof parsed
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
) {
|
|
30
|
-
throw new Error(`manifest at ${path} is not a version-1 import manifest`)
|
|
37
|
+
const version =
|
|
38
|
+
typeof parsed === 'object' && parsed !== null
|
|
39
|
+
? (parsed as { version?: unknown }).version
|
|
40
|
+
: undefined
|
|
41
|
+
if (version !== 1 && version !== 2) {
|
|
42
|
+
throw new Error(`manifest at ${path} is not a version-1/2 import manifest`)
|
|
31
43
|
}
|
|
32
44
|
return parsed as ImportManifest
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Write the manifest owner-read/write only (0600, issue #88) — on
|
|
49
|
+
* create AND on every rewrite, so a manifest created by an older kit
|
|
50
|
+
* is tightened too.
|
|
51
|
+
*/
|
|
35
52
|
export function writeManifest(manifest: ImportManifest, path: string): void {
|
|
36
|
-
|
|
53
|
+
writePrivateFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
export type VerifyIssue = {
|
|
@@ -67,6 +84,8 @@ export async function verifyImport(options: {
|
|
|
67
84
|
readonly sourceTables?: Readonly<Record<string, string | null>> | undefined
|
|
68
85
|
/** Whether the import carried sessions; `drop` skips the session count. */
|
|
69
86
|
readonly sessions?: 'carry' | 'drop' | undefined
|
|
87
|
+
/** Injectable clock: what counts as an expired source session. */
|
|
88
|
+
readonly now?: (() => Date) | undefined
|
|
70
89
|
}): Promise<VerifyResult> {
|
|
71
90
|
const { payload, sourceConnectionString } = options
|
|
72
91
|
const slugs: Record<string, string> = {
|
|
@@ -118,11 +137,6 @@ export async function verifyImport(options: {
|
|
|
118
137
|
}[] = [
|
|
119
138
|
{ label: 'users', table: sourceTables['users'], collection: String(slugs['user']) },
|
|
120
139
|
{ 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
140
|
{
|
|
127
141
|
label: 'verifications',
|
|
128
142
|
table: sourceTables['verifications'],
|
|
@@ -163,6 +177,58 @@ export async function verifyImport(options: {
|
|
|
163
177
|
name: 'count sessions (skipped: the import dropped them)',
|
|
164
178
|
passed: true,
|
|
165
179
|
})
|
|
180
|
+
} else if (sourceTables['sessions'] === null) {
|
|
181
|
+
checks.push({
|
|
182
|
+
name: 'count sessions (skipped: null-mapped — not installed on the source)',
|
|
183
|
+
passed: true,
|
|
184
|
+
})
|
|
185
|
+
} else {
|
|
186
|
+
// Sessions are LIVE data after go-live (issue #88): sign-ins add
|
|
187
|
+
// ba-sessions rows, and Better Auth deletes expired rows when a
|
|
188
|
+
// lookup meets them. So the check is scoped to the IMPORTED rows
|
|
189
|
+
// (deterministic `sess-<source id>` baIds): every unexpired source
|
|
190
|
+
// session must be present; sessions created after cutover and
|
|
191
|
+
// expired sessions already cleaned up are reported, never failed.
|
|
192
|
+
const nowMs = (options.now ?? (() => new Date()))().getTime()
|
|
193
|
+
const sourceSessions = await client.query(
|
|
194
|
+
`SELECT id, expires_at FROM ${sourceTables['sessions']}`
|
|
195
|
+
)
|
|
196
|
+
const target = await payload.find({
|
|
197
|
+
collection: String(slugs['session']),
|
|
198
|
+
depth: 0,
|
|
199
|
+
pagination: false,
|
|
200
|
+
overrideAccess: true,
|
|
201
|
+
select: { baId: true },
|
|
202
|
+
})
|
|
203
|
+
const targetBaIds = new Set(
|
|
204
|
+
target.docs.map((doc) => String((doc as Record<string, unknown>)['baId']))
|
|
205
|
+
)
|
|
206
|
+
let missingLive = 0
|
|
207
|
+
let cleanedExpired = 0
|
|
208
|
+
const imported = new Set<string>()
|
|
209
|
+
for (const row of sourceSessions.rows as { id: unknown; expires_at: unknown }[]) {
|
|
210
|
+
const baId = `sess-${String(row.id)}`
|
|
211
|
+
imported.add(baId)
|
|
212
|
+
if (targetBaIds.has(baId)) {
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
const expires = row.expires_at instanceof Date ? row.expires_at.getTime() : Number.NaN
|
|
216
|
+
if (Number.isFinite(expires) && expires <= nowMs) {
|
|
217
|
+
cleanedExpired += 1
|
|
218
|
+
} else {
|
|
219
|
+
missingLive += 1
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const created = [...targetBaIds].filter((baId) => !imported.has(baId)).length
|
|
223
|
+
check(
|
|
224
|
+
'imported sessions present (unexpired source sessions)',
|
|
225
|
+
missingLive === 0,
|
|
226
|
+
`${missingLive} unexpired source session(s) missing from ${String(slugs['session'])}`
|
|
227
|
+
)
|
|
228
|
+
checks.push({
|
|
229
|
+
name: `sessions after cutover (info): ${created} created since the import, ${cleanedExpired} expired imported session(s) already cleaned up`,
|
|
230
|
+
passed: true,
|
|
231
|
+
})
|
|
166
232
|
}
|
|
167
233
|
|
|
168
234
|
// 2. every source user has exactly one target row and its host doc.
|
|
@@ -307,6 +373,33 @@ export async function verifyImport(options: {
|
|
|
307
373
|
'no credential account carries a password hash'
|
|
308
374
|
)
|
|
309
375
|
|
|
376
|
+
// 3b. bans carried (issue #88): a banned source user stays banned.
|
|
377
|
+
// The importer refuses to run when banned users exist and the
|
|
378
|
+
// target declares no `banned` field, so here the field is present
|
|
379
|
+
// whenever it matters.
|
|
380
|
+
const bannedSource = users.filter((user) => user.banned === true)
|
|
381
|
+
if (bannedSource.length > 0) {
|
|
382
|
+
let notBanned = 0
|
|
383
|
+
for (const user of bannedSource) {
|
|
384
|
+
const found = await payload.find({
|
|
385
|
+
collection: String(slugs['user']),
|
|
386
|
+
where: { baId: { equals: user.id } },
|
|
387
|
+
limit: 1,
|
|
388
|
+
depth: 0,
|
|
389
|
+
overrideAccess: true,
|
|
390
|
+
})
|
|
391
|
+
const doc = found.docs[0] as Record<string, unknown> | undefined
|
|
392
|
+
if (doc?.['banned'] !== true) {
|
|
393
|
+
notBanned += 1
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
check(
|
|
397
|
+
'banned source users stay banned',
|
|
398
|
+
notBanned === 0,
|
|
399
|
+
`${notBanned} banned source user(s) are not banned on ${String(slugs['user'])}`
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
310
403
|
// 4. API keys: hash present, owner resolves, rate-limit state kept.
|
|
311
404
|
const keys = await payload.find({
|
|
312
405
|
collection: String(slugs['apikey']),
|
|
@@ -402,6 +495,14 @@ export async function rollbackImport(options: {
|
|
|
402
495
|
readonly payload: Payload
|
|
403
496
|
readonly manifest: ImportManifest
|
|
404
497
|
readonly log?: (message: string) => void
|
|
498
|
+
/**
|
|
499
|
+
* When given, the manifest at this path is RESET after a successful
|
|
500
|
+
* rollback (issue #88): no created rows, no field writes, no totals —
|
|
501
|
+
* so a later `--apply` starts from the restored state instead of
|
|
502
|
+
* building on stale entries.
|
|
503
|
+
*/
|
|
504
|
+
readonly manifestPath?: string | undefined
|
|
505
|
+
readonly now?: (() => Date) | undefined
|
|
405
506
|
}): Promise<{ deleted: number; restored: number }> {
|
|
406
507
|
const { payload, manifest, log = () => {} } = options
|
|
407
508
|
if (manifest.finalized) {
|
|
@@ -420,18 +521,19 @@ export async function rollbackImport(options: {
|
|
|
420
521
|
log(`rollback: ${row.collection}#${row.id} already absent`)
|
|
421
522
|
}
|
|
422
523
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
524
|
+
// Captured values go back through the adapter, one write per
|
|
525
|
+
// document, `updatedAt` included — see host-writes.ts for why.
|
|
526
|
+
const restored = await restoreCaptured(payload, manifest)
|
|
527
|
+
if (options.manifestPath !== undefined) {
|
|
528
|
+
const reset: ImportManifest = {
|
|
529
|
+
...manifest,
|
|
530
|
+
version: 2,
|
|
531
|
+
created: [],
|
|
532
|
+
fieldWrites: [],
|
|
533
|
+
totals: {},
|
|
534
|
+
rolledBackAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
427
535
|
}
|
|
428
|
-
|
|
429
|
-
collection: write.collection,
|
|
430
|
-
id: write.id,
|
|
431
|
-
data,
|
|
432
|
-
overrideAccess: true,
|
|
433
|
-
})
|
|
434
|
-
restored += 1
|
|
536
|
+
writeManifest(reset, options.manifestPath)
|
|
435
537
|
}
|
|
436
538
|
log(`rollback: ${deleted} row(s) deleted, ${restored} field value(s) restored`)
|
|
437
539
|
return { deleted, restored }
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import { capturedKeys, captureBefore } from './host-writes.js'
|
|
3
|
+
import type { FieldWrite, ImportManifest, Unmappable } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The adopt-mode pre-link (issues #49, #88): `migrate adopt-prelink`.
|
|
7
|
+
*
|
|
8
|
+
* In adopt mode the users collection IS Better Auth's user table
|
|
9
|
+
* (`schemaMap.user`, `idStrategy: 'payload'`), so Better Auth's user id
|
|
10
|
+
* for a document is the document id. What adopt mode does NOT do by
|
|
11
|
+
* itself is link the plugin side: without an identity row, the first
|
|
12
|
+
* provider sign-in reaches JIT linking, which links an existing
|
|
13
|
+
* document only on the verified-email proof (`linkable === true` with
|
|
14
|
+
* `linkableEmail` equal to the document email).
|
|
15
|
+
*
|
|
16
|
+
* This command mirrors exactly what the importer writes on the host
|
|
17
|
+
* users (issue #69):
|
|
18
|
+
*
|
|
19
|
+
* - ONE `better-auth` identity per user, `providerUserId` = the
|
|
20
|
+
* document id (other providers' identity rows are kept);
|
|
21
|
+
* - `linkable: true` + `linkableEmail: <email>` for users whose
|
|
22
|
+
* verified-email field (`emailVerified` on payload-auth layouts) is
|
|
23
|
+
* `true` — grant-only, never revoked.
|
|
24
|
+
*
|
|
25
|
+
* Dry run by default. `--apply` records every prior value in a manifest
|
|
26
|
+
* (first capture wins, sealed, mode 0600) so `rollback --manifest`
|
|
27
|
+
* restores the documents exactly, `updatedAt` included.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type PrelinkOptions = {
|
|
31
|
+
readonly payload: Payload
|
|
32
|
+
readonly apply: boolean
|
|
33
|
+
readonly manifest: ImportManifest | null
|
|
34
|
+
readonly manifestPath: string
|
|
35
|
+
/** Users collection slug. Defaults to `users`. */
|
|
36
|
+
readonly usersSlug?: string | undefined
|
|
37
|
+
/** The users' verified-email checkbox. Defaults to `emailVerified`. */
|
|
38
|
+
readonly verifiedField?: string | undefined
|
|
39
|
+
/** Email field. Defaults to `email`. */
|
|
40
|
+
readonly emailField?: string | undefined
|
|
41
|
+
readonly now?: () => Date
|
|
42
|
+
readonly log?: (message: string) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type PrelinkResult = {
|
|
46
|
+
readonly users: number
|
|
47
|
+
/** Documents that received (or would receive) the better-auth identity. */
|
|
48
|
+
readonly linked: number
|
|
49
|
+
/** Documents already carrying the exact identity. */
|
|
50
|
+
readonly alreadyLinked: number
|
|
51
|
+
/** Verified documents granted (or to be granted) the linking proof. */
|
|
52
|
+
readonly proofGranted: number
|
|
53
|
+
/** Users without a verified email: identity only, no linking proof. */
|
|
54
|
+
readonly unverified: number
|
|
55
|
+
readonly unmappable: readonly Unmappable[]
|
|
56
|
+
readonly fieldWrites: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type IdentityRow = {
|
|
60
|
+
readonly id?: unknown
|
|
61
|
+
readonly providerId?: unknown
|
|
62
|
+
readonly providerUserId?: unknown
|
|
63
|
+
readonly email?: unknown
|
|
64
|
+
readonly linkedAt?: unknown
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function identitiesOf(doc: Record<string, unknown>): IdentityRow[] {
|
|
68
|
+
const raw = doc['identities']
|
|
69
|
+
return Array.isArray(raw)
|
|
70
|
+
? raw.filter((row): row is IdentityRow => typeof row === 'object' && row !== null)
|
|
71
|
+
: []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function prelinkUsers(options: PrelinkOptions): Promise<PrelinkResult> {
|
|
75
|
+
const { payload, apply, log = () => {}, now = () => new Date() } = options
|
|
76
|
+
const usersSlug = options.usersSlug ?? 'users'
|
|
77
|
+
const verifiedField = options.verifiedField ?? 'emailVerified'
|
|
78
|
+
const emailField = options.emailField ?? 'email'
|
|
79
|
+
const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
|
|
80
|
+
const captured = capturedKeys(fieldWrites)
|
|
81
|
+
|
|
82
|
+
type Planned = {
|
|
83
|
+
readonly id: string | number
|
|
84
|
+
readonly email: string
|
|
85
|
+
readonly writeIdentity: boolean
|
|
86
|
+
readonly grantProof: boolean
|
|
87
|
+
readonly keep: IdentityRow[]
|
|
88
|
+
}
|
|
89
|
+
const planned: Planned[] = []
|
|
90
|
+
const unmappable: Unmappable[] = []
|
|
91
|
+
let users = 0
|
|
92
|
+
let alreadyLinked = 0
|
|
93
|
+
let unverified = 0
|
|
94
|
+
|
|
95
|
+
// Plan the WHOLE collection before any write: a conflicting identity
|
|
96
|
+
// anywhere aborts the run with nothing written.
|
|
97
|
+
for (let page = 1; ; page += 1) {
|
|
98
|
+
const batch = await payload.find({
|
|
99
|
+
collection: usersSlug,
|
|
100
|
+
depth: 0,
|
|
101
|
+
limit: 200,
|
|
102
|
+
page,
|
|
103
|
+
sort: 'id',
|
|
104
|
+
overrideAccess: true,
|
|
105
|
+
})
|
|
106
|
+
for (const doc of batch.docs as unknown as Record<string, unknown>[]) {
|
|
107
|
+
users += 1
|
|
108
|
+
const id = doc['id']
|
|
109
|
+
const email = doc[emailField]
|
|
110
|
+
if ((typeof id !== 'string' && typeof id !== 'number') || typeof email !== 'string') {
|
|
111
|
+
unmappable.push({
|
|
112
|
+
model: 'user',
|
|
113
|
+
key: String(id),
|
|
114
|
+
reason: `document has no usable id/'${emailField}'`,
|
|
115
|
+
})
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const providerUserId = String(id)
|
|
119
|
+
const identities = identitiesOf(doc)
|
|
120
|
+
const own = identities.filter((row) => row.providerId === 'better-auth')
|
|
121
|
+
const conflicting = own.filter((row) => String(row.providerUserId) !== providerUserId)
|
|
122
|
+
if (conflicting.length > 0) {
|
|
123
|
+
unmappable.push({
|
|
124
|
+
model: 'user',
|
|
125
|
+
key: providerUserId,
|
|
126
|
+
reason: `user #${providerUserId} already carries a better-auth identity for a different provider user id; adopt mode serves the document id as the Better Auth user id — resolve the link by hand first`,
|
|
127
|
+
})
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const verified = doc[verifiedField] === true
|
|
131
|
+
if (!verified) {
|
|
132
|
+
unverified += 1
|
|
133
|
+
}
|
|
134
|
+
const writeIdentity = own.length !== 1
|
|
135
|
+
const grantProof = verified && (doc['linkable'] !== true || doc['linkableEmail'] !== email)
|
|
136
|
+
if (!writeIdentity) {
|
|
137
|
+
alreadyLinked += 1
|
|
138
|
+
}
|
|
139
|
+
if (writeIdentity || grantProof) {
|
|
140
|
+
planned.push({
|
|
141
|
+
id,
|
|
142
|
+
email,
|
|
143
|
+
writeIdentity,
|
|
144
|
+
grantProof,
|
|
145
|
+
keep: identities.filter((row) => row.providerId !== 'better-auth'),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!batch.hasNextPage) {
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const linked = planned.filter((row) => row.writeIdentity).length
|
|
155
|
+
const proofGranted = planned.filter((row) => row.grantProof).length
|
|
156
|
+
if (unmappable.length > 0) {
|
|
157
|
+
log(`ABORT: ${unmappable.length} unmappable user(s); nothing was written`)
|
|
158
|
+
return {
|
|
159
|
+
users,
|
|
160
|
+
linked: 0,
|
|
161
|
+
alreadyLinked,
|
|
162
|
+
proofGranted: 0,
|
|
163
|
+
unverified,
|
|
164
|
+
unmappable,
|
|
165
|
+
fieldWrites: 0,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let fieldWriteCount = 0
|
|
170
|
+
if (apply) {
|
|
171
|
+
for (const row of planned) {
|
|
172
|
+
const { newlyCaptured } = await captureBefore({
|
|
173
|
+
payload,
|
|
174
|
+
collection: usersSlug,
|
|
175
|
+
id: row.id,
|
|
176
|
+
fields: ['identities', 'linkable', 'linkableEmail'],
|
|
177
|
+
captured,
|
|
178
|
+
into: fieldWrites,
|
|
179
|
+
})
|
|
180
|
+
fieldWriteCount += newlyCaptured
|
|
181
|
+
const data: Record<string, unknown> = {}
|
|
182
|
+
if (row.writeIdentity) {
|
|
183
|
+
data.identities = [
|
|
184
|
+
...row.keep.map((identity) => ({
|
|
185
|
+
providerId: identity.providerId,
|
|
186
|
+
providerUserId: identity.providerUserId,
|
|
187
|
+
email: identity.email,
|
|
188
|
+
linkedAt: identity.linkedAt,
|
|
189
|
+
})),
|
|
190
|
+
{
|
|
191
|
+
providerId: 'better-auth',
|
|
192
|
+
providerUserId: String(row.id),
|
|
193
|
+
email: row.email,
|
|
194
|
+
linkedAt: now().toISOString(),
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
if (row.grantProof) {
|
|
199
|
+
data.linkable = true
|
|
200
|
+
data.linkableEmail = row.email
|
|
201
|
+
}
|
|
202
|
+
await payload.update({ collection: usersSlug, id: row.id, data, overrideAccess: true })
|
|
203
|
+
}
|
|
204
|
+
const manifest: ImportManifest = {
|
|
205
|
+
version: 2,
|
|
206
|
+
kind: 'adopt-prelink',
|
|
207
|
+
createdAt: options.manifest?.createdAt ?? now().toISOString(),
|
|
208
|
+
sourceDatabase: '(adopt mode: the target app database)',
|
|
209
|
+
options: { sessions: 'carry' },
|
|
210
|
+
created: [],
|
|
211
|
+
fieldWrites,
|
|
212
|
+
finalized: options.manifest?.finalized ?? false,
|
|
213
|
+
sourceCounts: {},
|
|
214
|
+
totals: { user: users, linked, proofGranted },
|
|
215
|
+
}
|
|
216
|
+
const { writeManifest } = await import('./lifecycle.js')
|
|
217
|
+
writeManifest(manifest, options.manifestPath)
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
users,
|
|
221
|
+
linked,
|
|
222
|
+
alreadyLinked,
|
|
223
|
+
proofGranted,
|
|
224
|
+
unverified,
|
|
225
|
+
unmappable,
|
|
226
|
+
fieldWrites: fieldWriteCount,
|
|
227
|
+
}
|
|
228
|
+
}
|