@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/load.ts CHANGED
@@ -6,21 +6,35 @@
6
6
  * check reports on what it would do, and by then a malformed node file would be
7
7
  * a crash rather than a finding.
8
8
  */
9
+
9
10
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
11
  import { join } from 'node:path'
11
12
  import {
12
13
  BUNDLE_FORMAT_VERSION,
13
14
  BUNDLE_SECTIONS,
15
+ type BundleAccess,
14
16
  type BundleKind,
15
17
  type BundleManifest,
16
18
  type BundleNode,
17
19
  type BundleProblem,
20
+ type BundleRedirect,
21
+ type BundleSchedule,
18
22
  type BundleSection,
19
23
  type BundleValue,
20
24
  type BundleVariant,
21
25
  type ContentSet,
22
26
  } from './model.ts'
23
- import { blobPath, bundleIntegrity, MANIFEST_FILE, nodePath, sectionOf } from './write.ts'
27
+ import { type BundlePeople, openPeople, type PeopleBlock, parsePeopleBlock } from './people.ts'
28
+ import { openBytes, parseSealParameters, unlock } from './seal.ts'
29
+ import {
30
+ blobPath,
31
+ bundleIntegrity,
32
+ MANIFEST_FILE,
33
+ nodePath,
34
+ type SealedValues,
35
+ sectionOf,
36
+ valuesPath,
37
+ } from './write.ts'
24
38
 
