@crombolo/transfer 0.10.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.
package/src/people.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A bundle's people (story 071): member groups, members, user groups and
3
+ * users, in the bundle's `people/` directory — always sealed (`seal.ts`). The
4
+ * manifest says how many of each, never who.
5
+ * docs/content-transfer.md, "People".
6
+ */
7
+ import type { BundleValue } from './model.ts'
8
+ import { openBytes, type Seal, sealBytes } from './seal.ts'
9
+
10
+ export interface BundleMemberGroup {
11
+ key: string
12
+ name: string
13
+ }
14
+
15
+ export interface BundleMember {
16
+ key: string
17
+ name: string
18
+ memberTypeAlias: string
19
+ email: string
20
+ username: string
21
+ isApproved: boolean
22
+ isLockedOut: boolean
23
+ /** As the source stored it, with its algorithm marker; null for none. */
24
+ passwordHash: string | null
25
+ passwordConfig: string | null
26
+ groupKeys: string[]
27
+ values: BundleValue[]
28
+ }
29
+
30
+ export interface BundleUserGroup {
31
+ key: string
32
+ alias: string
33
+ name: string
34
+ icon: string | null
35
+ sections: string[]
36
+ /** Default permission verbs, e.g. `Umb.Document.Read`. */
37
+ permissions: string[]
38
+ hasAccessToAllLanguages: boolean
39
+ languages: string[]
40
+ documentStartNodeKey: string | null
41
+ mediaStartNodeKey: string | null
42
+ }
43
+
44
+ export interface BundleUser {
45
+ key: string
46
+ name: string
47
+ userName: string
48
+ email: string
49
+ languageIsoCode: string | null
50
+ disabled: boolean
51
+ passwordHash: string | null
52
+ passwordConfig: string | null
53
+ groupAliases: string[]
54
+ }
55
+
56
+ export interface BundlePeople {
57
+ memberGroups: BundleMemberGroup[]
58
+ members: BundleMember[]
59
+ userGroups: BundleUserGroup[]
60
+ users: BundleUser[]
61
+ }
62
+
63
+ export const EMPTY_PEOPLE: BundlePeople = {
64
+ memberGroups: [],
65
+ members: [],
66
+ userGroups: [],
67
+ users: [],
68
+ }
69
+
70
+ /** What the manifest says of a bundle's people: counts, never who. */
71
+ export interface PeopleBlock {
72
+ counts: { memberGroups: number; members: number; userGroups: number; users: number }
73
+ /** Every sealed file, in path order. */
74
+ paths: string[]
75
+ }
76
+
77
+ export const PEOPLE_DIR = 'people'
78
+
79
+ const MEMBER_GROUPS = `${PEOPLE_DIR}/member-groups.json`
80
+ const USER_GROUPS = `${PEOPLE_DIR}/user-groups.json`
81
+ const memberPath = (key: string) => `${PEOPLE_DIR}/members/${key}.json`
82
+ const userPath = (key: string) => `${PEOPLE_DIR}/users/${key}.json`
83
+
84
+ const json = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`)
85
+
86
+ /** People sealed under the bundle's seal: the files, and the manifest's block. */
87
+ export function sealPeople(
88
+ people: BundlePeople,
89
+ { key }: Seal,
90
+ ): { files: Array<{ path: string; bytes: Uint8Array }>; block: PeopleBlock } {
91
+ const plain: Array<{ path: string; bytes: Buffer }> = [
92
+ { path: MEMBER_GROUPS, bytes: json(people.memberGroups) },
93
+ { path: USER_GROUPS, bytes: json(people.userGroups) },
94
+ ...people.members.map((m) => ({ path: memberPath(m.key), bytes: json(m) })),
95
+ ...people.users.map((u) => ({ path: userPath(u.key), bytes: json(u) })),
96
+ ].sort((a, b) => a.path.localeCompare(b.path))
97
+ const files = plain.map((file) => ({
98
+ path: file.path,
99
+ bytes: sealBytes(key, file.path, file.bytes),
100
+ }))
101
+ return {
102
+ files,
103
+ block: {
104
+ counts: {
105
+ memberGroups: people.memberGroups.length,
106
+ members: people.members.length,
107
+ userGroups: people.userGroups.length,
108
+ users: people.users.length,
109
+ },
110
+ paths: files.map((f) => f.path),
111
+ },
112
+ }
113
+ }
114
+
115
+ /** A bundle's people, opened with its key; `SealedFileError` for a file altered or moved. */
116
+ export function openPeople(
117
+ block: PeopleBlock,
118
+ files: ReadonlyMap<string, Uint8Array>,
119
+ key: Buffer,
120
+ ): BundlePeople {
121
+ const read = <T>(path: string): T => {
122
+ const sealed = files.get(path)
123
+ if (!sealed) throw new Error(`${path} is missing from the bundle's people.`)
124
+ return JSON.parse(openBytes(key, path, sealed).toString('utf8')) as T
125
+ }
126
+ const out: BundlePeople = {
127
+ memberGroups: block.paths.includes(MEMBER_GROUPS) ? read(MEMBER_GROUPS) : [],
128
+ userGroups: block.paths.includes(USER_GROUPS) ? read(USER_GROUPS) : [],
129
+ members: [],
130
+ users: [],
131
+ }
132
+ for (const path of block.paths) {
133
+ if (path.startsWith(`${PEOPLE_DIR}/members/`)) out.members.push(read<BundleMember>(path))
134
+ if (path.startsWith(`${PEOPLE_DIR}/users/`)) out.users.push(read<BundleUser>(path))
135
+ }
136
+ const counts = block.counts
137
+ if (
138
+ out.members.length !== counts.members ||
139
+ out.users.length !== counts.users ||
140
+ out.memberGroups.length !== counts.memberGroups ||
141
+ out.userGroups.length !== counts.userGroups
142
+ )
143
+ throw new Error("The bundle's people do not match the counts its manifest gives.")
144
+ return out
145
+ }
146
+
147
+ /** A manifest's people block read without trusting it: undefined when absent or malformed. Pure. */
148
+ export function parsePeopleBlock(raw: unknown): PeopleBlock | undefined {
149
+ if (!raw || typeof raw !== 'object') return undefined
150
+ const block = raw as Record<string, unknown>
151
+ const counts = block.counts as Record<string, unknown> | undefined
152
+ if (!counts || !Array.isArray(block.paths)) return undefined
153
+ const paths = (block.paths as unknown[]).filter(
154
+ (p): p is string =>
155
+ typeof p === 'string' && p.startsWith(`${PEOPLE_DIR}/`) && !p.split('/').includes('..'),
156
+ )
157
+ const count = (name: string) => (Number.isInteger(counts[name]) ? (counts[name] as number) : 0)
158
+ return {
159
+ counts: {
160
+ memberGroups: count('memberGroups'),
161
+ members: count('members'),
162
+ userGroups: count('userGroups'),
163
+ users: count('users'),
164
+ },
165
+ paths: paths.sort(),
166
+ }
167
+ }
@@ -7,11 +7,19 @@
7
7
  * again in production, each time against more content.
