@crombolo/transfer 0.13.1 → 0.14.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/transfer",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Content transfer for crombolo: bundles of content moved between environments.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.13.1",
32
- "@crombolo/data": "0.13.1"
31
+ "@crombolo/core": "0.14.1",
32
+ "@crombolo/data": "0.14.1"
33
33
  }
34
34
  }
package/src/import.ts CHANGED
@@ -71,6 +71,11 @@ export interface ImportOptions extends CheckOptions {
71
71
  label?: string | undefined
72
72
  /** The npm package the bundle arrived in, recorded on the run. */
73
73
  package?: { name: string; version: string } | undefined
74
+ /**
75
+ * A node's values become exactly the bundle's, rather than the bundle's laid
76
+ * over what is here: a whole-site replace (story 105).
77
+ */
78
+ exact?: boolean
74
79
  }
75
80
 
76
81
  export interface ImportResult {
@@ -342,7 +347,7 @@ async function applyBundle(tx: Db, set: ContentSet, options: ImportOptions): Pro
342
347
  ? ((await templates.byAlias(node.template))?.key ?? current.componentKey)
343
348
  : current.componentKey,
344
349
  parentKey: current.parentKey,
345
- values: overlay(current.values, taken),
350
+ values: overlay(options.exact ? [] : current.values, taken),
346
351
  variants: keepNames
347
352
  ? current.variants.map((v) => ({
348
353
  culture: v.culture,
package/src/index.ts CHANGED
@@ -9,5 +9,6 @@ export * from './people-apply.ts'
9
9
  export * from './references.ts'
10
10
  export * from './resolutions.ts'
11
11
  export * from './revert.ts'
12
+ export * from './sanitise.ts'
12
13
  export * from './seal.ts'
13
14
  export * from './write.ts'
package/src/load.ts CHANGED
@@ -257,6 +257,7 @@ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest
257
257
  schemaVersion: str(provenance.schemaVersion) ?? '',
258
258
  schemaRevision: str(provenance.schemaRevision) ?? '',
259
259
  schemaHash: nullableStr(provenance.schemaHash) ?? null,
260
+ ...(str(provenance.cmsVersion) ? { cmsVersion: str(provenance.cmsVersion) as string } : {}),
260
261
  },
261
262
  selector: {
262
263
  roots: keys(selector.roots),
@@ -313,9 +314,20 @@ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest
313
314
  : {}),
314
315
  ...(parseSealParameters(raw.seal) ? { seal: parseSealParameters(raw.seal) } : {}),
315
316
  ...(raw.content === 'sealed' || raw.content === 'omitted' ? { content: raw.content } : {}),
317
+ ...(raw.scope === 'site' ? { scope: 'site' as const } : {}),
318
+ ...(raw.media === 'omitted' ? { media: 'omitted' as const } : {}),
319
+ ...(isRecord(raw.requires) ? { requires: parseRequires(raw.requires) } : {}),
316
320
  }
317
321
  }
318
322
 
323
+ /** Package names to versions, keeping only string pairs. */
324
+ function parseRequires(raw: Record<string, unknown>): Record<string, string> {
325
+ const out: Record<string, string> = {}
326
+ for (const [name, version] of Object.entries(raw))
327
+ if (typeof version === 'string') out[name] = version
328
+ return out
329
+ }
330
+
319
331
  /**
320
332
  * The declared sections, dropping any name this version does not know and any
321
333
  * path that tries to climb out of the bundle — a manifest is a file that
package/src/model.ts CHANGED
@@ -172,6 +172,8 @@ export interface BundleProvenance {
172
172
  schemaRevision: string
173
173
  schemaHash: string | null
174
174
  siteName: string
175
+ /** The CMS version that wrote the bundle (story 104). Absent on bundles written before it. */
176
+ cmsVersion?: string
175
177
  }
176
178
 
