@crombolo/server 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.
@@ -25,11 +25,13 @@ import {
25
25
  } from '@crombolo/backoffice-host'
26
26
  import { ObjectTypes } from '@crombolo/core'
27
27
  import {
28
+ answersByNode,
28
29
  type Db,
29
30
  describeRefFailure,
30
31
  type NodeRow,
31
32
  recordInLedger,
32
33
  resolveNodeRef,
34
+ TransferAnswerRepository,
33
35
  writeReport,
34
36
  } from '@crombolo/data'
35
37
  import {
@@ -40,8 +42,15 @@ import {
40
42
  isPackageName,
41
43
  loadBundle,
42
44
  loadResolutions,
45
+ mergeResolutions,
46
+ type NodeResolutions,
47
+ openBundle,
43
48
  type Resolution,
49
+ rehearsePublish,
50
+ SealedFileError,
44
51
  type TransferCheck,
52
+ WrongPassphraseError,
53
+ writeResolutions,
45
54
  } from '@crombolo/transfer'
46
55
  import { type BackupResult, backupBefore } from './backup.ts'
47
56
  import {
@@ -75,6 +84,9 @@ export class CommandError extends Error {
75
84
  }
76
85
  }
77
86
 
87
+ /** The bundle is encrypted, and the passphrase given does not open it, or none was. */
88
+ export class PassphraseError extends CommandError {}
89
+
78
90
  export interface BundleSummary {
79
91
  id: string
80
92
  from: string
@@ -95,10 +107,14 @@ export interface ResolvedBundle {
95
107
  payloadDir: string
96
108
  /** Whether it was found as one of the site's dependencies rather than a directory. */
97
109
  dependency: boolean
110
+ /** The package's directory. */
111
+ directory: string
98
112
  set: ContentSet
99
113
  blobs: ReadonlyMap<string, string>
100
114
  /** The sections it carries beyond its content; empty for a content-only bundle. */
101
115
  carried: CarriedSections
116
+ /** A carried media file's bytes as exported: opened, when the bundle sealed them. */
117
+ openBlob: (key: string, bytes: Uint8Array) => Uint8Array
102
118
  }
103
119
 
104
120
  /** Where a package directory or a dependency's name points, before anything is read. */
@@ -122,7 +138,7 @@ function locate(target: string, siteDir: string): { directory: string; dependenc
122
138
  * The bundle a path or a package name names, read whole, or a refusal: a
123
139
  * half-copied bundle must never be checked as though complete.
124
140
  */
125
- export function readBundle(target: string, siteDir: string): ResolvedBundle {
141
+ export function readBundle(target: string, siteDir: string, passphrase?: string): ResolvedBundle {
126
142
  const { directory, dependency } = locate(target, siteDir)
127
143
  const json = readPackageJson(join(directory, 'package.json'))
128
144
  if (!json) {
@@ -154,13 +170,34 @@ export function readBundle(target: string, siteDir: string): ResolvedBundle {
154
170
  'The bundle is incomplete or damaged; nothing was read from it.',
155
171
  problems,
156
172
  )
173
+ // Opened only when given the passphrase: without one the set stays sealed,
174
+ // and the check says what it needs rather than comparing what it cannot read.
175
+ let opened: ReturnType<typeof openBundle> = {
176
+ set: loaded.set,
177
+ people: undefined,
178
+ openBlob: (_key, bytes) => bytes,
179
+ }
180
+ if (passphrase !== undefined && passphrase !== '')
181
+ try {
182
+ opened = openBundle(loaded, passphrase)
183
+ } catch (error) {
184
+ if (error instanceof WrongPassphraseError)
185
+ throw new PassphraseError(
186
+ 'The passphrase does not open this bundle; nothing was read from it.',
187
+ )
188
+ if (error instanceof SealedFileError)
189
+ throw new CommandError('The bundle is damaged; nothing was read from it.', [error.message])
190
+ throw error
191
+ }
157
192
  return {
158
193
  package: pkg,
159
194
  payloadDir: pkg.contentDir,
160
195
  dependency,
161
- set: loaded.set,
196
+ directory,
197
+ set: opened.set,
162
198
  blobs: loaded.blobs,
163
199
  carried: carriedSections(loaded),
200
+ openBlob: opened.openBlob,
164
201
  }
165
202
  }
166
203
 
@@ -200,13 +237,22 @@ export interface TransferInput extends SectionOptions {
200
237
  bundle: string
201
238
  /** Where the bundle's roots go: a path or a key. */
202
239
  under?: string
203
- /** A decision per node key, on top of any the bundle carries. */
204
- resolutions?: Record<string, Resolution>
240
+ /** Decisions by node key, on top of any the bundle carries: `"*"` and per property. */
241
+ resolutions?: Record<string, NodeResolutions>
205
242
  resolveAll?: Resolution
206
243
  allowMissingBlobs?: boolean
244
+ /** Publish what was live at the source; the check then says which pages would be refused. */
245
+ publish?: boolean
246
+ /** Who asked, recorded on the pending import. */
247
+ by?: string
248
+ /** Opens what the bundle sealed; used for this request only, never stored. */
249
+ passphrase?: string
207
250
  }
208
251
 
209
- /** The options `check` and `import` share: saved decisions, then the caller's, then placement. */
252
+ /**
253
+ * The options `check` and `import` share: the bundle's saved decisions, then
254
+ * those given in the backoffice, then the caller's; then placement.
255
+ */
210
256
  export async function transferOptions(
211
257
  db: Db,
212
258
  config: CromboloConfig,
@@ -214,7 +260,16 @@ export async function transferOptions(
214
260
  input: Omit<TransferInput, 'bundle'>,
215
261
  ): Promise<{ options: CheckOptions; under: NodeRow | undefined; problems: string[] }> {
216
262
  const saved = loadResolutions(bundle.payloadDir, bundle.set.manifest.id)
217
- const resolutions = { ...(saved.file?.byNode ?? {}), ...(input.resolutions ?? {}) }
263
+ const answers = new TransferAnswerRepository(db)
264
+ const given = answersByNode(await answers.answers(bundle.set.manifest.id)) as Record<
265
+ string,
266
+ NodeResolutions
267
+ >
268
+ const pending = await answers.pending(bundle.set.manifest.id)
269
+ const resolutions = mergeResolutions(
270
+ mergeResolutions(saved.file?.byNode ?? {}, given),
271
+ input.resolutions ?? {},
272
+ )
218
273
  const resolveAll = input.resolveAll ?? saved.file?.all ?? undefined
219
274
  const underRef = input.under ?? saved.file?.under ?? undefined
220
275
  let under: NodeRow | undefined
@@ -232,7 +287,7 @@ export async function transferOptions(
232
287
  resolutions,
233
288
  resolveAll,
234
289
  componentAliases: componentAliasesIn(config.componentsDir),
235
- allowMissingBlobs: input.allowMissingBlobs ?? false,
290
+ allowMissingBlobs: input.allowMissingBlobs ?? pending?.allowMissingBlobs ?? false,
236
291
  hasBlob: async (key: string) => Boolean(await store.get(key)),
237
292
  backOfficePath: config.backOfficePath,
238
293
  },
@@ -251,8 +306,10 @@ export interface ContentCheckResult {
251
306
  sections: SectionPlan | undefined
252
307
  /** Nothing outstanding: the bundle would import cleanly. */
253
308
  clean: boolean
309
+ /** With `publish`: the pages the import would not publish, and the reason publish gives. */
310
+ publishRefusals: { key: string; reason: string }[]
254
311
  /** The decisions this check ran with — the bundle's saved ones under the caller's. */
255
- decisions: { all: Resolution | null; byNode: Record<string, Resolution> }
312
+ decisions: { all: Resolution | null; byNode: Record<string, NodeResolutions> }
256
313
  problems: string[]
257
314
  }
258
315
 
@@ -262,10 +319,30 @@ export async function checkBundleContent(
262
319
  config: CromboloConfig,
263
320
  input: TransferInput,
264
321
  ): Promise<ContentCheckResult> {
265
- const bundle = readBundle(input.bundle, config.siteDir)
322
+ const bundle = readBundle(input.bundle, config.siteDir, input.passphrase)
266
323
  const set = bundle.set
267
324
  const prepared = await transferOptions(db, config, bundle, input)
268
325
  const check = await checkBundle(db, set, prepared.options)
326
+ await recordPending(db, bundle, input, prepared.options.allowMissingBlobs ?? false)
327
+ // Rehearsed rather than predicted, so the refusals are publish's own, word
328
+ // for word; only once nothing is outstanding, since the import stops there.
329
+ const refusals =
330
+ input.publish && check.outstanding.length === 0
331
+ ? ((await rehearsePublish(db, set, prepared.options)) ?? [])
332
+ : []
333
+ const names = new Map(set.nodes.map((node) => [node.key, node.variants[0]?.name ?? node.key]))
334
+ for (const refusal of refusals)
335
+ check.findings.push({
336
+ kind: 'auto',
337
+ code: 'publish-refused',
338
+ subjectType: 'document',
339
+ subjectKey: refusal.key,
340
+ subjectName: names.get(refusal.key) ?? refusal.key,
341
+ propertyAlias: null,
342
+ culture: null,
343
+ message: `"${names.get(refusal.key) ?? refusal.key}" would not be published: ${refusal.reason}`,
344
+ link: null,
345
+ })
269
346
  // Under this bundle's own scope, so two bundles in flight do not resolve each other's.
270
347
  await writeReport(db, check.findings, { source: 'transfer', scope: set.manifest.id })
271
348
  return {
@@ -277,9 +354,10 @@ export async function checkBundleContent(
277
354
  ? await planSections(db, config, bundle.carried, input)
278
355
  : undefined,
279
356
  clean: check.outstanding.length === 0,
357
+ publishRefusals: refusals,
280
358
  decisions: {
281
359
  all: prepared.options.resolveAll ?? null,
282
- byNode: prepared.options.resolutions ?? {},
360
+ byNode: { ...(prepared.options.resolutions ?? {}) },
283
361
  },
284
362
  problems: prepared.problems,
285
363
  }
@@ -314,14 +392,66 @@ export interface ContentImportResult {
314
392
  problems: string[]
315
393
  }
316
394
 
395
+ /**
396
+ * What the dashboard needs to run this bundle's import again: where it was
397
+ * found and what it was asked to do. A directory is this server's path.
398
+ */
399
+ async function recordPending(
400
+ db: Db,
401
+ bundle: ResolvedBundle,
402
+ input: TransferInput,
403
+ allowMissingBlobs: boolean,
404
+ ): Promise<void> {
405
+ const manifest = bundle.set.manifest
406
+ await new TransferAnswerRepository(db).recordCheck({
407
+ bundleId: manifest.id,
408
+ label: manifest.label ?? (bundle.package.name || bundle.package.packageName),
409
+ target: bundle.dependency ? bundle.package.packageName : bundle.directory,
410
+ targetKind: bundle.dependency ? 'package' : 'directory',
411
+ under: input.under ?? null,
412
+ publish: input.publish ?? false,
413
+ allowMissingBlobs,
414
+ checkedAt: new Date(),
415
+ checkedBy: input.by ?? null,
416
+ })
417
+ }
418
+
419
+ /**
420
+ * The decisions an import of this bundle would run with — saved, then given
421
+ * in the backoffice — as the `resolutions.json` the CLI's `--save` writes.
422
+ */
423
+ export async function pendingResolutionsFile(
424
+ db: Db,
425
+ config: CromboloConfig,
426
+ bundleId: string,
427
+ ): Promise<string | undefined> {
428
+ const pending = await new TransferAnswerRepository(db).pending(bundleId)
429
+ if (!pending) return undefined
430
+ const bundle = readBundle(pending.target, config.siteDir)
431
+ const saved = loadResolutions(bundle.payloadDir, bundleId)
432
+ const given = answersByNode(await new TransferAnswerRepository(db).answers(bundleId))
433
+ return writeResolutions({
434
+ bundleId,
435
+ under: pending.under ?? saved.file?.under ?? null,
436
+ all: saved.file?.all ?? null,
437
+ byNode: mergeResolutions(saved.file?.byNode ?? {}, given as Record<string, NodeResolutions>),
438
+ })
439
+ }
440
+
317
441
  /** Back up, re-check, then apply in one transaction and record the run. */
318
442
  export async function installBundleContent(
319
443
  db: Db,
320
444
  config: CromboloConfig,
321
445
  input: ContentImportInput,
322
446
  ): Promise<ContentImportResult> {
323
- const bundle = readBundle(input.bundle, config.siteDir)
447
+ const bundle = readBundle(input.bundle, config.siteDir, input.passphrase)
324
448
  const { set, blobs, carried } = bundle
449
+ // Before the backup and the sections: nothing of a bundle that cannot be
450
+ // opened is applied.
451
+ if (set.sealed)
452
+ throw new PassphraseError(
453
+ 'This bundle is encrypted: give the passphrase it was exported with. Nothing was written.',
454
+ )
325
455
  // The coarse layer, under the fine-grained revert: in-place conversion is not
326
456
  // reversible by itself, so the same rule as `upgrade` applies here.
327
457
  const backup = await backupBefore(config, {
@@ -402,10 +532,15 @@ export async function installBundleContent(
402
532
  problems: prepared.problems,
403
533
  sections,
404
534
  }
405
- if (!result.runId) return outcome
535
+ if (!result.runId) {
536
+ await recordPending(db, bundle, input, prepared.options.allowMissingBlobs ?? false)
537
+ return outcome
538
+ }
539
+ await new TransferAnswerRepository(db).clear(set.manifest.id)
406
540
  // The backups become the run's, so `content revert` can find them.
407
541
  if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
408
- if (blobs.size > 0) outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs)
542
+ if (blobs.size > 0)
543
+ outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs, bundle.openBlob)
409
544
  await recordInLedger(db, {
410
545
  name: `content import ${set.manifest.id}`,
411
546
  kind: 'transfer',
package/src/bundles.ts CHANGED
@@ -40,6 +40,7 @@
40
40
  * Deliberately not offered: raw SQL, a `Db`, and anything that writes the
41
41
  * site's files.
42
42
  */
43
+
43
44
  import { hasCultureAccess, hasSection, type Principal } from '@crombolo/api-management'
44
45
  import { BUNDLE_ID, type BundlePackage, peerRangeProblem } from '@crombolo/backoffice-host'
45
46
  import {
@@ -60,6 +61,8 @@ import {
60
61
  type RedirectTargetKind,
61
62
  } from '@crombolo/data'
62
63
  import { normaliseRedirectPath, type PublishedCache } from '@crombolo/render'
64
+ import type { BundleForms } from './form-types.ts'
65
+ import type { BundleEvents } from './webhooks.ts'
63
66
 
64
67
  /** The segment every bundle's routes hang below, inside the plugin API. */
65
68
  export const BUNDLE_API_SEGMENT = 'bundle'
@@ -232,6 +235,17 @@ export interface ServerBundle<
232
235
  routes: readonly ServerBundleRoute<C, T>[]
233
236
  /** Tables the bundle owns, from `defineBundleTables`, under its own id. */
234
237
  tables?: T
238
+ /**
239
+ * Form field and workflow types it adds. Unlike its routes and tables these
240
+ * are honoured on every node, because forms render and submit on `web` nodes.
241
+ */
242
+ forms?: BundleForms
243
+ /**
244
+ * Handlers for webhook events, by alias. Each event reaches each handler
245
+ * through the webhook delivery queue: once per cluster, after the change,
246
+ * retried on failure, and in the delivery log (docs/webhooks.md).
247
+ */
248
+ events?: BundleEvents
235
249
  }
236
250
 
237
251
  /** A bundle as mounted, with the prefix its routes answer below. */
package/src/busy.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** Contention a write could not get past, answered as a retryable 503 rather than a 500. */
2
+ import { problemResponse, serviceBusy } from '@crombolo/core'
3
+ import { ContentBusyError, classifyContention } from '@crombolo/data'
4
+
5
+ /** The response for an error that is lock contention; undefined for anything else. */
6
+ export function busyResponse(error: unknown): Response | undefined {
7
+ const contention = classifyContention(error)
8
+ if (!contention) return undefined
9
+ const busy = error instanceof ContentBusyError ? error : new ContentBusyError(contention)
10
+ return problemResponse(serviceBusy(busy.message), {
11
+ 'retry-after': String(busy.retryAfterSeconds),
12
+ })
13
+ }
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { hostname, tmpdir } from 'node:os'
2
2
  import { join, resolve } from 'node:path'
3
+ import type { DeliveryApiOptions } from '@crombolo/api-delivery'
3
4
  import { DEFAULT_BACKOFFICE_PATH } from '@crombolo/core'
4
5
  import { DEFAULT_RENDER_TIMEOUT_MS, type RedirectRule, type SnapshotLimits } from '@crombolo/render'
5
6
  import type { AssistantConfig } from './assistant.ts'
@@ -65,8 +66,15 @@ export interface CromboloConfig {
65
66
  /** SQLite file, or ':memory:'. Ignored when CROMBOLO_DB=postgres. */
66
67
  sqliteFile: string
67
68
  adminLogin: string
68
- /** When unset, a password is generated on first run and printed once. */
69
+ /**
70
+ * Seeds the first administrator of a fresh database. Unset in development, one
71
+ * is generated and printed; unset in production, the site waits to be set up.
72
+ */
69
73
  adminPassword: string | undefined
74
+ /** Guards "Set up your new site". Chosen by the owner; never printed, logged or shown. */
75
+ setupCode: string | undefined
76
+ /** Seals secrets kept in the database, such as a webhook's headers; never stored. */
77
+ secretKey: string | undefined
70
78
  /**
71
79
  * The `__Host-` cookie prefix requires Secure. Browsers treat http://localhost
72
80
  * as secure, so this stays on in local development.
@@ -242,6 +250,13 @@ export interface CromboloConfig {
242
250
  * answers on. Every field may also be set in the backoffice, which wins.
243
251
  */
244
252
  seo: SeoConfig
253
+ /**
254
+ * Umbraco's Delivery API (`docs/delivery-api.md`): off unless enabled.
255
+ * `CROMBOLO_DELIVERY_API`, `CROMBOLO_DELIVERY_API_KEY`,
256
+ * `CROMBOLO_DELIVERY_API_PUBLIC` and `CROMBOLO_DELIVERY_API_MEDIA`; member
257
+ * sign-in is configured here, since its clients and secrets are a list.
258
+ */
259
+ deliveryApi: DeliveryApiOptions
245
260
  /**
246
261
  * Bundles whose server half this site runs, each the result of calling the
247
262
  * factory its package exports. Imported and listed here by hand on purpose:
@@ -393,6 +408,8 @@ export function loadConfig(
393
408
  sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
394
409
  adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
395
410
  adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
411
+ setupCode: Bun.env.CROMBOLO_SETUP_CODE,
412
+ secretKey: Bun.env.CROMBOLO_SECRET_KEY,
396
413
  secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
397
414
  siteName: Bun.env.CROMBOLO_SITE_NAME ?? DEFAULTS.siteName,
398
415
  siteDir: cwd,
@@ -482,6 +499,15 @@ export function loadConfig(
482
499
  sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
483
500
  indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
484
501
  }),
502
+ deliveryApi: stripUndefined({
503
+ enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API),
504
+ apiKey: Bun.env.CROMBOLO_DELIVERY_API_KEY || undefined,
505
+ publicAccess: envFlag(Bun.env.CROMBOLO_DELIVERY_API_PUBLIC),
506
+ media:
507
+ envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) === undefined
508
+ ? undefined
509
+ : { enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) },
510
+ }),
485
511
  // No environment variable: a bundle is an import, which is code.
486
512
  bundles: [],
487
513
  marketplaceUrl: Bun.env.CROMBOLO_MARKETPLACE_URL ?? DEFAULTS.marketplaceUrl,
@@ -493,6 +519,11 @@ export function loadConfig(
493
519
  const merged: CromboloConfig = { ...fromEnv, ...stripUndefined(overrides) }
494
520
  // Field by field, so a site setting its title suffix keeps the environment's switches.
495
521
  merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
522
+ merged.deliveryApi = {
523
+ ...fromEnv.deliveryApi,
524
+ ...stripUndefined(overrides.deliveryApi ?? {}),
525
+ media: { ...fromEnv.deliveryApi.media, ...(overrides.deliveryApi?.media ?? {}) },
526
+ }
496
527
  // A schema store makes backoffice writes durable, so it carries writability with
497
528
  // it — that is the point of configuring one. An explicit setting still wins.
498
529
  if (
@@ -566,4 +597,4 @@ export function describeDatabase(
566
597
  }
567
598
  }
568
599
 
569
- export const VERSION = '0.11.0'
600
+ export const VERSION = '0.12.1'
package/src/database.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  ensureSystemMediaTypes,
15
15
  ensureSystemMemberTypes,
16
16
  INITIAL_STATE,
17
+ markSetupPending,
17
18
  migrate,
18
19
  pendingMigrations,
19
20
  readState,
@@ -125,17 +126,18 @@ export async function installDatabase(
125
126
  // Core first, so a bundle step always runs against a core it declared itself compatible with.
126
127
  await migrateBundles(db, chains, { by: config.nodeId })
127
128
 
129
+ // In production a password is never made up: the pipeline supplies one, or
130
+ // the owner sets the site up in the browser (docs/operations.md).
131
+ const settingUp = !config.development && !config.adminPassword
128
132
  const password = config.adminPassword ?? crypto.randomUUID().replaceAll('-', '').slice(0, 20)
129
133
  const seeded = await seedIdentity(db, {
130
- admin: {
131
- name: 'Administrator',
132
- login: config.adminLogin,
133
- email: config.adminLogin,
134
- password,
135
- },
134
+ admin: settingUp
135
+ ? undefined
136
+ : { name: 'Administrator', login: config.adminLogin, email: config.adminLogin, password },
136
137
  hashPassword,
137
138
  passwordConfig: passwordConfigJson(),
138
139
  })
140
+ if (seeded && settingUp) await markSetupPending(db)
139
141
 
140
142
  await seedContent(db)
141
143
  // A database from an older version gains the built-ins it lacks.
@@ -144,5 +146,5 @@ export async function installDatabase(
144
146
  await ensureSystemMemberTypes(db)
145
147
  await ensureSystemContentTypes(db)
146
148
 
147
- return seeded && !config.adminPassword ? password : undefined
149
+ return seeded && !settingUp && !config.adminPassword ? password : undefined
148
150
  }