8
8
  *
9
9
  * It holds resolutions **by node key**, so a path that means something
10
- * different in the next environment cannot change what was decided.
10
+ * different in the next environment cannot change what was decided. Each node's
11
+ * answers are `"*"` for the whole node and `"<alias>"` or `"<alias>@<culture>"`
12
+ * for one property.
11
13
  */
12
14
  import { existsSync, readFileSync } from 'node:fs'
13
15
  import { join } from 'node:path'
14
- import { isResolution, type Resolution } from './check.ts'
16
+ import {
17
+ isResolution,
18
+ type NodeResolutions,
19
+ nodeResolutionProblems,
20
+ type Resolution,
21
+ WHOLE_NODE,
22
+ } from './check.ts'
15
23
 
16
24
  export const RESOLUTIONS_FILE = 'resolutions.json'
17
25
 
@@ -21,7 +29,48 @@ export interface ResolutionsFile {
21
29
  /** Where the bundle was placed, when it was re-rooted. */
22
30
  under?: string | null
23
31
  all?: Resolution | null
24
- byNode: Record<string, Resolution>
32
+ byNode: Record<string, NodeResolutions>
33
+ }
34
+
35
+ /**
36
+ * `--resolve` values into answers by node: `<key>=<choice>` answers the node,
37
+ * `<key>/<alias>[@<culture>]=<choice>` one property. Pure; problems are named.
38
+ */
39
+ export function parseResolveFlags(pairs: readonly string[]): {
40
+ byNode: Record<string, NodeResolutions>
41
+ problems: string[]
42
+ } {
43
+ const byNode: Record<string, Record<string, Resolution>> = {}
44
+ const problems: string[] = []
45
+ for (const pair of pairs) {
46
+ const eq = pair.lastIndexOf('=')
47
+ if (eq < 0) {
48
+ problems.push(`--resolve wants <key>=<choice> or <key>/<property>=<choice>, not "${pair}"`)
49
+ continue
50
+ }
51
+ const target = pair.slice(0, eq)
52
+ const choice = pair.slice(eq + 1)
53
+ const slash = target.indexOf('/')
54
+ const key = slash < 0 ? target : target.slice(0, slash)
55
+ const handle = slash < 0 ? WHOLE_NODE : target.slice(slash + 1)
56
+ const found = nodeResolutionProblems(key, { [handle]: choice })
57
+ if (found.length > 0 || !handle) {
58
+ problems.push(...(found.length > 0 ? found : [`--resolve "${pair}" names no property`]))
59
+ continue
60
+ }
61
+ byNode[key] = { ...(byNode[key] ?? {}), [handle]: choice as Resolution }
62
+ }
63
+ return { byNode, problems }
64
+ }
65
+
66
+ /** Answers laid over others, node by node and property by property. Pure. */
67
+ export function mergeResolutions(
68
+ under: Readonly<Record<string, NodeResolutions>>,
69
+ over: Readonly<Record<string, NodeResolutions>>,
70
+ ): Record<string, NodeResolutions> {
71
+ const out: Record<string, NodeResolutions> = { ...under }
72
+ for (const [key, answers] of Object.entries(over)) out[key] = { ...(out[key] ?? {}), ...answers }
73
+ return out
25
74
  }
