@crombolo/server 0.13.2 → 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/src/ports.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  ServerPort,
13
13
  TemporaryFilePort,
14
14
  UserItemPort,
15
+ UserTwoFactorPort,
15
16
  } from '@crombolo/api-management'
16
17
  import { AuthStore, expireCookie, serializeCookie } from '@crombolo/auth'
17
18
  import {
@@ -84,6 +85,7 @@ import { createOEmbedService } from './oembed.ts'
84
85
  import { createRedirectTracker } from './redirects.ts'
85
86
  import { componentAliasesIn, type SchemaBoot } from './schema.ts'
86
87
  import { publishSchema } from './schema-store.ts'
88
+ import { TwoFactorService, USER_TWO_FACTOR_PROVIDER } from './two-factor.ts'
87
89
  import { createWebhookService, type WebhookService } from './webhooks.ts'
88
90
 
89
91
  /** Re-exported for tests; the canonical list lives in @crombolo/core. */
@@ -492,7 +494,22 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
492
494
  await store.endAllSessionsForUser(userId)
493
495
  },
494
496
  })
495
- deps.userManagement = userPorts.users
497
+ // Story 032: the authenticator provider, offered only where secrets can be sealed.
498
+ const twoFactor = new TwoFactorService(options.db, {
499
+ secretKey: options.config.secretKey,
500
+ issuer: options.config.siteName,
501
+ })
502
+ const configuration = userPorts.users.configuration
503
+ const currentConfiguration = userPorts.users.currentConfiguration
504
+ deps.userManagement = {
505
+ ...userPorts.users,
506
+ configuration: () => ({ ...configuration(), allowTwoFactor: twoFactor.available }),
507
+ currentConfiguration: () => ({
508
+ ...currentConfiguration(),
509
+ allowTwoFactor: twoFactor.available,
510
+ }),
511
+ twoFactor: userTwoFactorPort(twoFactor),
512
+ }
496
513
  deps.userGroups = userPorts.groups
497
514
  deps.userData = userPorts.userData
498
515
  deps.media = createMediaPort(options.db, mediaFiles, {
@@ -621,3 +638,40 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
621
638
  }
622
639
  return deps
623
640
  }
641
+
642
+ /** The user operations' view of two-factor sign-in: the one authenticator provider. */
643
+ function userTwoFactorPort(service: TwoFactorService): UserTwoFactorPort {
644
+ const provider = USER_TWO_FACTOR_PROVIDER
645
+ return {
646
+ providerName: provider,
647
+ async list(userKey) {
648
+ return [
649
+ {
650
+ providerName: provider,
651
+ isEnabledOnUser: (await service.providers(userKey)).includes(provider),
652
+ },
653
+ ]
654
+ },
655
+ setup(account) {
656
+ const setup = service.setup(account)
657
+ return setup.ok
658
+ ? {
659
+ ok: true,
660
+ secret: setup.value.secret,
661
+ qrCodeSetupImageUrl: setup.value.qrCodeSetupImageUrl,
662
+ }
663
+ : { ok: false, message: setup.message }
664
+ },
665
+ async enable(userKey, code, secret) {
666
+ const enabled = await service.enable(userKey, provider, secret, code)
667
+ return enabled.ok
668
+ ? { ok: true, recoveryCodes: enabled.value.recoveryCodes }
669
+ : { ok: false, message: enabled.message }
670
+ },
671
+ async disable(userKey, code) {
672
+ const disabled = await service.disable(userKey, provider, code)
673
+ return disabled.ok ? { ok: true } : { ok: false, message: disabled.message }
674
+ },
675
+ reset: (userKey) => service.reset(userKey, provider),
676
+ }
677
+ }
@@ -111,6 +111,22 @@ export async function materialiseSchema(store: SchemaStore, dir: string): Promis
111
111
  return keys
112
112
  }
113
113
 
