@crombolo/transfer 0.11.0 → 0.12.1

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.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * A bundle's people, applied (story 071): planned by key, then written in the
3
+ * content run's transaction and recorded on it, so a revert removes the people
4
+ * it created. docs/content-transfer.md, "People".
5
+ *
6
+ * The same key is the same person: updated, keeping a password set or
7
+ * re-hashed here. A different key whose e-mail or username is taken here is a
8
+ * decision — take the bundle's details onto the person here, or leave it out.
9
+ * Findings name people by key only.
10
+ */
11
+ import { normaliseUuid, ObjectTypes, SystemNodes } from '@crombolo/core'
12
+ import {
13
+ ContentTypeRepository,
14
+ currentSchemaState,
15
+ type Db,
16
+ type Finding,
17
+ MemberRepository,
18
+ NodeRepository,
19
+ type NodeSchemaState,
20
+ type TransferRunRepository,
21
+ UserGroupRepository,
22
+ UserRepository,
23
+ } from '@crombolo/data'
24
+ import type { NodeResolutions, Resolution } from './check.ts'
25
+ import type { BundleMember, BundlePeople, BundleUser } from './people.ts'
26
+
27
+ export type PersonKind = 'member-group' | 'member' | 'user-group' | 'user'
28
+
29
+ export const PERSON_KINDS: readonly PersonKind[] = ['member-group', 'member', 'user-group', 'user']
30
+
31
+ export const isPersonKind = (kind: string): kind is PersonKind =>
32
+ (PERSON_KINDS as readonly string[]).includes(kind)
33
+
34
+ /** What the import would do to one person or group. */
35
+ export interface PersonPlan {
36
+ kind: PersonKind
37
+ /** The bundle's key. */
38
+ key: string
39
+ action: 'create' | 'update' | 'unchanged' | 'skip'
40
+ /** Where it lands here, when that is not its own key: matched by name, alias or answer. */
41
+ localKey?: string
42
+ /** The bundle's password replaces one set here: a new site's seeded administrator. */
43
+ takePassword?: boolean
44
+ }
45
+
46
+ export interface PeoplePlan {
47
+ findings: Finding[]
48
+ plan: PersonPlan[]
49
+ counts: Record<PersonKind, { create: number; update: number; skip: number }>
50
+ }
51
+
52
+ /** A hash set or re-hashed here, which an incoming one never replaces. */
53
+ const isLocalHash = (hash: string | null | undefined): boolean =>
54
+ typeof hash === 'string' && hash.startsWith('$argon2')
55
+
56
+ const NOUN: Record<PersonKind, string> = {
57
+ 'member-group': 'member group',
58
+ member: 'member',
59
+ 'user-group': 'user group',
60
+ user: 'user',
61
+ }
62
+
63
+ const finding = (
64
+ kind: Finding['kind'],
65
+ code: string,
66
+ subject: PersonKind,
67
+ key: string,
68
+ message: string,
69
+ ): Finding => ({
70
+ kind,
71
+ code,
72
+ subjectType: subject,
73
+ subjectKey: key,
74
+ subjectName: key,
75
+ propertyAlias: null,
76
+ culture: null,
77
+ message,
78
+ link: null,
79
+ })
80
+
81
+ const answerOf = (
82
+ resolutions: Readonly<Record<string, NodeResolutions>>,
83
+ key: string,
84
+ all: Resolution | undefined,
85
+ ): Resolution | undefined => resolutions[key]?.['*'] ?? all
86
+
87
+ /** Read-only: what applying these people here would do, and what needs a decision. */
88
+ export async function planPeople(
89
+ db: Db,
90
+ people: BundlePeople,
91
+ options: {
92
+ resolutions?: Readonly<Record<string, NodeResolutions>>
93
+ resolveAll?: Resolution | undefined
94
+ /** A user here the bundle's may take over without asking: a new site's seeded administrator. */
95
+ adoptUsers?: boolean
96
+ } = {},
97
+ ): Promise<PeoplePlan> {
98
+ const resolutions = options.resolutions ?? {}
99
+ const findings: Finding[] = []
100
+ const plan: PersonPlan[] = []
101
+ const nodes = new NodeRepository(db)
102
+ const members = new MemberRepository(db)
103
+ const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
104
+ const users = new UserRepository(db)
105
+ const userGroups = new UserGroupRepository(db)
106
+
107
+ for (const group of people.memberGroups) {
108
+ const here = await nodes.byKey(group.key)
109
+ if (here && here.objectType === ObjectTypes.MemberGroup) {
110
+ plan.push({
111
+ kind: 'member-group',
112
+ key: group.key,
113
+ action: here.text === group.name ? 'unchanged' : 'update',
114
+ })
115
+ continue
116
+ }
117
+ const named = await memberGroupNamed(db, group.name)
118
+ plan.push(
119
+ named
120
+ ? { kind: 'member-group', key: group.key, action: 'unchanged', localKey: named }
121
+ : { kind: 'member-group', key: group.key, action: 'create' },
122
+ )
123
+ }
124
+
125
+ for (const group of people.userGroups) {
126
+ const here = (await userGroups.byKey(group.key)) ?? (await userGroups.byAlias(group.alias))
127
+ // A group here is this site's to manage: its permissions are not rewritten
128
+ // by a bundle, only its members added.
129
+ plan.push(
130
+ here
131
+ ? { kind: 'user-group', key: group.key, action: 'unchanged', localKey: here.key }
132
+ : { kind: 'user-group', key: group.key, action: 'create' },
133
+ )
134
+ }
135
+
136
+ const decide = (kind: PersonKind, key: string, clashKey: string): PersonPlan | undefined => {
137
+ const answer = answerOf(resolutions, key, options.resolveAll)
138
+ if (answer === 'skip') return { kind, key, action: 'skip' }
139
+ if (answer === 'take-bundle') return { kind, key, action: 'update', localKey: clashKey }
140
+ findings.push(
141
+ finding(
142
+ 'person',
143
+ 'person-conflict',
144
+ kind,
145
+ key,
146
+ `the ${NOUN[kind]} ${key} in the bundle has an e-mail address or username that ${NOUN[kind]} ${clashKey} already has here`,
147
+ ),
148
+ )
149
+ return undefined
150
+ }
151
+
152
+ const missingTypes = new Set<string>()
153
+ for (const member of people.members) {
154
+ if (!(await memberTypes.byAlias(member.memberTypeAlias)))
155
+ missingTypes.add(member.memberTypeAlias)
156
+ const here = await members.byKey(member.key)
157
+ if (here) {
158
+ plan.push({ kind: 'member', key: member.key, action: 'update' })
159
+ continue
160
+ }
161
+ const clash =
162
+ (await memberKeyWhere(db, 'email', member.email)) ??
163
+ (await memberKeyWhere(db, 'login_name', member.username))
164
+ if (!clash) {
165
+ plan.push({ kind: 'member', key: member.key, action: 'create' })
166
+ continue
167
+ }
168
+ const decided = decide('member', member.key, clash)
169
+ if (decided) plan.push(decided)
170
+ }
171
+ for (const alias of missingTypes)
172
+ findings.push(
173
+ finding(
174
+ 'blocking',
175
+ 'missing-member-type',
176
+ 'member',
177
+ alias,
178
+ `no member type "${alias}" here — deploy schema/ before importing these members`,
179
+ ),
180
+ )
181
+
182
+ for (const user of people.users) {
183
+ if (await users.byKey(user.key)) {
184
+ plan.push({
185
+ kind: 'user',
186
+ key: user.key,
187
+ action: 'update',
188
+ ...(options.adoptUsers ? { takePassword: true } : {}),
189
+ })
190
+ continue
191
+ }
192
+ const clash = (await users.byUserName(user.userName)) ?? (await users.byEmail(user.email))
193
+ if (!clash) {
194
+ plan.push({ kind: 'user', key: user.key, action: 'create' })
195
+ continue
196
+ }
197
+ if (options.adoptUsers) {
198
+ plan.push({
199
+ kind: 'user',
200
+ key: user.key,
201
+ action: 'update',
202
+ localKey: clash.key,
203
+ takePassword: true,
204
+ })
205
+ findings.push(
206
+ finding(
207
+ 'auto',
208
+ 'user-adopted',
209
+ 'user',
210
+ user.key,
211
+ `user ${clash.key} here becomes the bundle's user ${user.key}, with the password it had there`,
212
+ ),
213
+ )
214
+ continue
215
+ }
216
+ const decided = decide('user', user.key, clash.key)
217
+ if (decided) plan.push(decided)
218
+ }
219
+
220
+ const counts = Object.fromEntries(
221
+ PERSON_KINDS.map((kind) => [kind, { create: 0, update: 0, skip: 0 }]),
222
+ ) as PeoplePlan['counts']
223
+ for (const entry of plan) if (entry.action !== 'unchanged') counts[entry.kind][entry.action] += 1
224
+ for (const kind of PERSON_KINDS) {
225
+ const c = counts[kind]
226
+ if (c.create + c.update > 0)
227
+ findings.push(
228
+ finding(
229
+ 'auto',
230
+ 'people',
231
+ kind,
232
+ kind,
233
+ `${c.create} ${NOUN[kind]}(s) created, ${c.update} updated${c.skip > 0 ? `, ${c.skip} left out` : ''}`,
234
+ ),
235
+ )
236
+ }
237
+ return { findings, plan, counts }
238
+ }
239
+
240
+ async function memberGroupNamed(db: Db, name: string): Promise<string | undefined> {
241
+ const rows = await db.query<{ unique_id: string }>(
242
+ 'SELECT unique_id FROM node WHERE node_object_type = ? AND text = ?',
243
+ [ObjectTypes.MemberGroup, name],
244
+ )
245
+ return rows[0] ? normaliseUuid(String(rows[0].unique_id)) : undefined
246
+ }
247
+
248
+ async function memberKeyWhere(
249
+ db: Db,
250
+ column: 'email' | 'login_name',
251
+ value: string,
252
+ ): Promise<string | undefined> {
253
+ const rows = await db.query<{ unique_id: string }>(
254
+ `SELECT n.unique_id FROM member m JOIN node n ON n.id = m.node_id WHERE LOWER(m.${column}) = LOWER(?)`,
255
+ [value],
256
+ )
257
+ return rows[0] ? normaliseUuid(String(rows[0].unique_id)) : undefined
258
+ }
259
+
260
+ /** What one applied person was before: enough for a revert to remove what it created. */
261
+ export interface AppliedPerson {
262
+ kind: PersonKind
263
+ key: string
264
+ action: 'create' | 'update'
265
+ }
266
+
267
+ /** Writes the plan, inside the caller's transaction, and records each on the run. */
268
+ export async function applyPeople(
269
+ tx: Db,
270
+ people: BundlePeople,
271
+ plan: readonly PersonPlan[],
272
+ run: { id: string; runs: TransferRunRepository; nodeState: NodeSchemaState; nodeId?: string },
273
+ ): Promise<AppliedPerson[]> {
274
+ const applied: AppliedPerson[] = []
275
+ const planFor = new Map(plan.map((p) => [`${p.kind}|${p.key}`, p]))
276
+ const nodes = new NodeRepository(tx)
277
+ const record = async (kind: PersonKind, key: string, action: 'create' | 'update') => {
278
+ applied.push({ kind, key, action })
279
+ await run.runs.record({
280
+ runId: run.id,
281
+ nodeKey: key,
282
+ kind,
283
+ action,
284
+ beforeEventId: null,
285
+ beforePublishedEventId: null,
286
+ eventId: null,
287
+ })
288
+ }
289
+
290
+ // Member groups first: memberships name them.
291
+ const memberGroupKey = new Map<string, string>()
292
+ for (const group of people.memberGroups) {
293
+ const p = planFor.get(`member-group|${group.key}`)
294
+ if (!p || p.action === 'skip') continue
295
+ memberGroupKey.set(group.key, p.localKey ?? group.key)
296
+ if (p.action === 'create') {
297
+ await nodes.create({
298
+ key: group.key,
299
+ parentId: SystemNodes.Root,
300
+ objectType: ObjectTypes.MemberGroup,
301
+ text: group.name,
302
+ })
303
+ await record('member-group', group.key, 'create')
304
+ } else if (p.action === 'update') {
305
+ const here = await nodes.byKey(group.key)
306
+ if (here) await nodes.rename(here.id, group.name)
307
+ await record('member-group', group.key, 'update')
308
+ }
309
+ }
310
+
311
+ const userGroups = new UserGroupRepository(tx)
312
+ const userGroupKey = new Map<string, string>()
313
+ for (const group of people.userGroups) {
314
+ const p = planFor.get(`user-group|${group.key}`)
315
+ if (!p || p.action === 'skip') continue
316
+ userGroupKey.set(group.alias, p.localKey ?? group.key)
317
+ if (p.action !== 'create') continue
318
+ const starts = async (key: string | null) =>
319
+ key && (await nodes.byKey(key)) ? { root: false, key } : { root: false, key: null }
320
+ const saved = await userGroups.save({
321
+ key: group.key,
322
+ alias: group.alias,
323
+ name: group.name,
324
+ description: null,
325
+ icon: group.icon,
326
+ sections: group.sections,
327
+ languages: [],
328
+ hasAccessToAllLanguages: group.hasAccessToAllLanguages || group.languages.length > 0,
329
+ startNodes: {
330
+ document: await starts(group.documentStartNodeKey),
331
+ media: await starts(group.mediaStartNodeKey),
332
+ element: { root: false, key: null },
333
+ },
334
+ permissions: group.permissions,
335
+ granular: [],
336
+ })
337
+ if (saved !== 'saved') throw new Error(`The user group "${group.alias}" could not be created.`)
338
+ await record('user-group', group.key, 'create')
339
+ }
340
+
341
+ const members = new MemberRepository(tx, { nodeState: run.nodeState, nodeId: run.nodeId })
342
+ const memberTypes = new ContentTypeRepository(tx, { kind: 'member' })
343
+ for (const member of people.members) {
344
+ const p = planFor.get(`member|${member.key}`)
345
+ if (!p || p.action === 'skip' || p.action === 'unchanged') continue
346
+ const type = await memberTypes.byAlias(member.memberTypeAlias)
347
+ if (!type)
348
+ throw new Error(
349
+ `No member type "${member.memberTypeAlias}", which the check should have refused.`,
350
+ )
351
+ const groupKeys = member.groupKeys.flatMap((k) => memberGroupKey.get(k) ?? [])
352
+ const target = p.localKey ?? member.key
353
+ const here = p.action === 'update' ? await members.byKey(target) : undefined
354
+ const content = memberContent(
355
+ member,
356
+ type.key,
357
+ target,
358
+ here ? await currentValues(members, target) : [],
359
+ )
360
+ const identity = {
361
+ email: member.email,
362
+ username: member.username,
363
+ isApproved: member.isApproved,
364
+ isLockedOut: member.isLockedOut,
365
+ groupKeys,
366
+ ...(here && isLocalHash(here.passwordHash)
367
+ ? {}
368
+ : { passwordHash: member.passwordHash, passwordConfig: member.passwordConfig }),
369
+ }
370
+ if (here) {
371
+ await members.update(target, content, identity)
372
+ await record('member', target, 'update')
373
+ } else {
374
+ await members.create(content, identity)
375
+ await record('member', member.key, 'create')
376
+ }
377
+ }
378
+
379
+ const users = new UserRepository(tx)
380
+ for (const user of people.users) {
381
+ const p = planFor.get(`user|${user.key}`)
382
+ if (!p || p.action === 'skip' || p.action === 'unchanged') continue
383
+ const groupKeys = user.groupAliases.flatMap((alias) => userGroupKey.get(alias) ?? [])
384
+ const target = p.localKey ?? user.key
385
+ const here = p.action === 'update' ? await users.byKey(target) : undefined
386
+ if (here) {
387
+ const outcome = await users.update(target, {
388
+ name: user.name,
389
+ userName: user.userName,
390
+ email: user.email,
391
+ languageIsoCode: user.languageIsoCode,
392
+ groupKeys: [...new Set([...here.groupKeys, ...groupKeys])],
393
+ startNodes: here.startNodes,
394
+ })
395
+ if (outcome !== 'updated')
396
+ throw new Error(`The user ${target} could not be updated (${outcome}).`)
397
+ if (p.takePassword || !isLocalHash(await users.passwordHash(target)))
398
+ await setUserPassword(tx, target, user)
399
+ await record('user', target, 'update')
400
+ } else {
401
+ const created = await users.create({
402
+ key: user.key,
403
+ name: user.name,
404
+ userName: user.userName,
405
+ email: user.email,
406
+ kind: 'Default',
407
+ groupKeys,
408
+ languageIsoCode: user.languageIsoCode,
409
+ })
410
+ if (!created) throw new Error(`The user ${user.key} could not be created.`)
411
+ await setUserPassword(tx, user.key, user)
412
+ await record('user', user.key, 'create')
413
+ }
414
+ await users.setApproved([target], !user.disabled)
415
+ }
416
+ return applied
417
+ }
418
+
419
+ async function setUserPassword(tx: Db, key: string, user: BundleUser): Promise<void> {
420
+ await tx.exec('UPDATE user_account SET password_hash = ?, password_config = ? WHERE key = ?', [
421
+ user.passwordHash,
422
+ user.passwordConfig,
423
+ normaliseUuid(key),
424
+ ])
425
+ }
426
+
427
+ async function currentValues(members: MemberRepository, key: string) {
428
+ return (await members.content.byKey(key))?.values ?? []
429
+ }
430
+
431
+ /** The member's content: the bundle's values over what is here. Pure. */
432
+ function memberContent(
433
+ member: BundleMember,
434
+ contentTypeKey: string,
435
+ key: string,
436
+ current: ReadonlyArray<{
437
+ alias: string
438
+ culture: string | null
439
+ segment: string | null
440
+ value: unknown
441
+ }>,
442
+ ) {
443
+ const at = (alias: string, culture: string | null, segment: string | null) =>
444
+ `${alias}|${culture ?? ''}|${segment ?? ''}`
445
+ const merged = new Map(current.map((v) => [at(v.alias, v.culture, v.segment), v]))
446
+ for (const v of member.values)
447
+ merged.set(at(v.property, v.culture, v.segment), {
448
+ alias: v.property,
449
+ culture: v.culture,
450
+ segment: v.segment,
451
+ value: v.value,
452
+ })
453
+ return {
454
+ key,
455
+ contentTypeKey,
456
+ componentKey: null,
457
+ parentKey: null,
458
+ values: [...merged.values()],
459
+ variants: [{ culture: null, segment: null, name: member.name }],
460
+ }
461
+ }
462
+
463
+ /** Removes what a run created, for a revert: members, users and groups. */
464
+ export async function removePerson(tx: Db, kind: PersonKind, key: string): Promise<boolean> {
465
+ switch (kind) {
466
+ case 'member':
467
+ return new MemberRepository(tx).delete(key)
468
+ case 'user': {
469
+ const users = new UserRepository(tx)
470
+ if (!(await users.byKey(key))) return false
471
+ await users.delete(key)
472
+ return true
473
+ }
474
+ case 'member-group': {
475
+ const nodes = new NodeRepository(tx)
476
+ const node = await nodes.byKey(key)
477
+ if (!node || node.objectType !== ObjectTypes.MemberGroup) return false
478
+ await tx.exec('DELETE FROM member_group_member WHERE member_group_id = ?', [node.id])
479
+ await tx.exec('DELETE FROM node WHERE id = ?', [node.id])
480
+ return true
481
+ }
482
+ case 'user-group': {
483
+ const groups = new UserGroupRepository(tx)
484
+ if (!(await groups.byKey(key))) return false
485
+ return groups.delete(key).then(() => true)
486
+ }
487
+ }
488
+ }
489
+
490
+ /** This site's people, for a bundle: members and their groups, and users and theirs when asked. */
491
+ export async function exportPeople(
492
+ db: Db,
493
+ options: { members: boolean; users: boolean },
494
+ ): Promise<BundlePeople> {
495
+ const people: BundlePeople = { memberGroups: [], members: [], userGroups: [], users: [] }
496
+ const state = await currentSchemaState(db)
497
+ if (options.members) {
498
+ const groups = await db.query<{ unique_id: string; text: string }>(
499
+ 'SELECT unique_id, text FROM node WHERE node_object_type = ? ORDER BY text',
500
+ [ObjectTypes.MemberGroup],
501
+ )
502
+ people.memberGroups = groups.map((g) => ({
503
+ key: normaliseUuid(String(g.unique_id)),
504
+ name: String(g.text),
505
+ }))
506
+ const members = new MemberRepository(db, {
507
+ nodeState: { version: state.version, revision: state.revision },
508
+ })
509
+ for (let skip = 0; ; skip += 500) {
510
+ const page = await members.filter({ skip, take: 500 })
511
+ for (const member of page.items) {
512
+ const content = await members.content.byKey(member.key)
513
+ people.members.push({
514
+ key: member.key,
515
+ name: member.name,
516
+ memberTypeAlias: member.contentTypeAlias,
517
+ email: member.email,
518
+ username: member.username,
519
+ isApproved: member.isApproved,
520
+ isLockedOut: member.isLockedOut,
521
+ passwordHash: member.passwordHash,
522
+ passwordConfig: member.passwordConfig,
523
+ groupKeys: await members.groupKeys(member.key),
524
+ values: (content?.values ?? []).map((v) => ({
525
+ property: v.alias,
526
+ culture: v.culture,
527
+ segment: v.segment,
528
+ editor: v.editorAlias ?? '',
529
+ value: v.value,
530
+ })),
531
+ })
532
+ }
533
+ if (skip + 500 >= page.total) break
534
+ }
535
+ }
536
+ if (options.users) {
537
+ const groups = await new UserGroupRepository(db).all()
538
+ people.userGroups = groups.map((g) => ({
539
+ key: g.key,
540
+ alias: g.alias,
541
+ name: g.name,
542
+ icon: g.icon,
543
+ sections: g.sections,
544
+ permissions: g.permissions,
545
+ hasAccessToAllLanguages: g.hasAccessToAllLanguages,
546
+ languages: g.languages,
547
+ documentStartNodeKey: g.startNodes.document.key,
548
+ mediaStartNodeKey: g.startNodes.media.key,
549
+ }))
550
+ const aliasOf = new Map(groups.map((g) => [g.key, g.alias]))
551
+ const users = new UserRepository(db)
552
+ for (const user of await users.all()) {
553
+ if (user.kind !== 'Default') continue
554
+ const [row] = await db.query<{
555
+ password_hash: string | null
556
+ password_config: string | null
557
+ }>('SELECT password_hash, password_config FROM user_account WHERE id = ?', [user.id])
558
+ people.users.push({
559
+ key: user.key,
560
+ name: user.name,
561
+ userName: user.userName,
562
+ email: user.email,
563
+ languageIsoCode: user.languageIsoCode,
564
+ disabled: !user.isApproved,
565
+ passwordHash: row?.password_hash ?? null,
566
+ passwordConfig: row?.password_config ?? null,
567
+ groupAliases: user.groupKeys.flatMap((k) => aliasOf.get(k) ?? []),
568
+ })
569
+ }
570
+ }
571
+ return people
572
+ }