26
75
 
27
76
  export function writeResolutions(file: ResolutionsFile): string {
@@ -31,7 +80,12 @@ export function writeResolutions(file: ResolutionsFile): string {
31
80
  under: file.under ?? null,
32
81
  all: file.all ?? null,
33
82
  byNode: Object.fromEntries(
34
- Object.entries(file.byNode).sort(([a], [b]) => a.localeCompare(b)),
83
+ Object.entries(file.byNode)
84
+ .sort(([a], [b]) => a.localeCompare(b))
85
+ .map(([key, answers]) => [
86
+ key,
87
+ Object.fromEntries(Object.entries(answers).sort(([a], [b]) => a.localeCompare(b))),
88
+ ]),
35
89
  ),
36
90
  },
37
91
  null,
@@ -68,11 +122,19 @@ export function loadResolutions(dir: string, bundleId: string): LoadedResolution
68
122
  if (found && found !== bundleId)
69
123
  problems.push(`${RESOLUTIONS_FILE}: answers bundle ${found}, not ${bundleId} — ignoring it`)
70
124
 
71
- const byNode: Record<string, Resolution> = {}
125
+ const byNode: Record<string, NodeResolutions> = {}
72
126
  const entries = typeof record.byNode === 'object' && record.byNode !== null ? record.byNode : {}
73
127
  for (const [key, value] of Object.entries(entries as Record<string, unknown>)) {
74
- if (typeof value === 'string' && isResolution(value)) byNode[key] = value
75
- else problems.push(`${RESOLUTIONS_FILE}: "${key}" has no usable resolution`)
128
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
129
+ problems.push(
130
+ `${RESOLUTIONS_FILE}: "${key}" must map "*" or a property to a resolution, as { "*": "take-bundle" }`,
131
+ )
132
+ continue
133
+ }
134
+ const found = nodeResolutionProblems(key, value as Record<string, unknown>)
135
+ if (found.length > 0)
136
+ problems.push(...found.map((message) => `${RESOLUTIONS_FILE}: ${message}`))
137
+ else byNode[key] = value as Record<string, Resolution>
76
138
  }