114
+ /**
115
+ * Points a config at its store's schema: materialises the store into
116
+ * `schemaCacheDir` and makes that the schema directory. A no-op without a store;
117
+ * answers how many files were copied.
118
+ */
119
+ export async function useSchemaStore(config: {
120
+ schemaStore: SchemaStore | undefined
121
+ schemaCacheDir: string
122
+ schemaDir: string
123
+ }): Promise<number | undefined> {
124
+ if (!config.schemaStore) return undefined
125
+ const copied = await materialiseSchema(config.schemaStore, config.schemaCacheDir)
126
+ config.schemaDir = config.schemaCacheDir
127
+ return copied.length
128
+ }
129
+
114
130
  export interface PublishReport {
115
131
  written: string[]
116
132
  removed: string[]
package/src/schema.ts CHANGED
@@ -43,7 +43,7 @@ import { listComponents } from './components.ts'
43
43
  import { type CromboloConfig, VERSION } from './config.ts'
44
44
  import { formTypesFor } from './form-types.ts'
45
45
  import { logger } from './logging.ts'
46
- import { materialiseSchema } from './schema-store.ts'
46
+ import { useSchemaStore } from './schema-store.ts'
47
47
 
48
48
  export interface SchemaBoot {
49
49
  /** The state this node runs at: the files' version, the deploy's revision. */
@@ -397,13 +397,11 @@ export async function importSchema(
397
397
  db: Db,
398
398
  config: CromboloConfig,
399
399
  boot: { nodeId: string; nodeState: NodeSchemaState },
400
- options: { dryRun?: boolean } = {},
400
+ options: { dryRun?: boolean; materialise?: boolean } = {},
401
401
  ): Promise<SchemaImportResult> {
402
- let materialised: number | undefined
403
- if (config.schemaStore) {
404
- materialised = (await materialiseSchema(config.schemaStore, config.schemaCacheDir)).length
405
- config.schemaDir = config.schemaCacheDir
406
- }
402
+ // A caller that has just written into the directory says not to, or the store's
403
+ // older copy would be laid over its write.
404
+ const materialised = options.materialise === false ? undefined : await useSchemaStore(config)
407
405
  if (!existsSync(config.schemaDir)) {
408
406
  return { materialised, classification: 'none', findings: [], report: undefined }
409
407
  }
package/src/server.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  hasAccessToSensitiveData,
47
47
  localReturnUrl,
48
48
  MANAGEMENT_API_PREFIX,
49
+ ObjectTypes,
49
50
  PREVIEW_HUB_PATH,
50
51
  SERVER_EVENT_HUB_PATH,
51
52
  } from '@crombolo/core'
@@ -53,6 +54,7 @@ import {
53
54
  appendCacheInstruction,
54
55
  createTablesCapability,
55
56
  type Db,
57
+ ensureKeyValue,
56
58
  FormEntryRepository,
57
59
  FormWorkflowRepository,
58
60
  PublicAccessRepository,
@@ -60,7 +62,9 @@ import {
60
62
  readLedger,
61
63
  readReport,
62
64
  readUrlOptions,
65
+ SiteSearch,
63
66
  TransferRunRepository,
67
+ UserGroupRepository,
64
68
  type WritesPaused,
65
69
  } from '@crombolo/data'
66
70
  import {
@@ -69,6 +73,7 @@ import {
69
73
  FORM_SCRIPT,
70
74
  FORM_SCRIPT_PATH,
71
75
  FORM_SUBMIT_PATH,
76
+ MEMBER_EXTERNAL_SIGN_IN_PATH,
72
77
  PublishedCache,
73
78
  Renderer,
74
79
  type SnapshotStatus,
@@ -141,6 +146,12 @@ import {
141
146
 
142
147
  export { PREVIEW_COOKIE }
143
148
 
149
+ import { BACKOFFICE_EXTERNAL_PATH, backofficeAudience } from './backoffice-external.ts'
150
+ import { createExternalSignIn } from './external-sign-in.ts'
151
+ import {
152
+ createBundleIdentityProviders,
153
+ IdentityProviderOverrides,
154
+ } from './identity-provider-overrides.ts'
144
155
  import { syncConfiguredRedirects } from './redirects.ts'
145
156
  import {
146
157
  bootSchema,
@@ -149,7 +160,7 @@ import {
149
160
  type SchemaBoot,
150
161
  watchSchema,
151
162
  } from './schema.ts'
152
- import { materialiseSchema, publishSchema, type SchemaStore } from './schema-store.ts'
163
+ import { publishSchema, type SchemaStore, useSchemaStore } from './schema-store.ts'
153
164
  import { createSeoHost, originOf } from './seo.ts'
154
165
  import {
155
166
  combineHubs,
@@ -158,6 +169,8 @@ import {
158
169
  type ServerEventHub,
159
170
  } from './server-events.ts'
160
171
  import { createSetupRoutes } from './setup.ts'
172
+ import { siteSearchFor } from './site-search.ts'
173
+ import { TwoFactorService, USER_TWO_FACTOR_PROVIDER } from './two-factor.ts'
161
174
  import { summariseUpgrades } from './upgrade-summary.ts'
162
175
  import { assertViewRuntime } from './view-runtime.ts'
163
176
  import { bundleEventProblems, createWebhookService, webhookStoreView } from './webhooks.ts'
@@ -305,15 +318,13 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
305
318
  // A configured schema store is copied down before anything reads `schema/`, and
306
319
  // becomes the schema directory for this boot. Everything after here — loading,
307
320
  // validation, hashing, sync, the writer — works on files exactly as it always has.
308
- if (config.schemaStore) {
309
- const copied = await materialiseSchema(config.schemaStore, config.schemaCacheDir)
310
- config.schemaDir = config.schemaCacheDir
321
+ const copied = await useSchemaStore(config)
322
+ if (config.schemaStore)
311
323
  schemaLog.info('Schema read from {store}: {count} files in {dir}', {
312
324
  store: config.schemaStore.description,
313
- count: copied.length,
325
+ count: copied,
314
326
  dir: config.schemaCacheDir,
315
327
  })
316
- }
317
328
  // A node that does not own the schema still loads it — the renderer needs the
318
329
  // property definitions — but writes none of it.
319
330
  const schema = await bootSchema(db, owns ? config : { ...config, syncSchemaAtBoot: false })
@@ -338,10 +349,29 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
338
349
  clientKey: (request) =>
339
350
  peers.get(request) ?? request.headers.get('x-forwarded-for') ?? 'unknown',
340
351
  })
352
+ // Story 032: a user with the authenticator on is asked for a code after the password.
353
+ const twoFactor = new TwoFactorService(db, {
354
+ secretKey: config.secretKey,
355
+ issuer: config.siteName,
356
+ })
357
+ let twoFactorKey: Promise<string> | undefined
341
358
  const authRoutes = createAuthRoutes({
342
359
  backOfficePath: paths.backOfficePath,
343
360
  service: auth,
344
361
  cookies,
362
+ twoFactor: {
363
+ providersFor: (userKey) => twoFactor.providers(userKey),
364
+ verify: (userKey, provider, code) =>
365
+ provider === USER_TWO_FACTOR_PROVIDER
366
+ ? twoFactor.verify(userKey, provider, code)
367
+ : Promise.resolve(false),
368
+ secret: () => {
369
+ twoFactorKey ??= ensureKeyValue(db, 'crombolo/twoFactorPendingKey', () =>
370
+ crypto.randomUUID(),
371
+ )
372
+ return twoFactorKey
373
+ },
374
+ },
345
375
  onAudit: (event, request) =>
346
376
  recordAudit(db, {
347
377
  category: 'sign-in',
@@ -356,6 +386,47 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
356
386
  ...clientAddress(request),
357
387
  }),
358
388
  })
389
+ // Story 031: backoffice users signing in through an identity provider, ending
390
+ // in the session the login endpoint would have set.
391
+ const backofficeExternal =
392
+ config.backofficeIdentityProviders.length === 0
393
+ ? undefined
394
+ : createExternalSignIn(
395
+ db,
396
+ backofficeAudience(db, {
397
+ auth,
398
+ providers: config.backofficeIdentityProviders,
399
+ backOfficePath: paths.backOfficePath,
400
+ sessionCookie: (sessionId) =>
401
+ serializeCookie({
402
+ name: names.session,
403
+ value: sessionId,
404
+ secure: config.secureCookies,
405
+ maxAgeSeconds: auth.settings.refreshTokenSeconds,
406
+ }),
407
+ clientAddress: (request) => clientAddress(request).ipAddress ?? undefined,
408
+ onSignIn: (user, request) =>
409
+ recordAudit(db, {
410
+ category: 'sign-in',
411
+ action: 'SignIn',
412
+ source: 'request',
413
+ userKey: user.key,
414
+ userName: user.userName,
415
+ entityType: 'User',
416
+ entityKey: user.key,
417
+ entityName: user.userName,
418
+ detail: 'Through an identity provider',
419
+ ...clientAddress(request),
420
+ }),
421
+ }),
422
+ {
423
+ applicationUrl: config.applicationUrl,
424
+ secureCookies: config.secureCookies,
425
+ log: (message) => log.warning('Backoffice sign-in: {message}', { message }),
426
+ resolve: (provider) =>
427
+ new IdentityProviderOverrides(db, config.secretKey).apply('user', provider),
428
+ },
429
+ )
359
430
 
360
431
  /**
361
432
  * Resolves the caller from the reference access token. The client sends
@@ -506,6 +577,25 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
506
577
  },
507
578
  },
508
579
  log: bundleLog('bundle'),
580
+ identityProviders: createBundleIdentityProviders({
581
+ overrides: new IdentityProviderOverrides(db, config.secretKey),
582
+ members: config.memberIdentityProviders,
583
+ backoffice: config.backofficeIdentityProviders,
584
+ memberCallbackPath: MEMBER_EXTERNAL_SIGN_IN_PATH,
585
+ backofficeCallbackPath: BACKOFFICE_EXTERNAL_PATH,
586
+ groups: async (audience) =>
587
+ audience === 'user'
588
+ ? (await new UserGroupRepository(db).all()).map((g) => ({
589
+ value: g.alias,
590
+ name: g.name,
591
+ }))
592
+ : (
593
+ await db.query<{ text: string }>(
594
+ 'SELECT text FROM node WHERE node_object_type = ? ORDER BY text',
595
+ [ObjectTypes.MemberGroup],
596
+ )
597
+ ).map((g) => ({ value: String(g.text), name: String(g.text) })),
598
+ }),
509
599
  },
510
600
  log: (id) => bundleLog(id),
511
601
  })
@@ -589,6 +679,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
589
679
  protection: () => new PublicAccessRepository(db).allRules(),
590
680
  member: (token) => deliveryMembers.member(token),
591
681
  security: (request, action) => deliveryMembers.handle(request, action),
682
+ search: (term, options) => new SiteSearch(db).query(term, options),
592
683
  })
593
684
 
594
685
  /** A document's latest saved version, as preview shows it, in the published tree's context. */
@@ -1593,6 +1684,8 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1593
1684
  if (response) return response
1594
1685
  }
1595
1686
 
1687
+ const externalResponse = await backofficeExternal?.handle(request, pathname)
1688
+ if (externalResponse) return externalResponse
1596
1689
  const authResponse = await authRoutes.handle(request, pathname)
1597
1690
  if (authResponse) return authResponse
1598
1691
 
@@ -1623,6 +1716,14 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1623
1716
  // the session exists, so anything else is an open redirect at the worst
1624
1717
  // possible moment, and `javascript:` there would run signed in.
1625
1718
  returnUrl: localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
1719
+ externalProviders: config.backofficeIdentityProviders.map((provider) => ({
1720
+ name: provider.oidc.name,
1721
+ url:
1722
+ backofficeExternal?.startUrl(
1723
+ provider.oidc.id,
1724
+ localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
1725
+ ) ?? '',
1726
+ })),
1626
1727
  }),
1627
1728
  )
1628
1729
  }
@@ -1731,7 +1832,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1731
1832
  if (!key) return undefined
1732
1833
  const draft = await draftModel(key, new URL(request.url).searchParams.get('culture'))
1733
1834
  if (!draft) return undefined
1734
- return renderer.renderModel(draft.content, draft.culture, await pageRequest(request))
1835
+ return renderer.renderModel(draft.content, draft.culture, {
1836
+ ...(await pageRequest(request)),
1837
+ search: siteSearchFor(db, cache, undefined),
1838
+ })
1735
1839
  }
