@crombolo/server 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.
Files changed (44) hide show
  1. package/package.json +12 -11
  2. package/src/adapters/audit.ts +186 -0
  3. package/src/adapters/content.ts +2 -0
  4. package/src/adapters/documents.ts +28 -25
  5. package/src/adapters/elements.ts +3 -0
  6. package/src/adapters/health.ts +333 -0
  7. package/src/adapters/media.ts +3 -0
  8. package/src/adapters/members.ts +10 -2
  9. package/src/adapters/references.ts +13 -0
  10. package/src/adapters/relations.ts +48 -0
  11. package/src/adapters/schema-files.ts +41 -11
  12. package/src/adapters/schema-proposals.ts +13 -6
  13. package/src/adapters/search-index.ts +31 -0
  14. package/src/adapters/webhooks.ts +76 -0
  15. package/src/boot-failure.ts +147 -0
  16. package/src/bundle-install.ts +148 -13
  17. package/src/bundles.ts +14 -0
  18. package/src/busy.ts +13 -0
  19. package/src/config.ts +46 -2
  20. package/src/database.ts +9 -7
  21. package/src/delivery-members.ts +387 -0
  22. package/src/editor-types.ts +7 -2
  23. package/src/extensions-notice.ts +3 -2
  24. package/src/form-files.ts +38 -3
  25. package/src/form-submit.ts +62 -5
  26. package/src/form-types.ts +187 -0
  27. package/src/form-workflows.ts +66 -6
  28. package/src/forms.ts +15 -7
  29. package/src/index.ts +12 -0
  30. package/src/jobs.ts +91 -10
  31. package/src/media-files.ts +3 -1
  32. package/src/member-auth.ts +17 -1
  33. package/src/models-builder.ts +12 -8
  34. package/src/ports.ts +57 -2
  35. package/src/schema.ts +58 -11
  36. package/src/secrets.ts +36 -0
  37. package/src/server.ts +388 -29
  38. package/src/setup.ts +288 -0
  39. package/src/status.ts +5 -2
  40. package/src/transfer-dashboard.ts +219 -0
  41. package/src/upgrade-summary.ts +43 -0
  42. package/src/url-settings.ts +91 -0
  43. package/src/webhook-files.ts +138 -0
  44. package/src/webhooks.ts +544 -0