77
139
  const all = typeof record.all === 'string' && isResolution(record.all) ? record.all : null
78
140
 
package/src/revert.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type TransferRun,
28
28
  TransferRunRepository,
29
29
  } from '@crombolo/data'
30
+ import { isPersonKind, removePerson } from './people-apply.ts'
30
31
 
31
32
  /** What to do about a node whose state has moved on since the import. */
32
33
  export type RevertResolution = 'discard' | 'skip'
@@ -74,7 +75,7 @@ export interface RevertPlan {
74
75
  * run created, and `unrecycle` brings back what a *reverted* run recycled —
75
76
  * which is what makes a revert revertible in its own right.
76
77
  */
77
- action: 'restore' | 'recycle' | 'unrecycle' | 'skip' | 'gone'
78
+ action: 'restore' | 'recycle' | 'unrecycle' | 'skip' | 'gone' | 'remove'
78
79
  resolution: RevertResolution | undefined
79
80
  }
80
81
 
@@ -91,6 +92,8 @@ export interface RevertResult {
91
92
  check: RevertCheck
92
93
  restored: string[]
93
94
  recycled: string[]
95
+ /** Members, users and groups the run had created, now removed. */
96
+ removed: string[]
94
97
  }
95
98
 
96
99
  const link = (backOfficePath: string | undefined, key: string): string | null =>
