@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/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
- /** A node, with its variants and values in a stable order so a diff means something. */
54
- export function writeNode(node: BundleNode): string {
55
- const values = [...node.values].sort(
56
- (a, b) =>
57
- a.property.localeCompare(b.property) ||
58
- (a.culture ?? '').localeCompare(b.culture ?? '') ||
59
- (a.segment ?? '').localeCompare(b.segment ?? ''),
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 nodes: BundleFile[] = [...set.nodes]
191
- .sort((a, b) => a.key.localeCompare(b.key))
192
- .map((node) => ({ path: nodePath(node.key), text: writeNode(node) }))
193
- const carried: BundleFile[] = [...blobs]
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]) => ({ path: blobPath(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
- // Content-only bundles keep saying 1, so an older node can still read one.
211
- formatVersion: extra.length > 0 ? BUNDLE_FORMAT_VERSION : CONTENT_ONLY_FORMAT_VERSION,
212
- blobs: set.manifest.blobs.map((blob) => {
213
- const bytes = blobs.get(blob.key)
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
- integrity: bundleIntegrity([...nodes, ...carried, ...extra]),
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 [{ path: MANIFEST_FILE, text: writeManifest(manifest) }, ...nodes, ...carried, ...extra]
363
+ return [
364
+ { path: MANIFEST_FILE, text: writeManifest(manifest) },
365
+ ...nodes,
366
+ ...sealedFiles,
367
+ ...carried,
368
+ ...extra,
369
+ ...(people?.files ?? []),
370
+ ]
220
371
  }