@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.
- package/package.json +12 -11
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/webhooks.ts +76 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +33 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +11 -0
- package/src/jobs.ts +17 -0
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +51 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +315 -27
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- package/src/webhooks.ts +544 -0
package/src/bundle-install.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
204
|
-
resolutions?: Record<string,
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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)
|
|
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)
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
}
|