@@ -154,6 +157,30 @@ export async function checkRevert(
154
157
 
155
158
  for (const change of changes) {
156
159
  if (change.action === 'skip') continue
160
+ // People have no history to roll back to: what the run created goes, and
161
+ // what it updated keeps the bundle's details.
162
+ if (isPersonKind(change.kind)) {
163
+ if (change.action === 'create')
164
+ plan.push({
165
+ key: change.nodeKey,
166
+ name: change.nodeKey,
167
+ action: 'remove',
168
+ resolution: undefined,
169
+ })
170
+ else
171
+ findings.push({
172
+ kind: 'auto',
173
+ code: 'person-kept',
174
+ subjectType: change.kind,
175
+ subjectKey: change.nodeKey,
176
+ subjectName: change.nodeKey,
177
+ propertyAlias: null,
178
+ culture: null,
179
+ message: `${change.kind} ${change.nodeKey} was updated by the run and keeps those details`,
180
+ link: null,
181
+ })
182
+ continue
183
+ }
157
184
  const resolution = resolutions[change.nodeKey] ?? options.resolveAll
158
185
  const node = await docs.nodes.byKey(change.nodeKey)
159
186
  const name = node?.text ?? change.nodeKey
@@ -261,7 +288,7 @@ export async function revertRun(
261
288
  db.transaction(async (tx) => {
262
289
  const check = await checkRevert(tx, runId, options)
263
290
  if (check.outstanding.length > 0)
264
- return { runId: undefined, check, restored: [], recycled: [] }
291
+ return { runId: undefined, check, restored: [], recycled: [], removed: [] }
265
292
 
266
293
  const runs = new TransferRunRepository(tx)
267
294
  const original = (await runs.byId(runId)) as TransferRun
@@ -288,7 +315,19 @@ export async function revertRun(
288
315
  const skipped = new Set(
289
316
  check.plan.filter((p) => p.action === 'skip' || p.action === 'gone').map((p) => p.key),
290
317
  )
291
- const todo = changes.filter((c) => c.action !== 'skip' && !skipped.has(c.nodeKey))
318
+ const todo = changes.filter(
319
+ (c) => c.action !== 'skip' && !skipped.has(c.nodeKey) && !isPersonKind(c.kind),
320
+ )
321
+ // Members before the groups they belong to.
322
+ const removed: string[] = []
323
+ const order = ['member', 'user', 'member-group', 'user-group']
324
+ for (const change of changes
325
+ .filter((c) => isPersonKind(c.kind) && c.action === 'create')
326
+ .sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind)))
327
+ if (
328
+ await removePerson(tx, change.kind as Parameters<typeof removePerson>[1], change.nodeKey)
329
+ )
330
+ removed.push(change.nodeKey)
292
331
  const byKey = new Map<string, NodeRow>()
293
332
  for (const change of todo) {
294
333
  const node = await repo(change.kind).nodes.byKey(change.nodeKey)
@@ -372,7 +411,7 @@ export async function revertRun(
372
411
  eventId: markers?.head ?? null,
373
412
  })
374
413
  }
375
- await runs.finish(newRunId, todo.length)
414
+ await runs.finish(newRunId, todo.length + removed.length)
376
415
  await runs.setStatus(runId, 'reverted')
377
416
 
378
417
  await appendCacheInstruction(tx, {
@@ -381,7 +420,7 @@ export async function revertRun(
381
420
  by: options.nodeId,
382
421
  })
383
422
 
384
- return { runId: newRunId, check, restored, recycled }
423
+ return { runId: newRunId, check, restored, recycled, removed }
385
424
  }),
386
425
  )
387
426
  }
