@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/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/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 {
|
|
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
|
-
|
|
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 =
|
|
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}` })),
|