1736
1840
 
1737
1841
  /** What every rendered page is told about the request and the site's search defaults. */
@@ -1880,6 +1984,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1880
1984
  member,
1881
1985
  submission,
1882
1986
  access: (content) => members.decide(content, member),
1987
+ search: siteSearchFor(db, cache, member),
1883
1988
  ...(await pageRequest(request)),
1884
1989
  }))
1885
1990
  const cleared: Record<string, string> = cookie
@@ -0,0 +1,258 @@
1
+ /**
2
+ * A whole site in one file (story 104): every content node, media file, schema
3
+ * file, component, stylesheet, script and dictionary item, as one private npm
4
+ * package tarball — the bundle format with `scope: 'site'`. `docs/bundles.md`.
5
+ *
6
+ * Written as it goes. The media files stream from the store into the gzip, and
7
+ * the integrity hash is computed as each file passes, in the path order
8
+ * `bundleIntegrity` defines; only the small files are held. The tarball is
9
+ * written beside its destination and renamed into place once complete, so a
10
+ * failed export never leaves a file that looks whole.
11
+ */
12
+ import { rename, rm } from 'node:fs/promises'
13
+ import { discoverBundles } from '@crombolo/backoffice-host'
14
+ import { type Db, NodeRepository, type NodeRow, ObjectTypes } from '@crombolo/data'
15
+ import {
16
+ BUNDLE_SECTIONS,
17
+ type BundleFile,
18
+ type BundlePackageInfo,
19
+ blobPath,
20
+ bundleIdFor,
21
+ bundlePackageJson,
22
+ exportBundle,
23
+ exportPeople,
24
+ MANIFEST_FILE,
25
+ newSeal,
26
+ PAYLOAD_DIR,
27
+ packageNameFor,
28
+ planStreamedBundle,
29
+ sanitise,
30
+ writeManifest,
31
+ } from '@crombolo/transfer'
32
+ import { createFileSystemPort } from './adapters/file-system.ts'
33
+ import { createTemplateFileStore } from './adapters/template-files.ts'
34
+ import { type CromboloConfig, VERSION } from './config.ts'
35
+ import { mediaStoreFor } from './media-files.ts'
36
+ import { type StoredFile, streamOf } from './media-store.ts'
37
+ import { siteSections } from './site-sections.ts'
38
+ import { TarballWriter } from './tar.ts'
39
+
40
+ export interface SiteExportOptions {
41
+ /** The `.tgz` to write. */
42
+ out: string
43
+ /** False for `--no-media`: the media nodes travel, their files do not. */
44
+ media?: boolean
45
+ /** Carry the latest drafts rather than what is published. */
46
+ drafts?: boolean
47
+ withMembers?: boolean
48
+ withUsers?: boolean
49
+ /** Replace every personal value before anything is written (`sanitise`). */
50
+ anonymise?: boolean
51
+ /** Seals the people; needed when any travel. */
52
+ passphrase?: string
53
+ package?: { name?: string; version?: string }
54
+ }
55
+
56
+ export interface SiteExportResult {
57
+ out: string
58
+ bundleId: string
59
+ package: { name: string; version: string }
60
+ counts: Record<string, number>
61
+ sections: Partial<Record<keyof typeof BUNDLE_SECTIONS, number>>
62
+ media: { carried: number; bytes: number; missing: string[]; omitted: boolean }
63
+ people: { members: number; users: number }
64
+ /** How many distinct email addresses `--anonymise` replaced. */
65
+ anonymised?: number
66
+ }
67
+
68
+ /** The content kinds a whole site carries, each from its tree's top. */
69
+ const SITE_TREES = [
70
+ ObjectTypes.Document,
71
+ ObjectTypes.Media,
72
+ ObjectTypes.Element,
73
+ ObjectTypes.DocumentBlueprint,
74
+ ]
75
+
76
+ async function topLevelNodes(db: Db): Promise<NodeRow[]> {
77
+ const nodes = new NodeRepository(db)
78
+ const roots: NodeRow[] = []
79
+ for (const tree of SITE_TREES) {
80
+ for (let skip = 0; ; skip += 500) {
81
+ const page = await nodes.roots(tree, skip, 500)
82
+ roots.push(...page.items)
83
+ if (skip + 500 >= page.total) break
84
+ }
85
+ }
86
+ return roots
87
+ }
88
+
89
+ /** The server bundles the site wires, by package name and version. */
90
+ function requiredPackages(config: CromboloConfig): Record<string, string> {
91
+ if (config.bundles.length === 0) return {}
92
+ const wired = new Set(config.bundles.map((bundle) => bundle.id))
93
+ const required: Record<string, string> = {}
94
+ for (const found of discoverBundles(config.siteDir).bundles)
95
+ if (wired.has(found.id)) required[found.packageName] = found.version
96
+ return required
97
+ }
98
+
99
+ export async function exportSite(
100
+ db: Db,
101
+ config: CromboloConfig,
102
+ options: SiteExportOptions,
103
+ ): Promise<SiteExportResult> {
104
+ const withMedia = options.media !== false
105
+ const carryMembers = Boolean(options.withMembers)
106
+ // Anonymised users are removed, so there are none to carry.
107
+ const carryUsers = Boolean(options.withUsers) && !options.anonymise
108
+ if ((carryMembers || carryUsers) && !options.passphrase)
109
+ throw new Error('Members and users travel sealed: give a passphrase.')
110
+
111
+ const exported = await exportBundle(db, {
112
+ roots: await topLevelNodes(db),
113
+ asGiven: [],
114
+ descendants: true,
115
+ snapshot: options.drafts ? 'drafts' : 'published',
116
+ blueprints: true,
117
+ withBlobs: withMedia,
118
+ siteName: config.siteName,
119
+ nodeId: config.nodeId,
120
+ })
121
+
122
+ let sections: BundleFile[] = await siteSections(
123
+ {
124
+ db,
125
+ componentFiles: createTemplateFileStore(config.componentsDir),
126
+ components: createFileSystemPort(config.componentsDir, {
127
+ extension: '.tsx',
128
+ rewrites: ['.cshtml'],
129
+ }),
130
+ stylesheets: createFileSystemPort(config.stylesheetsDir, { extension: '.css' }),
131
+ scripts: createFileSystemPort(config.scriptsDir, { extension: '.js' }),
132
+ schemaDir: config.schemaDir,
133
+ },
134
+ 'all',
135
+ )
136
+
137
+ let set = exported.set
138
+ let people =
139
+ carryMembers || carryUsers
140
+ ? await exportPeople(db, { members: carryMembers, users: carryUsers })
141
+ : undefined
142
+ let anonymised: number | undefined
143
+ if (options.anonymise) {
144
+ const dictionaryPath = `${BUNDLE_SECTIONS.dictionary}.udt`
145
+ const dictionary = sections.find((file) => file.path === dictionaryPath)
146
+ const clean = sanitise({ set, people, dictionary: dictionary?.text })
147
+ set = clean.set
148
+ people = clean.people
149
+ anonymised = clean.emails
150
+ if (dictionary && clean.dictionary !== undefined)
151
+ sections = sections.map((file) =>
152
+ file.path === dictionaryPath ? { ...file, text: clean.dictionary } : file,
153
+ )
154
+ }
155
+
156
+ // Each media file is looked up once: its size goes in the manifest now, its
157
+ // bytes stream into the tarball later.
158
+ const store = await mediaStoreFor(config)
159
+ const stored = new Map<string, StoredFile>()
160
+ const missing: string[] = []
161
+ if (withMedia)
162
+ for (const key of exported.blobKeys) {
163
+ const file = await store.get(key)
164
+ if (file) stored.set(key, file)
165
+ else missing.push(key)
166
+ }
167
+
168
+ const site = {
169
+ ...set,
170
+ manifest: {
171
+ ...set.manifest,
172
+ label: `${config.siteName} (whole site)`,
173
+ provenance: { ...set.manifest.provenance, cmsVersion: VERSION },
174
+ scope: 'site' as const,
175
+ ...(withMedia ? {} : { media: 'omitted' as const }),
176
+ requires: requiredPackages(config),
177
+ },
178
+ }
179
+ const seal = people && options.passphrase ? newSeal(options.passphrase) : undefined
180
+ const planned = planStreamedBundle(
181
+ site,
182
+ new Map([...stored].map(([key, file]) => [key, file.size])),
183
+ sections,
184
+ { people, seal },
185
+ )
186
+
187
+ const name = options.package?.name ?? packageNameFor(`${config.siteName}-site`)
188
+ const info: BundlePackageInfo = {
189
+ name,
190
+ version: options.package?.version ?? '1.0.0',
191
+ id: bundleIdFor(name.replace(/^@[^/]+\//, '')),
192
+ label: site.manifest.label,
193
+ private: true,
194
+ peerRange: `>=${VERSION}`,
195
+ }
196
+
197
+ const partial = `${options.out}.partial`
198
+ const writer = TarballWriter.create(partial)
199
+ const encoder = new TextEncoder()
200
+ const hasher = new Bun.CryptoHasher('sha256')
201
+ let bytes = 0
202
+ try {
203
+ const small = new Map(planned.files.map((file) => [file.path, file]))
204
+ const blobs = new Map(planned.blobs.map((key) => [blobPath(key), key]))
205
+ const paths = [...small.keys(), ...blobs.keys()].sort((a, b) => a.localeCompare(b))
206
+ for (const path of paths) {
207
+ hasher.update(path)
208
+ hasher.update('\0')
209
+ const file = small.get(path)
210
+ if (file) {
211
+ const body = file.bytes ?? encoder.encode(file.text ?? '')
212
+ hasher.update(body)
213
+ await writer.add(`${PAYLOAD_DIR}/${path}`, body)
214
+ } else {
215
+ const media = stored.get(blobs.get(path) as string) as StoredFile
216
+ async function* hashed() {
217
+ for await (const chunk of streamOf(media)) {
218
+ hasher.update(chunk)
219
+ yield chunk
220
+ }
221
+ }
222
+ await writer.addStream(`${PAYLOAD_DIR}/${path}`, media.size, hashed())
223
+ bytes += media.size
224
+ }
225
+ hasher.update('\n')
226
+ }
227
+ const manifest = { ...planned.manifest, integrity: `sha256-${hasher.digest('hex')}` }
228
+ await writer.add(`${PAYLOAD_DIR}/${MANIFEST_FILE}`, encoder.encode(writeManifest(manifest)))
229
+ await writer.add(
230
+ 'package.json',
231
+ encoder.encode(`${JSON.stringify(bundlePackageJson(info), null, 2)}\n`),
232
+ )
233
+ await writer.close()
234
+ await rename(partial, options.out)
235
+ } catch (error) {
236
+ await writer.close().catch(() => {})
237
+ await rm(partial, { force: true })
238
+ throw error
239
+ }
240
+
241
+ const counted: SiteExportResult['sections'] = {}
242
+ for (const file of sections) {
243
+ const section = (Object.keys(BUNDLE_SECTIONS) as (keyof typeof BUNDLE_SECTIONS)[]).find(
244
+ (s) => file.path === `${BUNDLE_SECTIONS[s]}.udt` || file.path.startsWith(`${s}/`),
245
+ )
246
+ if (section) counted[section] = (counted[section] ?? 0) + 1
247
+ }
248
+ return {
249
+ out: options.out,
250
+ bundleId: site.manifest.id,
251
+ package: { name: info.name, version: info.version },
252
+ counts: { ...site.manifest.counts },
253
+ sections: counted,
254
+ media: { carried: stored.size, bytes, missing, omitted: !withMedia },
255
+ people: { members: people?.members.length ?? 0, users: people?.users.length ?? 0 },
256
+ ...(anonymised !== undefined ? { anonymised } : {}),
257
+ }
258
+ }