177
179
  export interface BundleSelector {
@@ -228,6 +230,20 @@ export interface BundleManifest {
228
230
  * Absent: in the open.
229
231
  */
230
232
  content?: BundleContentMode
233
+ /**
234
+ * `site`: a whole site (story 104) — every node, the whole schema and every
235
+ * file — which an import replaces a site with rather than overlays. Absent: a
236
+ * selection, overlaid as always.
237
+ */
238
+ scope?: 'site'
239
+ /** `omitted`: a whole-site file written without its media files (`--no-media`). */
240
+ media?: 'omitted'
241
+ /**
242
+ * The server bundles the site wires, by package name, at the versions that
243
+ * wrote it. An import refuses when the destination lacks one or has an older
244
+ * version, naming it.
245
+ */
246
+ requires?: Record<string, string>
231
247
  }
232
248
 
233
249
  export type BundleContentMode = 'sealed' | 'omitted'
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Anonymising a bundle before it is written (story 104, reused by 100's clone):
3
+ * the structure travels, the people in it do not. It runs on what the export
4
+ * gathered and before anything is serialised, so an unsanitised copy never
5
+ * exists in the output.
6
+ *
7
+ * - Back-office users are removed; the destination makes one fresh local
8
+ * administrator. User groups stay, as structure.
9
+ * - Members stay as placeholders — type, groups and approval kept — with name,
10
+ * username and email replaced, the password and every property value gone.
11
+ * - Every email address in content, people and the dictionary is replaced, the
12
+ * same address by the same stand-in each time so references still agree.
13
+ *
14
+ * Tokens, sessions, external logins and form entries are never exported, so
15
+ * there is nothing of theirs to remove. Code and schema are left alone: an
16
+ * address in a template is the site's, not a person's.
17
+ */
18
+ import type { BundleNode, BundleValue, ContentSet } from './model.ts'
19
+ import type { BundleMember, BundlePeople } from './people.ts'
20
+
21
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
22
+
23
+ /** The domain stand-ins use: reserved, so nothing is ever delivered to it. */
24
+ export const ANONYMISED_DOMAIN = 'example.invalid'
25
+
26
+ export interface SanitiseInput {
27
+ set: ContentSet
28
+ people?: BundlePeople
29
+ /** The dictionary `.udt`, whose translations may hold addresses. */
30
+ dictionary?: string
31
+ }
32
+
33
+ export interface SanitiseResult {
34
+ set: ContentSet
35
+ people: BundlePeople | undefined
36
+ dictionary: string | undefined
37
+ /** How many distinct email addresses were replaced. */
38
+ emails: number
39
+ }
40
+
41
+ export function sanitise(input: SanitiseInput): SanitiseResult {
42
+ const standIns = new Map<string, string>()
43
+ const standInFor = (address: string): string => {
44
+ const known = address.toLowerCase()
45
+ let replacement = standIns.get(known)
46
+ if (!replacement) {
47
+ replacement = `person-${standIns.size + 1}@${ANONYMISED_DOMAIN}`
48
+ standIns.set(known, replacement)
49
+ }
50
+ return replacement
51
+ }
52
+ const scrub = (text: string) =>
53
+ text.replace(EMAIL, (address) =>
54
+ address.toLowerCase().endsWith(`@${ANONYMISED_DOMAIN}`) ? address : standInFor(address),
55
+ )
56
+ const deep = (value: unknown): unknown => {
57
+ if (typeof value === 'string') return scrub(value)
58
+ if (Array.isArray(value)) return value.map(deep)
59
+ if (value && typeof value === 'object')
60
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deep(v)]))
61
+ return value
62
+ }
63
+
64
+ const usernames = new Map<string, string>()
65
+ const node = (n: BundleNode): BundleNode => ({
66
+ ...n,
67
+ variants: deep(n.variants) as BundleNode['variants'],
68
+ values: n.values.map((v: BundleValue) => ({ ...v, value: deep(v.value) })),
69
+ ...(n.access
70
+ ? {
71
+ access: {
72
+ ...n.access,
73
+ // A protected page naming a member names their stand-in; one naming
74
+ // someone who did not travel names nobody.
75
+ ...(n.access.memberUserNames
76
+ ? {
77
+ memberUserNames: n.access.memberUserNames.flatMap((name) => {
78
+ const standIn = usernames.get(name.toLowerCase())
79
+ return standIn ? [standIn] : []
80
+ }),
81
+ }
82
+ : {}),
83
+ },
84
+ }
85
+ : {}),
86
+ })
87
+
88
+ const member = (m: BundleMember, index: number): BundleMember => {
89
+ const n = index + 1
90
+ standIns.set(m.email.toLowerCase(), `member-${n}@${ANONYMISED_DOMAIN}`)
91
+ usernames.set(m.username.toLowerCase(), `member-${n}`)
92
+ return {
93
+ ...m,
94
+ name: `Member ${n}`,
95
+ username: `member-${n}`,
96
+ email: `member-${n}@${ANONYMISED_DOMAIN}`,
97
+ passwordHash: null,
98
+ passwordConfig: null,
99
+ values: [],
100
+ }
101
+ }
102
+
103
+ // Members first, so an address that belongs to one maps to their stand-in
104
+ // wherever content mentions it.
105
+ const people = input.people
106
+ ? {
107
+ memberGroups: input.people.memberGroups,
108
+ members: input.people.members.map(member),
109
+ userGroups: input.people.userGroups,
110
+ users: [],
111
+ }
112
+ : undefined
113
+ for (const user of input.people?.users ?? []) standInFor(user.email)
114
+
115
+ return {
116
+ set: { ...input.set, nodes: input.set.nodes.map(node) },
117
+ people,
118
+ dictionary: input.dictionary === undefined ? undefined : scrub(input.dictionary),
119
+ emails: standIns.size,
120
+ }
121
+ }
package/src/write.ts CHANGED
@@ -157,6 +157,7 @@ export function writeManifest(manifest: BundleManifest): string {
157
157
  schemaVersion: manifest.provenance.schemaVersion,
158
158
  schemaRevision: manifest.provenance.schemaRevision,
159
159
  schemaHash: manifest.provenance.schemaHash,
160
+ ...(manifest.provenance.cmsVersion ? { cmsVersion: manifest.provenance.cmsVersion } : {}),
160
161
  },