package/src/seal.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Sealing what in a bundle can hold personal information (story 071):
3
+ * property values, media files, protected-page usernames and people. The key is stretched from a passphrase
4
+ * given at export and at install — never stored — with scrypt and a salt the
5
+ * bundle keeps; each file is AES-256-GCM with a nonce of its own and its path
6
+ * as additional data, so a file cannot be swapped for another's. A check value
7
+ * tells a wrong passphrase apart from a damaged file. docs/content-transfer.md,
8
+ * "People".
9
+ */
10
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
11
+
12
+ /** What the manifest keeps to derive the key again: never the key itself. */
13
+ export interface SealParameters {
14
+ kdf: 'scrypt'
15
+ /** scrypt's cost, block size and parallelism. */
16
+ n: number
17
+ r: number
18
+ p: number
19
+ /** Base64. */
20
+ salt: string
21
+ /** A known value sealed under the key, so a wrong passphrase is named as one. */
22
+ check: string
23
+ }
24
+
25
+ export const SEAL_MAGIC = Buffer.from('CRBSEAL1')
26
+ const CHECK_PATH = 'people/.check'
27
+ const CHECK_TEXT = 'crombolo people'
28
+
29
+ export class WrongPassphraseError extends Error {
30
+ constructor() {
31
+ super('The passphrase does not open this bundle’s people.')
32
+ }
33
+ }
34
+
35
+ export class SealedFileError extends Error {}
36
+
37
+ /** The key a passphrase gives under these parameters. */
38
+ export function deriveKey(
39
+ passphrase: string,
40
+ params: Pick<SealParameters, 'n' | 'r' | 'p' | 'salt'>,
41
+ ): Buffer {
42
+ if (!passphrase) throw new WrongPassphraseError()
43
+ return scryptSync(passphrase.normalize('NFC'), Buffer.from(params.salt, 'base64'), 32, {
44
+ N: params.n,
45
+ r: params.r,
46
+ p: params.p,
47
+ maxmem: 256 * params.n * params.r + 1024 * 1024,
48
+ })
49
+ }
50
+
51
+ /** One file sealed: magic, nonce, ciphertext, tag. `nonce` is injectable for tests. */
52
+ export function sealBytes(
53
+ key: Buffer,
54
+ path: string,
55
+ plain: Uint8Array,
56
+ nonce = randomBytes(12),
57
+ ): Buffer {
58
+ const cipher = createCipheriv('aes-256-gcm', key, nonce)
59
+ cipher.setAAD(Buffer.from(path))
60
+ const body = Buffer.concat([cipher.update(plain), cipher.final()])
61
+ return Buffer.concat([SEAL_MAGIC, nonce, body, cipher.getAuthTag()])
62
+ }
63
+
64
+ /** One file opened; throws when it was altered, moved or is not sealed. */
65
+ export function openBytes(key: Buffer, path: string, sealed: Uint8Array): Buffer {
66
+ const bytes = Buffer.from(sealed)
67
+ if (bytes.length < SEAL_MAGIC.length + 12 + 16 || !bytes.subarray(0, 8).equals(SEAL_MAGIC))
68
+ throw new SealedFileError(`${path} is not a sealed file.`)
69
+ const nonce = bytes.subarray(8, 20)
70
+ const tag = bytes.subarray(bytes.length - 16)
71
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce)
72
+ decipher.setAAD(Buffer.from(path))
73
+ decipher.setAuthTag(tag)
74
+ try {
75
+ return Buffer.concat([decipher.update(bytes.subarray(20, bytes.length - 16)), decipher.final()])
76
+ } catch {
77
+ throw new SealedFileError(`${path} was altered, or belongs to another file.`)
78
+ }
79
+ }
80
+
81
+ /** The parameters a bundle keeps, and the key they give. */
82
+ export interface Seal {
83
+ params: SealParameters
84
+ key: Buffer
85
+ }
86
+
87
+ /** Fresh parameters and the key they give: what an export starts with. */
88
+ export function newSeal(
89
+ passphrase: string,
90
+ cost: Pick<SealParameters, 'n' | 'r' | 'p'> = { n: 2 ** 17, r: 8, p: 1 },
91
+ ): Seal {
92
+ const salt = randomBytes(16).toString('base64')
93
+ const key = deriveKey(passphrase, { ...cost, salt })
94
+ const check = sealBytes(key, CHECK_PATH, Buffer.from(CHECK_TEXT)).toString('base64')
95
+ return { params: { kdf: 'scrypt', ...cost, salt, check }, key }
96
+ }
97
+
98
+ /** The key for a bundle's people, or a refusal naming a wrong passphrase. */
99
+ export function unlock(passphrase: string, params: SealParameters): Buffer {
100
+ const key = deriveKey(passphrase, params)
101
+ try {
102
+ const opened = openBytes(key, CHECK_PATH, Buffer.from(params.check, 'base64'))
103
+ if (opened.toString() !== CHECK_TEXT) throw new WrongPassphraseError()
104
+ } catch {
105
+ throw new WrongPassphraseError()
106
+ }
107
+ return key
108
+ }
109
+
110
+ /** A manifest's seal read without trusting it: undefined when absent or malformed. Pure. */
111
+ export function parseSealParameters(raw: unknown): SealParameters | undefined {
112
+ if (!raw || typeof raw !== 'object') return undefined
113
+ const seal = raw as Record<string, unknown>
114
+ if (
115
+ seal.kdf !== 'scrypt' ||
116
+ typeof seal.salt !== 'string' ||
117
+ typeof seal.check !== 'string' ||
118
+ ![seal.n, seal.r, seal.p].every((v) => Number.isInteger(v) && (v as number) > 0)
119
+ )
120
+ return undefined
121
+ return {
122
+ kdf: 'scrypt',
123
+ n: seal.n as number,
124
+ r: seal.r as number,
125
+ p: seal.p as number,
126
+ salt: seal.salt,
127
+ check: seal.check,
128
+ }
129
+ }