@crombolo/server 0.11.0 → 0.13.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 (46) hide show
  1. package/package.json +12 -11
  2. package/src/adapters/bundles.ts +2 -1
  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/webhooks.ts +76 -0
  14. package/src/approvals-api.ts +189 -0
  15. package/src/approvals.ts +823 -0
  16. package/src/boot-failure.ts +147 -0
  17. package/src/bundle-install.ts +148 -13
  18. package/src/bundles.ts +14 -0
  19. package/src/busy.ts +13 -0
  20. package/src/config.ts +54 -2
  21. package/src/database.ts +9 -7
  22. package/src/delivery-members.ts +387 -0
  23. package/src/editor-types.ts +7 -2
  24. package/src/extensions-notice.ts +3 -2
  25. package/src/form-files.ts +38 -3
  26. package/src/form-submit.ts +62 -5
  27. package/src/form-types.ts +187 -0
  28. package/src/form-workflows.ts +66 -6
  29. package/src/forms.ts +15 -7
  30. package/src/index.ts +11 -0
  31. package/src/jobs.ts +17 -0
  32. package/src/marketplace.ts +18 -0
  33. package/src/media-files.ts +3 -1
  34. package/src/member-auth.ts +17 -1
  35. package/src/models-builder.ts +12 -8
  36. package/src/ports.ts +66 -2
  37. package/src/schema.ts +58 -11
  38. package/src/secrets.ts +36 -0
  39. package/src/server.ts +388 -57
  40. package/src/setup.ts +288 -0
  41. package/src/status.ts +5 -2
  42. package/src/transfer-dashboard.ts +219 -0
  43. package/src/upgrade-summary.ts +43 -0
  44. package/src/url-settings.ts +91 -0
  45. package/src/webhook-files.ts +138 -0
  46. package/src/webhooks.ts +544 -0
@@ -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
+ }
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:
@@ -257,6 +272,16 @@ export interface CromboloConfig {
257
272
  * deploy turns it off, and an install can decline it for itself.
258
273
  */
259
274
  applyBundleContentOnInstall: boolean
275
+ /**
276
+ * Whether the Packages section may install and remove packages on the running
277
+ * site (`bun add` on the node that served the request). Off, the Marketplace
278
+ * still lists packages and says to add them to `package.json` and deploy:
279
+ * what a site of several nodes, or of containers that start afresh, needs —
280
+ * an install there reaches one node and is lost at its next restart. On unless
281
+ * set, or unless a schema store is configured, which says the same thing
282
+ * about the nodes' disks. `CROMBOLO_RUNTIME_INSTALLS`.
283
+ */
284
+ runtimeInstalls: boolean
260
285
  /**
261
286
  * Where the Packages section's Marketplace sends someone browsing for
262
287
  * extensions. Never framed — npm refuses to be framed — so the native view
@@ -393,6 +418,8 @@ export function loadConfig(
393
418
  sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
394
419
  adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
395
420
  adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
421
+ setupCode: Bun.env.CROMBOLO_SETUP_CODE,
422
+ secretKey: Bun.env.CROMBOLO_SECRET_KEY,
396
423
  secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
397
424
  siteName: Bun.env.CROMBOLO_SITE_NAME ?? DEFAULTS.siteName,
398
425
  siteDir: cwd,
@@ -478,10 +505,21 @@ export function loadConfig(
478
505
  redirects: [],
479
506
  trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
480
507
  applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
508
+ // Resolved again below, once it is known whether a store was configured.
509
+ runtimeInstalls: Bun.env.CROMBOLO_RUNTIME_INSTALLS !== 'false',
481
510
  seo: stripUndefined({
482
511
  sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
483
512
  indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
484
513
  }),
514
+ deliveryApi: stripUndefined({
515
+ enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API),
516
+ apiKey: Bun.env.CROMBOLO_DELIVERY_API_KEY || undefined,
517
+ publicAccess: envFlag(Bun.env.CROMBOLO_DELIVERY_API_PUBLIC),
518
+ media:
519
+ envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) === undefined
520
+ ? undefined
521
+ : { enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) },
522
+ }),
485
523
  // No environment variable: a bundle is an import, which is code.
486
524
  bundles: [],
487
525
  marketplaceUrl: Bun.env.CROMBOLO_MARKETPLACE_URL ?? DEFAULTS.marketplaceUrl,
@@ -493,6 +531,11 @@ export function loadConfig(
493
531
  const merged: CromboloConfig = { ...fromEnv, ...stripUndefined(overrides) }
494
532
  // Field by field, so a site setting its title suffix keeps the environment's switches.
495
533
  merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
534
+ merged.deliveryApi = {
535
+ ...fromEnv.deliveryApi,
536
+ ...stripUndefined(overrides.deliveryApi ?? {}),
537
+ media: { ...fromEnv.deliveryApi.media, ...(overrides.deliveryApi?.media ?? {}) },
538
+ }
496
539
  // A schema store makes backoffice writes durable, so it carries writability with
497
540
  // it — that is the point of configuring one. An explicit setting still wins.
498
541
  if (
@@ -502,6 +545,15 @@ export function loadConfig(
502
545
  ) {
503
546
  merged.schemaWritable = true
504
547
  }
548
+ // Likewise a store says the nodes' disks do not last, so an install on one of
549
+ // them would not either. An explicit setting still wins.
550
+ if (
551
+ merged.schemaStore &&
552
+ Bun.env.CROMBOLO_RUNTIME_INSTALLS === undefined &&
553
+ overrides.runtimeInstalls === undefined
554
+ ) {
555
+ merged.runtimeInstalls = false
556
+ }
505
557
  // Relative paths are relative to the site, not to wherever the process started.
506
558
  merged.siteDir = resolve(cwd, merged.siteDir)
507
559
  // Against the site rather than the working directory: it has to sit where the
@@ -566,4 +618,4 @@ export function describeDatabase(
566
618
  }
567
619
  }
568
620
 
569
- export const VERSION = '0.11.0'
621
+ export const VERSION = '0.13.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
  }