161
162
  selector: {
162
163
  roots: manifest.selector.roots,
@@ -193,6 +194,11 @@ export function writeManifest(manifest: BundleManifest): string {
193
194
  ...(manifest.people ? { people: manifest.people } : {}),
194
195
  ...(manifest.seal ? { seal: manifest.seal } : {}),
195
196
  ...(manifest.content ? { content: manifest.content } : {}),
197
+ ...(manifest.scope ? { scope: manifest.scope } : {}),
198
+ ...(manifest.media ? { media: manifest.media } : {}),
199
+ ...(manifest.requires && Object.keys(manifest.requires).length > 0
200
+ ? { requires: Object.fromEntries(Object.entries(manifest.requires).sort()) }
201
+ : {}),
196
202
  })
197
203
  }
198
204
 
@@ -289,13 +295,56 @@ export function writeBundle(
289
295
  sections: readonly BundleFile[] = [],
290
296
  options: WriteOptions = {},
291
297
  ): BundleFile[] {
298
+ const sizes = new Map([...blobs].map(([key, bytes]) => [key, bytes.length]))
299
+ const planned = planBundle(set, sizes, sections, options)
300
+ const carried: BundleFile[] = planned.blobs.map((key) => {
301
+ const path = blobPath(key)
302
+ const bytes = blobs.get(key) as Uint8Array
303
+ return {
304
+ path,
305
+ bytes: planned.sealing && options.seal ? sealBytes(options.seal.key, path, bytes) : bytes,
306
+ }
307
+ })
308
+ const manifest = {
309
+ ...planned.manifest,
310
+ integrity: bundleIntegrity([...planned.files, ...carried]),
311
+ }
312
+ return [{ path: MANIFEST_FILE, text: writeManifest(manifest) }, ...planned.files, ...carried]
313
+ }
314
+
315
+ /**
316
+ * A bundle whose media files the caller streams (story 104): the manifest and
317
+ * every other file, from each carried file's size alone. The caller writes
318
+ * `blobs` at `blobPath`, computes `bundleIntegrity` over those and `files` in
319
+ * path order, and writes the manifest with it. Open content only: sealing a
320
+ * file needs all of it at once.
321
+ */
322
+ export function planStreamedBundle(
323
+ set: ContentSet,
324
+ blobSizes: ReadonlyMap<string, number>,
325
+ sections: readonly BundleFile[] = [],
326
+ options: Omit<WriteOptions, 'content'> & { content?: 'open' | 'omitted' } = {},
327
+ ): { manifest: BundleManifest; files: BundleFile[]; blobs: string[] } {
328
+ const planned = planBundle(set, blobSizes, sections, {
329
+ ...options,
330
+ content: options.content ?? 'open',
331
+ })
332
+ return { manifest: planned.manifest, files: planned.files, blobs: planned.blobs }
333
+ }
334
+
335
+ function planBundle(
336
+ set: ContentSet,
337
+ blobSizes: ReadonlyMap<string, number>,
338
+ sections: readonly BundleFile[],
339
+ options: WriteOptions,
340
+ ): { manifest: BundleManifest; files: BundleFile[]; blobs: string[]; sealing: boolean } {
292
341
  const seal = options.seal
293
342
  const content = options.content ?? (seal ? 'sealed' : 'open')
294
343
  if ((content === 'sealed' || options.people) && !seal)
295
344
  throw new Error('Sealed content and people need a seal: give a passphrase.')
296
345
  const sealing = content === 'sealed'
297
346
  const source = content === 'omitted' ? set.nodes.map(withoutContent) : set.nodes
298
- const carriedBlobs: ReadonlyMap<string, Uint8Array> = content === 'omitted' ? new Map() : blobs
347
+ const carriedSizes: ReadonlyMap<string, number> = content === 'omitted' ? new Map() : blobSizes
299
348
 
300
349
  const sorted = [...source].sort((a, b) => a.key.localeCompare(b.key))
301
350
  const nodes: BundleFile[] = sorted.map((node) => ({
@@ -309,12 +358,6 @@ export function writeBundle(
309
358
  return { path, bytes: sealBytes(seal.key, path, Buffer.from(json(sealedValues(node)))) }
310
359
  })
311
360
  : []
312
- const carried: BundleFile[] = [...carriedBlobs]
313
- .sort(([a], [b]) => a.localeCompare(b))
314
- .map(([key, bytes]) => {
315
- const path = blobPath(key)
316
- return { path, bytes: sealing && seal ? sealBytes(seal.key, path, bytes) : bytes }
317
- })
318
361
 
319
362
  // A file in no known section would travel without being declared, so it
320
363
  // would not be hashed and a reader would never look for it. Refused here
@@ -345,27 +388,19 @@ export function writeBundle(
345
388
  : CONTENT_ONLY_FORMAT_VERSION,
346
389
  // Without values nothing names a media file, so none is expected either.
347
390
  blobs: (content === 'omitted' ? [] : set.manifest.blobs).map((blob) => {
348
- const bytes = carriedBlobs.get(blob.key)
349
- return bytes ? { ...blob, included: true, size: bytes.length } : { ...blob, included: false }
391
+ const size = carriedSizes.get(blob.key)
392
+ return size !== undefined ? { ...blob, included: true, size } : { ...blob, included: false }
350
393
  }),
351
394
  carries: extra.length > 0 ? carries : undefined,
352
395
  people: people?.block,
353
396
  seal: seal && (sealing || people) ? seal.params : undefined,
354
397
  content: content === 'open' ? undefined : content,
355
- integrity: bundleIntegrity([
356
- ...nodes,
357
- ...sealedFiles,
358
- ...carried,
359
- ...extra,
360
- ...(people?.files ?? []),
361
- ]),
398
+ integrity: '',
399
+ }
400
+ return {
401
+ manifest,
402
+ files: [...nodes, ...sealedFiles, ...extra, ...(people?.files ?? [])],
403
+ blobs: [...carriedSizes.keys()].sort((a, b) => a.localeCompare(b)),
404
+ sealing,
362
405
  }
363
- return [
364
- { path: MANIFEST_FILE, text: writeManifest(manifest) },
365
- ...nodes,
366
- ...sealedFiles,
367
- ...carried,
368
- ...extra,
369
- ...(people?.files ?? []),
370
- ]
371
406
  }