@@ -0,0 +1,31 @@
1
+ /** The Examine dashboard's port over the search index. docs/search.md. */
2
+ import type { SearchIndexInfo, SearchIndexPort } from '@crombolo/api-management'
3
+ import { type Db, SEARCH_INDEXES, SearchIndex, type SearchIndexDefinition } from '@crombolo/data'
4
+
5
+ export function createSearchIndexPort(db: Db, onQueued?: () => void): SearchIndexPort {
6
+ const search = new SearchIndex(db)
7
+ const info = async (index: SearchIndexDefinition): Promise<SearchIndexInfo> => ({
8
+ name: index.name,
9
+ searcherName: index.searcherName,
10
+ ...(await search.stats(index)),
11
+ })
12
+ return {
13
+ indexes: () => Promise.all(SEARCH_INDEXES.map(info)),
14
+ async index(name) {
15
+ const index = SearchIndex.definition(name)
16
+ return index ? info(index) : undefined
17
+ },
18
+ async rebuild(name) {
19
+ const index = SearchIndex.definition(name)
20
+ if (!index) return false
21
+ await search.rebuild(index)
22
+ onQueued?.()
23
+ return true
24
+ },
25
+ searchers: () => SEARCH_INDEXES.map((index) => index.searcherName),
26
+ async query(searcherName, term, paging) {
27
+ const index = SearchIndex.bySearcher(searcherName)
28
+ return index ? search.query(index, term, paging) : undefined
29
+ },
30
+ }
31
+ }
@@ -0,0 +1,76 @@
1
+ /** Webhooks, shaped for the Management API: secrets masked, logs as Umbraco's client reads them. */
2
+ import type { WebhookPort, WebhookPortView } from '@crombolo/api-management'
3
+ import { nameBasedUuid, type WebhookLogEntry } from '@crombolo/data'
4
+ import { envReference } from '../webhook-files.ts'
5
+ import { MASKED, type WebhookService, type WebhookView } from '../webhooks.ts'
6
+
7
+ /** A typed-in secret never leaves the server; an `env:` reference is not a secret. */
8
+ const masked = (headers: Record<string, string>) =>
9
+ Object.fromEntries(
10
+ Object.entries(headers).map(([name, value]) => [name, envReference(value) ? value : MASKED]),
11
+ )
12
+
13
+ function toView(webhook: WebhookView): WebhookPortView {
14
+ return {
15
+ id: webhook.key,
16
+ name: webhook.name,
17
+ description: webhook.description,
18
+ url: webhook.url,
19
+ enabled: webhook.enabled,
20
+ events: webhook.events,
21
+ contentTypeKeys: webhook.contentTypeKeys,
22
+ headers: masked(webhook.headers),
23
+ }
24
+ }
25
+
26
+ /** A bundle's handler has no webhook key, so its log rows take a stable one of their own. */
27
+ const subscriberKey = (subscriber: string) =>
28
+ subscriber.startsWith('bundle:') ? nameBasedUuid(subscriber) : subscriber
29
+
30
+ function toLog(entry: WebhookLogEntry) {
31
+ return {
32
+ key: entry.key,
33
+ webhookKey: subscriberKey(entry.subscriber),
34
+ statusCode: entry.exception
35
+ ? 'Exception'
36
+ : entry.statusCode === null
37
+ ? entry.success
38
+ ? 'Handled'
39
+ : 'Failed'
40
+ : String(entry.statusCode),
41
+ httpStatusCode: entry.statusCode,
42
+ isSuccessStatusCode: entry.success,
43
+ date: entry.date.toISOString(),
44
+ eventAlias: entry.eventAlias,
45
+ url: entry.url,
46
+ retryCount: entry.retryCount,
47
+ requestHeaders: entry.requestHeaders,
48
+ requestBody: entry.requestBody,
49
+ responseHeaders: entry.responseHeaders,
50
+ responseBody: entry.responseBody,
51
+ exceptionOccured: entry.exception,
52
+ }
53
+ }
54
+
55
+ export function createWebhookPort(service: WebhookService): WebhookPort {
56
+ return {
57
+ list: async () => (await service.list()).map(toView),
58
+ async get(id) {
59
+ const webhook = await service.get(id)
60
+ return webhook ? toView(webhook) : undefined
61
+ },
62
+ async create(input) {
63
+ const result = await service.create({ ...input, key: input.id ?? crypto.randomUUID() })
64
+ return result.ok ? { ok: true, id: result.key } : result
65
+ },
66
+ async update(id, input) {
67
+ const result = await service.update(id, { ...input, key: id })
68
+ return result.ok ? { ok: true, id: result.key } : result
69
+ },
70
+ remove: (id) => service.remove(id),
71
+ async logs(id, paging) {
72
+ const result = await service.logs(id?.toLowerCase(), paging)
73
+ return { total: result.total, items: result.items.map(toLog) }
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Why a server could not start, said so a person can fix it, and how long to
3
+ * wait before trying again. Pure: `crombolo start` serves the page and does the
4
+ * waiting. Nothing here may carry a secret — the database is named by host and
5
+ * database only, never by its URL.
6
+ */
7
+ import { MigrationStepError } from '@crombolo/data'
8
+ import { DatabaseNotReadyError, UpgradePendingError } from './database.ts'
9
+ import { SchemaBootError } from './schema.ts'
10
+
11
+ export interface BootFailure {
12
+ title: string
13
+ detail: string
14
+ /** The setting or command that fixes it. */
15
+ remedy: string
16
+ }
17
+
18
+ const sqlState = (error: { code?: unknown; errno?: unknown }) =>
19
+ typeof error.errno === 'string' ? error.errno : typeof error.code === 'string' ? error.code : ''
20
+
21
+ export function describeBootFailure(error: unknown, database: string): BootFailure {
22
+ if (error instanceof UpgradePendingError)
23
+ return {
24
+ title: 'An upgrade is waiting',
25
+ detail: `The database at ${database} needs upgrading before this version can use it.`,
26
+ remedy: 'Run `crombolo upgrade check`, then `crombolo upgrade`. This page retries by itself.',
27
+ }
28
+ if (error instanceof DatabaseNotReadyError)
29
+ return {
30
+ title: 'The database has not been prepared yet',
31
+ detail: `The database at ${database} is behind this version, and this server only reads.`,
32
+ remedy: 'Start the server that edits the site first, or run `crombolo upgrade`.',
33
+ }
34
+ if (error instanceof MigrationStepError)
35
+ return {
36
+ title: 'An upgrade step failed',
37
+ detail: error.message,
38
+ remedy: 'Fix the cause named above; the next attempt carries on from that step.',
39
+ }
40
+ if (error instanceof SchemaBootError)
41
+ return {
42
+ title: "The site's content types could not be loaded",
43
+ detail: error.message,
44
+ remedy: 'Correct the files named above in `schema/`, then deploy again.',
45
+ }
46
+ const e = (error ?? {}) as { code?: unknown; errno?: unknown; message?: unknown }
47
+ const state = sqlState(e)
48
+ if (state === 'ERR_POSTGRES_CONNECTION_REFUSED' || state === 'ECONNREFUSED')
49
+ return {
50
+ title: "Can't reach the database",
51
+ detail: `Nothing answered at ${database}.`,
52
+ remedy:
53
+ 'Check the host and port in `CROMBOLO_POSTGRES_URL`, and that the database server is running and reachable from here.',
54
+ }
55
+ if (state === '28P01' || state === '28000')
56
+ return {
57
+ title: 'The database refused the sign-in',
58
+ detail: `${database} did not accept the user name and password it was given.`,
59
+ remedy: 'Check the user name and password in `CROMBOLO_POSTGRES_URL`.',
60
+ }
61
+ if (state === '3D000')
62
+ return {
63
+ title: 'The database does not exist',
64
+ detail: `The server at ${database} has no database by that name.`,
65
+ remedy: 'Create the database, or correct its name in `CROMBOLO_POSTGRES_URL`.',
66
+ }
67
+ if (state === 'SQLITE_CANTOPEN')
68
+ return {
69
+ title: "Can't open the database file",
70
+ detail: `${database} could not be opened or created.`,
71
+ remedy:
72
+ 'Check `CROMBOLO_SQLITE_FILE`, and that its folder exists and this server may write to it.',
73
+ }
74
+ if (/no connection string/i.test(String(e.message ?? '')))
75
+ return {
76
+ title: 'No database is configured',
77
+ detail: 'Postgres was selected, but no connection string was given.',
78
+ remedy: 'Set `CROMBOLO_POSTGRES_URL`, or unset `CROMBOLO_DB` to use SQLite.',
79
+ }
80
+ return {
81
+ title: 'The site could not start',
82
+ detail: 'Something unexpected stopped it. The server log has the details.',
83
+ remedy: 'Read the latest error in the server log.',
84
+ }
85
+ }
86
+
87
+ /** Waits between attempts: doubling from `baseMs`, never longer than `capMs`. */
88
+ export function bootRetryDelays(attempts: number, baseMs = 2_000, capMs = 60_000): number[] {
89
+ return Array.from({ length: Math.max(0, attempts - 1) }, (_, i) =>
90
+ Math.min(capMs, baseMs * 2 ** i),
91
+ )
92
+ }
93
+
94
+ const escapeHtml = (value: string): string =>
95
+ value.replace(
96
+ /[&<>"']/g,
97
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string,
98
+ )
99
+
100
+ const withCode = (value: string) =>
101
+ escapeHtml(value).replace(/`([^`]+)`/g, (_, code: string) => `<code>${code}</code>`)
102
+
103
+ /** The page a starting server serves while it cannot start. */
104
+ export function bootFailurePage(
105
+ failure: BootFailure | undefined,
106
+ progress: { attempt: number; attempts: number; retryInSeconds: number | undefined },
107
+ ): string {
108
+ const title = failure?.title ?? 'The site is starting'
109
+ const next =
110
+ progress.retryInSeconds === undefined
111
+ ? 'It will not try again; the server is stopping.'
112
+ : `Trying again in ${progress.retryInSeconds} seconds (attempt ${progress.attempt} of ${progress.attempts}).`
113
+ return `<!doctype html>
114
+ <html lang="en">
115
+ <head>
116
+ <meta charset="utf-8">
117
+ <meta name="viewport" content="width=device-width, initial-scale=1">
118
+ <meta name="robots" content="noindex">
119
+ <meta http-equiv="refresh" content="10">
120
+ <title>${escapeHtml(title)}</title>
121
+ <style>
122
+ :root { color-scheme: light dark; }
123
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center;
124
+ font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
125
+ background: #f6f5f3; color: #1b1b1b; }
126
+ main { max-width: 34rem; padding: 2.5rem 1rem; }
127
+ h1 { font-size: 1.6rem; margin: 0 0 .75rem; }
128
+ p { margin: 0 0 1rem; white-space: pre-line; }
129
+ code { font-size: .9em; background: #e9e7e2; padding: .1em .35em; border-radius: 3px; }
130
+ .fine { font-size: .85rem; color: #6b6b63; }
131
+ @media (prefers-color-scheme: dark) {
132
+ body { background: #16171a; color: #e8e6e3; }
133
+ code { background: #26282c; }
134
+ .fine { color: #9a9a93; }
135
+ }
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <main>
140
+ <h1>${escapeHtml(title)}</h1>
141
+ ${failure ? `<p>${withCode(failure.detail)}</p>\n <p><strong>To fix it:</strong> ${withCode(failure.remedy)}</p>` : '<p>This takes a few seconds.</p>'}
142
+ <p class="fine">${escapeHtml(next)}</p>
143
+ </main>
144
+ </body>
145
+ </html>
146
+ `
147
+ }
@@ -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
+ }