25
39
  export interface LoadedBundle {
26
40
  set: ContentSet | undefined
@@ -31,6 +45,10 @@ export interface LoadedBundle {
31
45
  * content-only bundle, which is every version 1 bundle.
32
46
  */
33
47
  files: Map<string, string>
48
+ /** The sealed people, when the bundle carries any: opened by `openBundle`. */
49
+ people?: { block: PeopleBlock; files: Map<string, Uint8Array> }
50
+ /** Each node's sealed values, by path, when the content is sealed. */
51
+ sealedValues?: Map<string, Uint8Array>
34
52
  /**
35
53
  * The media files the bundle carries, by store key, as paths on disk.
36
54
  *
@@ -139,6 +157,31 @@ export function parseNode(
139
157
  : []
140
158
  if (variants.length === 0) problems.push({ file, message: 'no variants: a node needs a name' })
141
159
 
160
+ const access = parseAccess(raw.access)
161
+ const schedule = Array.isArray(raw.schedule)
162
+ ? raw.schedule.flatMap((e): BundleSchedule[] =>
163
+ isRecord(e) &&
164
+ (e.action === 'publish' || e.action === 'unpublish') &&
165
+ typeof e.date === 'string' &&
166
+ !Number.isNaN(Date.parse(e.date))
167
+ ? [{ culture: nullableStr(e.culture) ?? null, action: e.action, date: e.date }]
168
+ : [],
169
+ )
170
+ : []
171
+ const redirects = Array.isArray(raw.redirects)
172
+ ? raw.redirects.flatMap((r): BundleRedirect[] =>
173
+ isRecord(r) && typeof r.path === 'string' && r.path.startsWith('/')
174
+ ? [
175
+ {
176
+ path: r.path,
177
+ root: nullableStr(r.root) ?? null,
178
+ culture: nullableStr(r.culture) ?? null,
179
+ },
180
+ ]
181
+ : [],
182
+ )
183
+ : []
184
+
142
185
  return {
143
186
  key,
144
187
  kind,
@@ -148,6 +191,25 @@ export function parseNode(
148
191
  template: nullableStr(raw.template) ?? null,
149
192
  variants,
150
193
  values,
194
+ ...(access ? { access } : {}),
195
+ ...(schedule.length > 0 ? { schedule } : {}),
196
+ ...(redirects.length > 0 ? { redirects } : {}),
197
+ }
198
+ }
199
+
200
+ const names = (v: unknown): string[] =>
201
+ Array.isArray(v) ? v.flatMap((n) => (typeof n === 'string' ? [n] : [])) : []
202
+
203
+ function parseAccess(raw: unknown): BundleAccess | undefined {
204
+ if (!isRecord(raw)) return undefined
205
+ const loginNode = str(raw.loginNode)
206
+ const errorNode = str(raw.errorNode)
207
+ if (!loginNode || !errorNode) return undefined
208
+ return {
209
+ loginNode,
210
+ errorNode,
211
+ memberGroups: names(raw.memberGroups),
212
+ ...(Array.isArray(raw.memberUserNames) ? { memberUserNames: names(raw.memberUserNames) } : {}),
151
213
  }
152
214
  }
153
215
 
@@ -246,6 +308,11 @@ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest
246
308
  )
247
309
  : [],
248
310
  ...(isRecord(raw.carries) ? { carries: parseCarries(raw.carries) } : {}),
311
+ ...(raw.people !== undefined && parsePeopleBlock(raw.people)
312
+ ? { people: parsePeopleBlock(raw.people) as PeopleBlock }
313
+ : {}),
314
+ ...(parseSealParameters(raw.seal) ? { seal: parseSealParameters(raw.seal) } : {}),
315
+ ...(raw.content === 'sealed' || raw.content === 'omitted' ? { content: raw.content } : {}),
249
316
  }
250
317
  }
251
318
 
@@ -329,6 +396,48 @@ export function loadBundle(dir: string): LoadedBundle {
329
396
  }
330
397
  }
331
398
 
399
+ // People are sealed bytes, opened only with the passphrase at install; here
400
+ // they are only read, and counted in the hash like everything else.
401
+ const people = new Map<string, Uint8Array>()
402
+ for (const path of manifest.people?.paths ?? []) {
403
+ const file = join(dir, path)
404
+ if (!existsSync(file)) {
405
+ problems.push({
406
+ file: path,
407
+ message: 'the manifest says this file is carried, and it is not',
408
+ })
409
+ continue
410
+ }
411
+ const bytes = new Uint8Array(readFileSync(file))
412
+ people.set(path, bytes)
413
+ read.push({ path, bytes })
414
+ }
415
+
416
+ if ((manifest.content === 'sealed' || manifest.people) && !manifest.seal)
417
+ problems.push({
418
+ file: MANIFEST_FILE,
419
+ message: 'the bundle says it is sealed but carries no seal to open it with',
420
+ })
421
+
422
+ // A sealed bundle carries one sealed file per node, read as bytes here and
423
+ // opened only with the passphrase.
424
+ const sealed = new Map<string, Uint8Array>()
425
+ if (manifest.content === 'sealed')
426
+ for (const name of nodeFiles) {
427
+ const path = valuesPath(name.slice(0, -'.json'.length))
428
+ const file = join(dir, path)
429
+ if (!existsSync(file)) {
430
+ problems.push({
431
+ file: path,
432
+ message: 'a sealed bundle carries the values of every node, and this one is missing',
433
+ })
434
+ continue
435
+ }
436
+ const bytes = new Uint8Array(readFileSync(file))
437
+ sealed.set(path, bytes)
438
+ read.push({ path, bytes })
439
+ }
440
+
332
441
  // Before shape: a truncated copy produces confusing field errors otherwise.
333
442
  const integrity = bundleIntegrity(read)
334
443
  if (manifest.integrity && manifest.integrity !== integrity)
@@ -356,5 +465,60 @@ export function loadBundle(dir: string): LoadedBundle {
356
465
  seen.add(node.key)
357
466
  }
358
467
 
359
- return { set: { manifest, nodes }, problems, blobs, files }
468
+ const isSealed = manifest.content === 'sealed' || manifest.people !== undefined
469
+ return {
470
+ set: { manifest, nodes, ...(isSealed ? { sealed: true } : {}) },
471
+ problems,
472
+ blobs,
473
+ files,
474
+ ...(manifest.people ? { people: { block: manifest.people, files: people } } : {}),
475
+ ...(manifest.content === 'sealed' ? { sealedValues: sealed } : {}),
476
+ }
477
+ }
478
+
479
+ /** A bundle opened with its passphrase: values filled in, people read, media files openable. */
480
+ export interface OpenedBundle {
481
+ set: ContentSet
482
+ people: BundlePeople | undefined
483
+ /** A carried media file's bytes as they were exported. */
484
+ openBlob: (key: string, bytes: Uint8Array) => Uint8Array
485
+ }
486
+
487
+ /**
488
+ * Opens what a bundle sealed. Throws `WrongPassphraseError` for the wrong
489
+ * passphrase and `SealedFileError` for a file altered or moved; a bundle with
490
+ * nothing sealed opens without one.
491
+ */
492
+ export function openBundle(loaded: LoadedBundle, passphrase: string | undefined): OpenedBundle {
493
+ const set = loaded.set
494
+ if (!set) throw new Error('The bundle could not be read, so there is nothing to open.')
495
+ const params = set.manifest.seal
496
+ if (!set.sealed || !params) return { set, people: undefined, openBlob: (_key, bytes) => bytes }
497
+ const key = unlock(passphrase ?? '', params)
498
+ const nodes = set.nodes.map((node) => {
499
+ const path = valuesPath(node.key)
500
+ const bytes = loaded.sealedValues?.get(path)
501
+ if (!bytes) return node
502
+ const opened = JSON.parse(openBytes(key, path, bytes).toString('utf8')) as SealedValues
503
+ const at = (v: { property: string; culture: string | null; segment: string | null }) =>
504
+ `${v.property}|${v.culture ?? ''}|${v.segment ?? ''}`
505
+ const payload = new Map(opened.values.map((v) => [at(v), v.value]))
506
+ return {
507
+ ...node,
508
+ values: node.values.map((v) => ({ ...v, value: payload.get(at(v)) })),
509
+ ...(node.access && opened.memberUserNames
510
+ ? { access: { ...node.access, memberUserNames: opened.memberUserNames } }
511
+ : {}),
512
+ }
513
+ })
514
+ const people = loaded.people
515
+ ? openPeople(loaded.people.block, loaded.people.files, key)
516
+ : undefined
517
+ const sealedBlobs = set.manifest.content === 'sealed'
518
+ const { sealed: _sealed, ...rest } = set
519
+ return {
520
+ set: { ...rest, nodes, ...(people ? { people } : {}) },
521
+ people,
522
+ openBlob: (blob, bytes) => (sealedBlobs ? openBytes(key, blobPath(blob), bytes) : bytes),
523
+ }
360
524
  }
package/src/model.ts CHANGED
@@ -8,8 +8,14 @@
8
8
  * across environments — with the alias as the readable fallback.
9
9
  */
10
10
 
11
+ import type { BundlePeople, PeopleBlock } from './people.ts'
12
+ import type { SealParameters } from './seal.ts'
13
+
11
14
  /** Bumped when a change to the shapes below stops an older reader understanding a bundle. */
12
- export const BUNDLE_FORMAT_VERSION = 2
15
+ export const BUNDLE_FORMAT_VERSION = 3
16
+
17
+ /** What a bundle with sections and nothing sealed writes: version 2 readers still read it. */
18
+ export const SECTIONS_FORMAT_VERSION = 2
13
19
 
14
20
  /**
15
21
  * The version a bundle carrying only content is still written as.
@@ -61,6 +67,7 @@ export interface BundleValue {
61
67
  segment: string | null
62
68
  /** The editor that wrote it, so the destination can refuse a property whose editor changed. */
63
69
  editor: string
70
+ /** Undefined while the bundle's content is sealed. */
64
71
  value: unknown
65
72
  /** Node keys this value refers to, extracted at export so the importer need not re-parse. */
66
73
  references?: string[]
@@ -77,6 +84,34 @@ export interface BundleVariant {
77
84
  published: boolean
78
85
  }
79
86
 
87
+ /**
88
+ * A protected page: who may see it, by member group name, and where a
89
+ * visitor is sent. The usernames are personal, so they travel with the values.
90
+ */
91
+ export interface BundleAccess {
92
+ loginNode: string
93
+ errorNode: string
94
+ memberGroups: string[]
95
+ /** Absent while sealed, or when the bundle carries no content. */
96
+ memberUserNames?: string[]
97
+ }
98
+
99
+ /** A publish or unpublish waiting for its time. */
100
+ export interface BundleSchedule {
101
+ culture: string | null
102
+ action: 'publish' | 'unpublish'
103
+ /** ISO 8601, UTC. */
104
+ date: string
105
+ }
106
+
107
+ /** A route the page used to answer on, which redirects to it. */
108
+ export interface BundleRedirect {
109
+ path: string
110
+ /** The hostname root it was under, by key; null for none. */
111
+ root: string | null
112
+ culture: string | null
113
+ }
114
+
80
115
  export interface BundleNode {
81
116
  key: string
82
117
  kind: BundleKind
@@ -88,6 +123,9 @@ export interface BundleNode {
88
123
  template: string | null
89
124
  variants: BundleVariant[]
90
125
  values: BundleValue[]
126
+ access?: BundleAccess
127
+ schedule?: BundleSchedule[]
128
+ redirects?: BundleRedirect[]
91
129
  }
92
130
 
93
131
  /** Something the bundle refers to but does not carry: it must already exist at the destination. */
@@ -176,8 +214,24 @@ export interface BundleManifest {
176
214
  * content-only bundle, which is what version 1 always was.
177
215
  */
178
216
  carries?: Partial<Record<BundleSection, string[]>>
217
+ /**
218
+ * Members and users, sealed (story 071): counts and how to derive the key,
219
+ * never who. Present only on a bundle exported with them — format 3, which an
220
+ * older reader refuses by name rather than half-reading.
221
+ */
222
+ people?: PeopleBlock
223
+ /** How values, media files and people are sealed: the salt and cost, never the key. */
224
+ seal?: SealParameters
225
+ /**
226
+ * `sealed`: values and media files are under `seal`. `omitted`: exported
227
+ * without them, so an import names and places pages and writes no value.
228
+ * Absent: in the open.
229
+ */
230
+ content?: BundleContentMode
179
231
  }
180
232
 
233
+ export type BundleContentMode = 'sealed' | 'omitted'
234
+
181
235
  /**
182
236
  * A bundle read into memory: the manifest and its nodes. Named for
183
237
  * `SchemaSet` in `@crombolo/schema`, which plays the same part for structure.
@@ -185,6 +239,10 @@ export interface BundleManifest {
185
239
  export interface ContentSet {
186
240
  manifest: BundleManifest
187
241
  nodes: BundleNode[]
242
+ /** True while the values are still sealed: `openBundle` with the passphrase first. */
243
+ sealed?: boolean
244
+ /** The people, once opened. */
245
+ people?: BundlePeople
188
246
  }
189
247
 
190
248
  /** A problem with the bundle itself, as opposed to a finding about applying it. */
package/src/package.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * hash and sections mean what they always did. The package is what gives it a
8
8
  * name, a version and a core it was written for.
9
9
  */
10
+ import { PEOPLE_DIR } from './people.ts'
10
11
  import type { BundleFile } from './write.ts'
11
12
 
12
13
  /** The directory inside a package that holds the `bundle.json` payload. */
@@ -77,6 +78,12 @@ export function writeBundlePackage(
77
78
  info: BundlePackageInfo,
78
79
  ): BundleFile[] {
79
80
  if (!isPackageName(info.name)) throw new Error(`"${info.name}" is not a package name npm accepts`)
81
+ // Personal data never goes where `npm publish` can take it, sealed or not;
82
+ // and sealed content would be no use to anyone who installed it.
83
+ if (!info.private && payload.some((file) => file.path.startsWith(`${PEOPLE_DIR}/`)))
84
+ throw new Error('A bundle that carries members or users can only be a private package.')
85
+ if (!info.private && payload.some((file) => file.path.startsWith('values/')))
86
+ throw new Error('A bundle whose content is encrypted can only be a private package.')
80
87
  return [
81
88
  { path: 'package.json', text: `${JSON.stringify(bundlePackageJson(info), null, 2)}\n` },
82
89
  ...payload.map((file) => ({ ...file, path: `${PAYLOAD_DIR}/${file.path}` })),