@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.
- package/package.json +3 -3
- package/src/check.ts +221 -28
- package/src/export.ts +46 -0
- package/src/import.ts +331 -193
- package/src/index.ts +3 -0
- package/src/load.ts +166 -2
- package/src/model.ts +59 -1
- package/src/package.ts +7 -0
- package/src/people-apply.ts +572 -0
- package/src/people.ts +167 -0
- package/src/resolutions.ts +69 -7
- package/src/revert.ts +44 -5
- package/src/seal.ts +129 -0
- package/src/write.ts +173 -22
package/src/write.ts
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
*
|
|
11
11
|
* `read(write(x))` must equal `x` — the tests hold it to that.
|
|
12
12
|
*/
|
|
13
|
+
|
|
13
14
|
import {
|
|
14
15
|
BUNDLE_FORMAT_VERSION,
|
|
15
16
|
BUNDLE_SECTIONS,
|
|
17
|
+
type BundleContentMode,
|
|
16
18
|
type BundleManifest,
|
|
17
19
|
type BundleNode,
|
|
18
20
|
type BundleSection,
|
|
@@ -20,7 +22,10 @@ import {
|
|
|
20
22
|
type BundleVariant,
|
|
21
23
|
CONTENT_ONLY_FORMAT_VERSION,
|
|
22
24
|
type ContentSet,
|
|
25
|
+
SECTIONS_FORMAT_VERSION,
|
|
23
26
|
} from './model.ts'
|
|
27
|
+
import { type BundlePeople, sealPeople } from './people.ts'
|
|
28
|
+
import { type Seal, sealBytes } from './seal.ts'
|
|
24
29
|
|
|
25
30
|
/** One file of a written bundle, ready to be put on disk or into a store. */
|
|
26
31
|
export interface BundleFile {
|
|
@@ -37,27 +42,31 @@ function variant(v: BundleVariant): Record<string, unknown> {
|
|
|
37
42
|
return { culture: v.culture, segment: v.segment, name: v.name, published: v.published }
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
function value(v: BundleValue): Record<string, unknown> {
|
|
45
|
+
function value(v: BundleValue, open: boolean): Record<string, unknown> {
|
|
41
46
|
const out: Record<string, unknown> = {
|
|
42
47
|
property: v.property,
|
|
43
48
|
culture: v.culture,
|
|
44
49
|
segment: v.segment,
|
|
45
50
|
editor: v.editor,
|
|
46
|
-
value: v.value,
|
|
51
|
+
...(open ? { value: v.value } : {}),
|
|
47
52
|
}
|
|
48
53
|
// Absent rather than empty: a value that refers to nothing should not carry a key.
|
|
49
54
|
if (v.references && v.references.length > 0) out.references = [...v.references].sort()
|
|
50
55
|
return out
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
const byHandle = (a: BundleValue, b: BundleValue) =>
|
|
59
|
+
a.property.localeCompare(b.property) ||
|
|
60
|
+
(a.culture ?? '').localeCompare(b.culture ?? '') ||
|
|
61
|
+
(a.segment ?? '').localeCompare(b.segment ?? '')
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A node, with its variants and values in a stable order so a diff means
|
|
65
|
+
* something. `sealed` leaves each value's payload and the protected page's
|
|
66
|
+
* usernames out, for `sealedValues` to carry.
|
|
67
|
+
*/
|
|
68
|
+
export function writeNode(node: BundleNode, sealed = false): string {
|
|
69
|
+
const values = [...node.values].sort(byHandle)
|
|
61
70
|
const variants = [...node.variants].sort(
|
|
62
71
|
(a, b) =>
|
|
63
72
|
(a.culture ?? '').localeCompare(b.culture ?? '') ||
|
|
@@ -71,10 +80,68 @@ export function writeNode(node: BundleNode): string {
|
|
|
71
80
|
sortOrder: node.sortOrder,
|
|
72
81
|
template: node.template,
|
|
73
82
|
variants: variants.map(variant),
|
|
74
|
-
values: values.map(value),
|
|
83
|
+
values: values.map((v) => value(v, !sealed)),
|
|
84
|
+
// Absent when there is none, so an older node's bundle is unchanged.
|
|
85
|
+
...(node.access
|
|
86
|
+
? {
|
|
87
|
+
access: {
|
|
88
|
+
loginNode: node.access.loginNode,
|
|
89
|
+
errorNode: node.access.errorNode,
|
|
90
|
+
memberGroups: [...node.access.memberGroups].sort(),
|
|
91
|
+
...(!sealed && node.access.memberUserNames
|
|
92
|
+
? { memberUserNames: [...node.access.memberUserNames].sort() }
|
|
93
|
+
: {}),
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
: {}),
|
|
97
|
+
...(node.schedule && node.schedule.length > 0
|
|
98
|
+
? {
|
|
99
|
+
schedule: [...node.schedule]
|
|
100
|
+
.sort((a, b) => a.date.localeCompare(b.date) || a.action.localeCompare(b.action))
|
|
101
|
+
.map((e) => ({ culture: e.culture, action: e.action, date: e.date })),
|
|
102
|
+
}
|
|
103
|
+
: {}),
|
|
104
|
+
...(node.redirects && node.redirects.length > 0
|
|
105
|
+
? {
|
|
106
|
+
redirects: [...node.redirects]
|
|
107
|
+
.sort(
|
|
108
|
+
(a, b) =>
|
|
109
|
+
a.path.localeCompare(b.path) || (a.culture ?? '').localeCompare(b.culture ?? ''),
|
|
110
|
+
)
|
|
111
|
+
.map((r) => ({ path: r.path, root: r.root, culture: r.culture })),
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
75
114
|
})
|
|
76
115
|
}
|
|
77
116
|
|
|
117
|
+
/** What a node's sealed file holds: its values' payloads, and its protected page's usernames. */
|
|
118
|
+
export interface SealedValues {
|
|
119
|
+
values: Array<{
|
|
120
|
+
property: string
|
|
121
|
+
culture: string | null
|
|
122
|
+
segment: string | null
|
|
123
|
+
value: unknown
|
|
124
|
+
}>
|
|
125
|
+
memberUserNames?: string[]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function sealedValues(node: BundleNode): SealedValues {
|
|
129
|
+
return {
|
|
130
|
+
values: [...node.values].sort(byHandle).map((v) => ({
|
|
131
|
+
property: v.property,
|
|
132
|
+
culture: v.culture,
|
|
133
|
+
segment: v.segment,
|
|
134
|
+
value: v.value,
|
|
135
|
+
})),
|
|
136
|
+
...(node.access?.memberUserNames
|
|
137
|
+
? { memberUserNames: [...node.access.memberUserNames].sort() }
|
|
138
|
+
: {}),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Where a node's sealed values sit. */
|
|
143
|
+
export const valuesPath = (key: string): string => `values/${key}.json`
|
|
144
|
+
|
|
78
145
|
export function writeManifest(manifest: BundleManifest): string {
|
|
79
146
|
const d = manifest.dependencies
|
|
80
147
|
return json({
|
|
@@ -123,6 +190,9 @@ export function writeManifest(manifest: BundleManifest): string {
|
|
|
123
190
|
...(manifest.carries && Object.keys(manifest.carries).length > 0
|
|
124
191
|
? { carries: sortedCarries(manifest.carries) }
|
|
125
192
|
: {}),
|
|
193
|
+
...(manifest.people ? { people: manifest.people } : {}),
|
|
194
|
+
...(manifest.seal ? { seal: manifest.seal } : {}),
|
|
195
|
+
...(manifest.content ? { content: manifest.content } : {}),
|
|
126
196
|
})
|
|
127
197
|
}
|
|
128
198
|
|
|
@@ -172,6 +242,37 @@ export function bundleIntegrity(files: readonly BundleFile[]): string {
|
|
|
172
242
|
return `sha256-${hasher.digest('hex')}`
|
|
173
243
|
}
|
|
174
244
|
|
|
245
|
+
export interface WriteOptions {
|
|
246
|
+
/**
|
|
247
|
+
* How values, media files and protected-page usernames travel: `open`,
|
|
248
|
+
* `sealed` under `seal`, or `omitted`. Sealed when a seal is given, open
|
|
249
|
+
* otherwise.
|
|
250
|
+
*/
|
|
251
|
+
content?: 'open' | BundleContentMode
|
|
252
|
+
/** One per export, from `newSeal`; needed for sealed content and for people. */
|
|
253
|
+
seal?: Seal
|
|
254
|
+
/** Members and users: always sealed. */
|
|
255
|
+
people?: BundlePeople
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Without values, media files or usernames: what `omitted` writes. Pure. */
|
|
259
|
+
function withoutContent(node: BundleNode): BundleNode {
|
|
260
|
+
const { access, ...rest } = node
|
|
261
|
+
return {
|
|
262
|
+
...rest,
|
|
263
|
+
values: [],
|
|
264
|
+
...(access
|
|
265
|
+
? {
|
|
266
|
+
access: {
|
|
267
|
+
loginNode: access.loginNode,
|
|
268
|
+
errorNode: access.errorNode,
|
|
269
|
+
memberGroups: access.memberGroups,
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
: {}),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
175
276
|
/**
|
|
176
277
|
* The whole bundle as files. The integrity hash is computed over them and
|
|
177
278
|
* written into the manifest, so the caller passes a manifest whose `integrity`
|
|
@@ -186,13 +287,34 @@ export function writeBundle(
|
|
|
186
287
|
set: ContentSet,
|
|
187
288
|
blobs: ReadonlyMap<string, Uint8Array> = new Map(),
|
|
188
289
|
sections: readonly BundleFile[] = [],
|
|
290
|
+
options: WriteOptions = {},
|
|
189
291
|
): BundleFile[] {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
292
|
+
const seal = options.seal
|
|
293
|
+
const content = options.content ?? (seal ? 'sealed' : 'open')
|
|
294
|
+
if ((content === 'sealed' || options.people) && !seal)
|
|
295
|
+
throw new Error('Sealed content and people need a seal: give a passphrase.')
|
|
296
|
+
const sealing = content === 'sealed'
|
|
297
|
+
const source = content === 'omitted' ? set.nodes.map(withoutContent) : set.nodes
|
|
298
|
+
const carriedBlobs: ReadonlyMap<string, Uint8Array> = content === 'omitted' ? new Map() : blobs
|
|
299
|
+
|
|
300
|
+
const sorted = [...source].sort((a, b) => a.key.localeCompare(b.key))
|
|
301
|
+
const nodes: BundleFile[] = sorted.map((node) => ({
|
|
302
|
+
path: nodePath(node.key),
|
|
303
|
+
text: writeNode(node, sealing),
|
|
304
|
+
}))
|
|
305
|
+
const sealedFiles: BundleFile[] =
|
|
306
|
+
sealing && seal
|
|
307
|
+
? sorted.map((node) => {
|
|
308
|
+
const path = valuesPath(node.key)
|
|
309
|
+
return { path, bytes: sealBytes(seal.key, path, Buffer.from(json(sealedValues(node)))) }
|
|
310
|
+
})
|
|
311
|
+
: []
|
|
312
|
+
const carried: BundleFile[] = [...carriedBlobs]
|
|
194
313
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
195
|
-
.map(([key, bytes]) =>
|
|
314
|
+
.map(([key, bytes]) => {
|
|
315
|
+
const path = blobPath(key)
|
|
316
|
+
return { path, bytes: sealing && seal ? sealBytes(seal.key, path, bytes) : bytes }
|
|
317
|
+
})
|
|
196
318
|
|
|
197
319
|
// A file in no known section would travel without being declared, so it
|
|
198
320
|
// would not be hashed and a reader would never look for it. Refused here
|
|
@@ -205,16 +327,45 @@ export function writeBundle(
|
|
|
205
327
|
carries[section] = [...(carries[section] ?? []), file.path]
|
|
206
328
|
}
|
|
207
329
|
|
|
330
|
+
const people = options.people && seal ? sealPeople(options.people, seal) : undefined
|
|
331
|
+
// Rules a node carries beyond its content: an older reader would drop them,
|
|
332
|
+
// and a protected page it dropped would be public.
|
|
333
|
+
const nodeExtras = source.some(
|
|
334
|
+
(n) => n.access || (n.schedule?.length ?? 0) > 0 || (n.redirects?.length ?? 0) > 0,
|
|
335
|
+
)
|
|
208
336
|
const manifest: BundleManifest = {
|
|
209
337
|
...set.manifest,
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
338
|
+
// The oldest version that can read it: content only stays 1, sections
|
|
339
|
+
// need 2, and anything sealed or a node's rules need 3.
|
|
340
|
+
formatVersion:
|
|
341
|
+
people || sealing || nodeExtras
|
|
342
|
+
? BUNDLE_FORMAT_VERSION
|
|
343
|
+
: extra.length > 0
|
|
344
|
+
? SECTIONS_FORMAT_VERSION
|
|
345
|
+
: CONTENT_ONLY_FORMAT_VERSION,
|
|
346
|
+
// Without values nothing names a media file, so none is expected either.
|
|
347
|
+
blobs: (content === 'omitted' ? [] : set.manifest.blobs).map((blob) => {
|
|
348
|
+
const bytes = carriedBlobs.get(blob.key)
|
|
214
349
|
return bytes ? { ...blob, included: true, size: bytes.length } : { ...blob, included: false }
|
|
215
350
|
}),
|
|
216
351
|
carries: extra.length > 0 ? carries : undefined,
|
|
217
|
-
|
|
352
|
+
people: people?.block,
|
|
353
|
+
seal: seal && (sealing || people) ? seal.params : undefined,
|
|
354
|
+
content: content === 'open' ? undefined : content,
|
|
355
|
+
integrity: bundleIntegrity([
|
|
356
|
+
...nodes,
|
|
357
|
+
...sealedFiles,
|
|
358
|
+
...carried,
|
|
359
|
+
...extra,
|
|
360
|
+
...(people?.files ?? []),
|
|
361
|
+
]),
|
|
218
362
|
}
|
|
219
|
-
return [
|
|
363
|
+
return [
|
|
364
|
+
{ path: MANIFEST_FILE, text: writeManifest(manifest) },
|
|
365
|
+
...nodes,
|
|
366
|
+
...sealedFiles,
|
|
367
|
+
...carried,
|
|
368
|
+
...extra,
|
|
369
|
+
...(people?.files ?? []),
|
|
370
|
+
]
|
|
220
371
|
}
|