@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.
- package/package.json +12 -11
- package/src/adapters/bundles.ts +2 -1
- 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/approvals-api.ts +189 -0
- package/src/approvals.ts +823 -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 +54 -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/marketplace.ts +18 -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 +66 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -57
- 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/server.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* API, the SPA shells, then the front-end site.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
9
10
|
import { join } from 'node:path'
|
|
11
|
+
import { createDeliveryApi, resolveDeliveryApiSettings } from '@crombolo/api-delivery'
|
|
10
12
|
import {
|
|
11
13
|
type ClientAddress,
|
|
12
14
|
createManagementApiRouter,
|
|
@@ -36,7 +38,10 @@ import {
|
|
|
36
38
|
serveStaticFile,
|
|
37
39
|
} from '@crombolo/backoffice-host'
|
|
38
40
|
import {
|
|
41
|
+
CHOICE_SOURCE_KINDS,
|
|
42
|
+
type ChoiceSourceKind,
|
|
39
43
|
DEFAULT_UPLOAD_SETTINGS,
|
|
44
|
+
DELIVERY_API_PREFIX,
|
|
40
45
|
FormPermissions,
|
|
41
46
|
hasAccessToSensitiveData,
|
|
42
47
|
localReturnUrl,
|
|
@@ -50,8 +55,11 @@ import {
|
|
|
50
55
|
type Db,
|
|
51
56
|
FormEntryRepository,
|
|
52
57
|
FormWorkflowRepository,
|
|
58
|
+
PublicAccessRepository,
|
|
53
59
|
RedirectRepository,
|
|
60
|
+
readLedger,
|
|
54
61
|
readReport,
|
|
62
|
+
readUrlOptions,
|
|
55
63
|
TransferRunRepository,
|
|
56
64
|
type WritesPaused,
|
|
57
65
|
} from '@crombolo/data'
|
|
@@ -64,21 +72,27 @@ import {
|
|
|
64
72
|
PublishedCache,
|
|
65
73
|
Renderer,
|
|
66
74
|
type SnapshotStatus,
|
|
75
|
+
setFormFieldTypes,
|
|
67
76
|
setFormTokenSigner,
|
|
68
77
|
ViewSnapshots,
|
|
69
78
|
} from '@crombolo/render'
|
|
70
79
|
import type { SyncReport } from '@crombolo/schema'
|
|
80
|
+
import { choicesFor } from '@crombolo/transfer'
|
|
71
81
|
import type { Server } from 'bun'
|
|
72
82
|
import { createNodeLookup, loadAccess } from './access.ts'
|
|
73
83
|
import { createAuditHost, createAuditSink, pluginAuditPlan, recordAudit } from './adapters/audit.ts'
|
|
74
84
|
import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
|
|
75
85
|
import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
|
|
76
86
|
import { createSchemaProposals } from './adapters/schema-proposals.ts'
|
|
77
|
-
import {
|
|
87
|
+
import type { Approvals } from './approvals.ts'
|
|
88
|
+
import { handleApprovalsApi } from './approvals-api.ts'
|
|
89
|
+
import { CommandError, installBundleContent } from './bundle-install.ts'
|
|
78
90
|
import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
|
|
79
91
|
import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
|
|
92
|
+
import { busyResponse } from './busy.ts'
|
|
80
93
|
import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
|
|
81
94
|
import { attachDatabase, bootstrapDatabase } from './database.ts'
|
|
95
|
+
import { createDeliveryMemberSecurity } from './delivery-members.ts'
|
|
82
96
|
import { createDiagnostics } from './diagnostics.ts'
|
|
83
97
|
import { syncDomainsFile } from './domains.ts'
|
|
84
98
|
import { editorTypeLibs, siteJsxImportSource } from './editor-types.ts'
|
|
@@ -96,6 +110,7 @@ import {
|
|
|
96
110
|
wantsJson,
|
|
97
111
|
writeFlash,
|
|
98
112
|
} from './form-submit.ts'
|
|
113
|
+
import { describeFormTypes, formTypesFor, formTypesOf } from './form-types.ts'
|
|
99
114
|
import { createWorkflowRunner } from './form-workflows.ts'
|
|
100
115
|
import {
|
|
101
116
|
entriesCsv,
|
|
@@ -114,7 +129,18 @@ import { mediaStoreFromEnvironment } from './media-store.ts'
|
|
|
114
129
|
import { createMemberAuth, MEMBER_ROUTE_PREFIX } from './member-auth.ts'
|
|
115
130
|
import { noNodesPage } from './no-nodes.ts'
|
|
116
131
|
import { NotImplementedLog } from './not-implemented.ts'
|
|
117
|
-
import { createDeps, resolveUserLinkSender } from './ports.ts'
|
|
132
|
+
import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
|
|
133
|
+
import { createRedirectTracker } from './redirects.ts'
|
|
134
|
+
import { handleTransferAction, pendingTransfers } from './transfer-dashboard.ts'
|
|
135
|
+
import {
|
|
136
|
+
changeUrlOptions,
|
|
137
|
+
redirectAfterUrlChange,
|
|
138
|
+
type UrlChangeContext,
|
|
139
|
+
urlSettingEditable,
|
|
140
|
+
} from './url-settings.ts'
|
|
141
|
+
|
|
142
|
+
export { PREVIEW_COOKIE }
|
|
143
|
+
|
|
118
144
|
import { syncConfiguredRedirects } from './redirects.ts'
|
|
119
145
|
import {
|
|
120
146
|
bootSchema,
|
|
@@ -131,7 +157,10 @@ import {
|
|
|
131
157
|
type HubSocketData,
|
|
132
158
|
type ServerEventHub,
|
|
133
159
|
} from './server-events.ts'
|
|
160
|
+
import { createSetupRoutes } from './setup.ts'
|
|
161
|
+
import { summariseUpgrades } from './upgrade-summary.ts'
|
|
134
162
|
import { assertViewRuntime } from './view-runtime.ts'
|
|
163
|
+
import { bundleEventProblems, createWebhookService, webhookStoreView } from './webhooks.ts'
|
|
135
164
|
|
|
136
165
|
const CONTRACT_PATH = Bun.resolveSync('@crombolo/contracts/OpenApi.json', import.meta.dir)
|
|
137
166
|
|
|
@@ -183,9 +212,6 @@ export interface ServerHandle {
|
|
|
183
212
|
close(): Promise<void>
|
|
184
213
|
}
|
|
185
214
|
|
|
186
|
-
/** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
|
|
187
|
-
export const PREVIEW_COOKIE = 'UMB_PREVIEW'
|
|
188
|
-
|
|
189
215
|
export async function createServer(config: CromboloConfig = loadConfig()): Promise<ServerHandle> {
|
|
190
216
|
configureLogging({
|
|
191
217
|
logsDir: config.logsDir,
|
|
@@ -209,6 +235,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
209
235
|
}
|
|
210
236
|
}
|
|
211
237
|
const domainLog = logger('domains')
|
|
238
|
+
const apiLog = logger('management-api')
|
|
212
239
|
// Extensions are the site's own npm dependencies (`docs/bundles.md`).
|
|
213
240
|
// Uncached in development so an author editing one sees a reload pick it up.
|
|
214
241
|
const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
|
|
@@ -222,8 +249,18 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
222
249
|
contentApplied: async (packageName) =>
|
|
223
250
|
(await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
|
|
224
251
|
applyContentOnInstall: config.applyBundleContentOnInstall,
|
|
252
|
+
installs: config.runtimeInstalls,
|
|
225
253
|
applyContent: async (packageName) => {
|
|
226
|
-
|
|
254
|
+
// A package from the marketplace is for anyone, so nothing in it can be
|
|
255
|
+
// sealed; one that is is refused rather than half applied.
|
|
256
|
+
const result = await installBundleContent(db, config, { bundle: packageName }).catch(
|
|
257
|
+
(error: unknown) => {
|
|
258
|
+
if (error instanceof CommandError) return error
|
|
259
|
+
throw error
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
if (result instanceof CommandError)
|
|
263
|
+
return { applied: false, message: `Its content was not applied: ${result.message}` }
|
|
227
264
|
if (!result.runId) {
|
|
228
265
|
const reasons = [
|
|
229
266
|
...result.problems,
|
|
@@ -231,7 +268,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
231
268
|
]
|
|
232
269
|
return {
|
|
233
270
|
applied: false,
|
|
234
|
-
message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}.
|
|
271
|
+
message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Answer what it needs in Settings, under Changes, and import it from there.`,
|
|
235
272
|
}
|
|
236
273
|
}
|
|
237
274
|
// The content landed under a cache this process had already constructed.
|
|
@@ -247,12 +284,20 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
247
284
|
const owns = config.role !== 'web'
|
|
248
285
|
// Bundles are checked before the database is touched, because a bundle that
|
|
249
286
|
// owns tables is migrated at boot, and a bad declaration must not get that far.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
// On every role: a bundle's form types are needed wherever forms render and
|
|
288
|
+
// submit. Its routes and tables stay with the nodes that serve the backoffice.
|
|
289
|
+
const bundlePackages = extensions.bundles().bundles
|
|
290
|
+
const formTypes = formTypesOf(config)
|
|
291
|
+
{
|
|
292
|
+
const problems = [
|
|
293
|
+
...validateServerBundles(config.bundles, bundlePackages, VERSION),
|
|
294
|
+
...formTypes.problems,
|
|
295
|
+
...bundleEventProblems(config.bundles),
|
|
296
|
+
]
|
|
253
297
|
if (problems.length > 0)
|
|
254
298
|
throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
|
|
255
299
|
}
|
|
300
|
+
setFormFieldTypes(formTypes.types, formTypes.renderers)
|
|
256
301
|
const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
|
|
257
302
|
const { db, seededAdminPassword } = owns
|
|
258
303
|
? await bootstrapDatabase(config, chains)
|
|
@@ -283,6 +328,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
283
328
|
ipAddress: peers.get(request) ?? null,
|
|
284
329
|
forwardedFor: request.headers.get('x-forwarded-for'),
|
|
285
330
|
})
|
|
331
|
+
// The global cap behind the per-caller one is what holds when a forwarded
|
|
332
|
+
// address is spoofed to dodge it.
|
|
333
|
+
const setup = createSetupRoutes({
|
|
334
|
+
db,
|
|
335
|
+
setupCode: config.setupCode,
|
|
336
|
+
siteName: config.siteName,
|
|
337
|
+
backOfficePath: paths.backOfficePath,
|
|
338
|
+
clientKey: (request) =>
|
|
339
|
+
peers.get(request) ?? request.headers.get('x-forwarded-for') ?? 'unknown',
|
|
340
|
+
})
|
|
286
341
|
const authRoutes = createAuthRoutes({
|
|
287
342
|
backOfficePath: paths.backOfficePath,
|
|
288
343
|
service: auth,
|
|
@@ -333,15 +388,39 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
333
388
|
|
|
334
389
|
// Form definitions are files, so they are re-read rather than cached with the
|
|
335
390
|
// content: nothing about a publish tells this node that a form changed.
|
|
336
|
-
const forms = createFormRegistry(config.schemaDir)
|
|
391
|
+
const forms = createFormRegistry(config.schemaDir, { formTypes: formTypes.types })
|
|
337
392
|
// Every form this node renders carries a token signed with the site's key, so
|
|
338
393
|
// the timing guard cannot be bypassed by posting a made-up render time.
|
|
339
394
|
const formSecret = await formSigningSecret(db)
|
|
340
395
|
setFormTokenSigner((formKey, renderedAt) => signFormToken(formSecret, formKey, renderedAt))
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
396
|
+
const publishedSource = createPublishedContentSource(db, {
|
|
397
|
+
nodeState: schema.nodeState,
|
|
398
|
+
nodeId: schema.nodeId,
|
|
399
|
+
})
|
|
400
|
+
const cacheOptions = { form: (key: string) => forms.byKey(key) }
|
|
401
|
+
const cache = new PublishedCache(publishedSource, cacheOptions)
|
|
402
|
+
// A sync or a Settings change that alters how URLs are built moves every URL:
|
|
403
|
+
// the old ones redirect, as a rename's do.
|
|
404
|
+
const urlChange: UrlChangeContext = {
|
|
405
|
+
db,
|
|
406
|
+
config,
|
|
407
|
+
source: publishedSource,
|
|
408
|
+
cacheOptions,
|
|
409
|
+
cache,
|
|
410
|
+
tracker: createRedirectTracker({
|
|
411
|
+
cache,
|
|
412
|
+
redirects: new RedirectRepository(db),
|
|
413
|
+
enabled: config.trackRedirects,
|
|
414
|
+
}),
|
|
415
|
+
}
|
|
416
|
+
const afterSync = async (report: SyncReport | undefined) => {
|
|
417
|
+
if (!report?.urls) return
|
|
418
|
+
await redirectAfterUrlChange(urlChange, report.urls.before)
|
|
419
|
+
log.info('Page addresses now {mode} the top-level page; old addresses redirect', {
|
|
420
|
+
mode: report.urls.after.hideTopLevel ? 'leave out' : 'include',
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
await afterSync(schema.report)
|
|
345
424
|
// A snapshot of the views, so a template edited anywhere — the backoffice, a
|
|
346
425
|
// deploy, a bucket synced underneath — is picked up without restarting this
|
|
347
426
|
// node. `assertViewRuntime` first, because a cache directory the JSX runtime
|
|
@@ -462,6 +541,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
462
541
|
{ nodeId: schema.nodeId, revision: config.schemaRevision },
|
|
463
542
|
(report: SyncReport) => {
|
|
464
543
|
cache.invalidate()
|
|
544
|
+
void afterSync(report).catch((error: unknown) =>
|
|
545
|
+
schemaLog.error('Recording redirects after an address change failed: {error}', {
|
|
546
|
+
error: String(error),
|
|
547
|
+
}),
|
|
548
|
+
)
|
|
465
549
|
if (report.action === 'refused')
|
|
466
550
|
schemaLog.error('Schema change refused: {reason}', {
|
|
467
551
|
reason:
|
|
@@ -486,6 +570,26 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
486
570
|
nodeState: schema.nodeState,
|
|
487
571
|
nodeId: schema.nodeId,
|
|
488
572
|
})
|
|
573
|
+
const deliverySettings = resolveDeliveryApiSettings(config.deliveryApi)
|
|
574
|
+
const deliveryPrefix = DELIVERY_API_PREFIX
|
|
575
|
+
const deliveryMembers = createDeliveryMemberSecurity({
|
|
576
|
+
db,
|
|
577
|
+
settings: deliverySettings,
|
|
578
|
+
members,
|
|
579
|
+
})
|
|
580
|
+
for (const problem of deliveryMembers.problems)
|
|
581
|
+
log.warning('Delivery API: {problem}', { problem })
|
|
582
|
+
const deliveryApi =
|
|
583
|
+
config.role === 'api'
|
|
584
|
+
? undefined
|
|
585
|
+
: createDeliveryApi({
|
|
586
|
+
settings: deliverySettings,
|
|
587
|
+
cache,
|
|
588
|
+
prefix: deliveryPrefix,
|
|
589
|
+
protection: () => new PublicAccessRepository(db).allRules(),
|
|
590
|
+
member: (token) => deliveryMembers.member(token),
|
|
591
|
+
security: (request, action) => deliveryMembers.handle(request, action),
|
|
592
|
+
})
|
|
489
593
|
|
|
490
594
|
/** A document's latest saved version, as preview shows it, in the published tree's context. */
|
|
491
595
|
async function draftModel(key: string, requested: string | null) {
|
|
@@ -531,12 +635,6 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
531
635
|
...entry,
|
|
532
636
|
})
|
|
533
637
|
})
|
|
534
|
-
const previewPort = {
|
|
535
|
-
enter: () => [
|
|
536
|
-
serializeCookie({ name: PREVIEW_COOKIE, value: 'preview', secure: config.secureCookies }),
|
|
537
|
-
],
|
|
538
|
-
exit: () => [expireCookie(PREVIEW_COOKIE, config.secureCookies)],
|
|
539
|
-
}
|
|
540
638
|
// The site's config may name a store; without one the environment decides,
|
|
541
639
|
// and that falls back to the media directory on this disk.
|
|
542
640
|
const mediaStore = config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir))
|
|
@@ -550,45 +648,65 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
550
648
|
const canSendUserLinks = Boolean(resolveUserLinkSender(config, email))
|
|
551
649
|
const mediaFiles = new MediaFileStore(mediaStore)
|
|
552
650
|
const images = new ImageProcessor(mediaStore)
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
651
|
+
const webhooks = createWebhookService({
|
|
652
|
+
db,
|
|
653
|
+
schemaDir: config.schemaDir,
|
|
654
|
+
filesWritable: config.schemaWritable,
|
|
655
|
+
secretKey: config.secretKey,
|
|
656
|
+
bundles: config.bundles,
|
|
657
|
+
onFilesChanged: config.schemaStore
|
|
658
|
+
? async () => {
|
|
659
|
+
await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
|
|
660
|
+
}
|
|
661
|
+
: undefined,
|
|
662
|
+
})
|
|
663
|
+
const managementDeps = createDeps({
|
|
664
|
+
config,
|
|
665
|
+
paths,
|
|
666
|
+
extensions,
|
|
667
|
+
db,
|
|
668
|
+
webhooks,
|
|
669
|
+
cache,
|
|
670
|
+
schema,
|
|
671
|
+
mediaFiles,
|
|
672
|
+
email,
|
|
673
|
+
onSearchQueued: () => jobs.kickSearchIndexing(),
|
|
674
|
+
// This node looks again at once; the others are told to. The write has
|
|
675
|
+
// already landed wherever `componentsDir` points, so there is nothing to
|
|
676
|
+
// distribute — only the fact that it happened.
|
|
677
|
+
onViewsChange: async (alias) => {
|
|
678
|
+
// The snapshot first, and awaited: the hash announced is then the one
|
|
679
|
+
// this write produced rather than the generation it replaced, and the
|
|
680
|
+
// editor's next request renders what they just saved.
|
|
681
|
+
const hash = await snapshots.refresh()
|
|
682
|
+
void appendCacheInstruction(db, {
|
|
683
|
+
kind: 'components',
|
|
684
|
+
payload: hash ? { alias, hash } : { alias },
|
|
685
|
+
by: schema.nodeId,
|
|
686
|
+
}).catch((error: unknown) => {
|
|
687
|
+
renderLog.error('Could not announce the template {alias}: {error}', { alias, error })
|
|
688
|
+
})
|
|
583
689
|
},
|
|
690
|
+
})
|
|
691
|
+
const api = createManagementApiRouter({
|
|
692
|
+
deps: managementDeps,
|
|
584
693
|
authenticate,
|
|
585
694
|
nodes: createNodeLookup(db),
|
|
586
695
|
allowLocalLogin: config.allowLocalLogin,
|
|
587
696
|
audit: createAuditSink(db),
|
|
588
697
|
clientAddress,
|
|
698
|
+
onError: (error) => {
|
|
699
|
+
const answered = busyResponse(error)
|
|
700
|
+
if (answered) apiLog.warn('A write gave up waiting for another: {error}', { error })
|
|
701
|
+
return answered
|
|
702
|
+
},
|
|
589
703
|
onNotImplemented: config.development
|
|
590
704
|
? (operation, request) => notImplemented.record(operation, request)
|
|
591
705
|
: undefined,
|
|
706
|
+
// Queued after the response, so a slow receiver never holds an editor up.
|
|
707
|
+
onWebhookEvent: (event) => {
|
|
708
|
+
void webhooks.raise(event)
|
|
709
|
+
},
|
|
592
710
|
onServerEvent: (event) => {
|
|
593
711
|
events.broadcast('notify', [event])
|
|
594
712
|
// Saves, deletes and moves all announce themselves; whatever they queued gets indexed now.
|
|
@@ -641,6 +759,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
641
759
|
mediaFiles,
|
|
642
760
|
// The workflow queue drains here rather than in the request: a submission is
|
|
643
761
|
// stored first, and an e-mail that cannot be sent yet must not cost it.
|
|
762
|
+
webhooks,
|
|
644
763
|
formWorkflows: createWorkflowRunner({
|
|
645
764
|
db,
|
|
646
765
|
forms,
|
|
@@ -649,6 +768,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
649
768
|
nodeState: schema.nodeState,
|
|
650
769
|
nodeId: schema.nodeId,
|
|
651
770
|
onContentChanged: () => cache.invalidate(),
|
|
771
|
+
bundleWorkflows: formTypes.workflows,
|
|
652
772
|
}),
|
|
653
773
|
onDocumentChanged: (key) =>
|
|
654
774
|
events.broadcast('notify', [
|
|
@@ -667,6 +787,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
667
787
|
// App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
|
|
668
788
|
// extensions silently stopped loading after an upgrade; remove at 1.0.
|
|
669
789
|
warnAboutAppPlugins(config.siteDir, log)
|
|
790
|
+
// Named, never shown: the value is the one thing this must not print.
|
|
791
|
+
if (config.setupCode && (await setup.pending()) === false)
|
|
792
|
+
log.warning('CROMBOLO_SETUP_CODE is still set, but the site is set up. Remove the setting.')
|
|
670
793
|
noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
|
|
671
794
|
const hubPath = SERVER_EVENT_HUB_PATH
|
|
672
795
|
const previewHubPath = PREVIEW_HUB_PATH
|
|
@@ -680,7 +803,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
680
803
|
module.createAssistantHost({
|
|
681
804
|
config: config.assistant as NonNullable<typeof config.assistant>,
|
|
682
805
|
db,
|
|
683
|
-
schema: createSchemaProposals({ db, config, boot: schema }),
|
|
806
|
+
schema: createSchemaProposals({ db, config, boot: schema, afterSync }),
|
|
684
807
|
siteName: config.siteName,
|
|
685
808
|
version: VERSION,
|
|
686
809
|
dispatch: (inner) => api.dispatch(inner),
|
|
@@ -747,6 +870,13 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
747
870
|
// `web` node falls straight through to the public site, so the backoffice,
|
|
748
871
|
// the Management API and the sign-in routes are not merely unauthorised
|
|
749
872
|
// there — they are not routes at all, and 404 like any other unknown path.
|
|
873
|
+
// Umbraco's Delivery API, below the backoffice path but served wherever the
|
|
874
|
+
// site is, from the published cache: it scales with rendering.
|
|
875
|
+
if (deliveryApi && pathname.startsWith(`${deliveryPrefix}/`)) {
|
|
876
|
+
const response = await deliveryApi.handle(request)
|
|
877
|
+
if (response) return response
|
|
878
|
+
}
|
|
879
|
+
|
|
750
880
|
if (servesBackOffice) {
|
|
751
881
|
const response = await serveBackOffice(request, url)
|
|
752
882
|
if (response) {
|
|
@@ -819,6 +949,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
819
949
|
async function serveBackOffice(request: Request, url: URL): Promise<Response | undefined> {
|
|
820
950
|
const { pathname } = url
|
|
821
951
|
|
|
952
|
+
if (pathname === setup.path) return setup.handle(request)
|
|
953
|
+
// Until the first administrator exists there is nobody to sign in as.
|
|
954
|
+
if (
|
|
955
|
+
request.method === 'GET' &&
|
|
956
|
+
(pathname === `${paths.backOfficePath}/login` ||
|
|
957
|
+
isSpaRoute(paths.backOfficePath, pathname)) &&
|
|
958
|
+
(await setup.pending())
|
|
959
|
+
)
|
|
960
|
+
return new Response(null, { status: 303, headers: { location: setup.path } })
|
|
961
|
+
|
|
822
962
|
// Non-contract, signed-in: the assistant's chat, review and MCP endpoints.
|
|
823
963
|
// Absent configuration there is no `assistant`, so these paths 404 like any
|
|
824
964
|
// other unknown route.
|
|
@@ -868,6 +1008,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
868
1008
|
}
|
|
869
1009
|
const dryRun = request.method === 'GET'
|
|
870
1010
|
const result = await importSchema(db, config, schema, { dryRun })
|
|
1011
|
+
if (!dryRun) await afterSync(result.report)
|
|
871
1012
|
if (!dryRun) {
|
|
872
1013
|
log.info('{user} imported the schema: {action}, {classification}', {
|
|
873
1014
|
user: principal.userName,
|
|
@@ -899,7 +1040,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
899
1040
|
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
900
1041
|
if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
|
|
901
1042
|
const body = JSON.stringify({
|
|
902
|
-
libs: await editorTypeLibs({
|
|
1043
|
+
libs: await editorTypeLibs({
|
|
1044
|
+
schemaDir: config.schemaDir,
|
|
1045
|
+
formTypes: formTypesFor(config),
|
|
1046
|
+
}),
|
|
903
1047
|
jsxImportSource: siteJsxImportSource(config.siteDir),
|
|
904
1048
|
})
|
|
905
1049
|
// Revalidated on every open rather than kept: the generated types follow
|
|
@@ -947,6 +1091,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
947
1091
|
url: marketplace.url(),
|
|
948
1092
|
keyword: config.packageKeyword,
|
|
949
1093
|
appliesContent: config.applyBundleContentOnInstall,
|
|
1094
|
+
installs: config.runtimeInstalls,
|
|
950
1095
|
items: await marketplace.search(url.searchParams.get('q') ?? undefined),
|
|
951
1096
|
})
|
|
952
1097
|
}
|
|
@@ -968,7 +1113,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
968
1113
|
}
|
|
969
1114
|
|
|
970
1115
|
if (action === 'installed' && request.method === 'GET') {
|
|
971
|
-
return Response.json({
|
|
1116
|
+
return Response.json({
|
|
1117
|
+
installs: config.runtimeInstalls,
|
|
1118
|
+
items: await marketplace.installed(),
|
|
1119
|
+
})
|
|
972
1120
|
}
|
|
973
1121
|
|
|
974
1122
|
if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
|
|
@@ -1026,6 +1174,33 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1026
1174
|
* files are the truth, the backoffice may have just rewritten one, and a
|
|
1027
1175
|
* picker that disagreed with what a render uses would be worse than slow.
|
|
1028
1176
|
*/
|
|
1177
|
+
// Non-contract, Settings: the "Where it's kept" panel on a webhook's screen.
|
|
1178
|
+
const kept = new RegExp(`^${paths.pluginPath}/api/webhooks/([0-9a-fA-F-]{36})/store$`).exec(
|
|
1179
|
+
pathname,
|
|
1180
|
+
)
|
|
1181
|
+
if (kept) {
|
|
1182
|
+
const principal = await authenticate(request)
|
|
1183
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1184
|
+
if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
|
|
1185
|
+
const key = (kept[1] as string).toLowerCase()
|
|
1186
|
+
if (request.method === 'PUT') {
|
|
1187
|
+
const body = (await request.json().catch(() => ({}))) as { store?: unknown }
|
|
1188
|
+
if (body.store !== 'files' && body.store !== 'database')
|
|
1189
|
+
return Response.json({ reason: 'Choose files or database.' }, { status: 400 })
|
|
1190
|
+
const moved = await webhooks.move(key, body.store)
|
|
1191
|
+
return moved.ok
|
|
1192
|
+
? Response.json({ ok: true })
|
|
1193
|
+
: Response.json(
|
|
1194
|
+
{ reason: moved.reason, problems: moved.problems ?? [] },
|
|
1195
|
+
{ status: moved.status },
|
|
1196
|
+
)
|
|
1197
|
+
}
|
|
1198
|
+
if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
|
|
1199
|
+
const webhook = await webhooks.get(key)
|
|
1200
|
+
if (!webhook) return new Response('Not Found', { status: 404 })
|
|
1201
|
+
return Response.json(webhookStoreView(webhook.store, webhooks.stores()))
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1029
1204
|
if (pathname.startsWith(`${paths.pluginPath}/api/forms`)) {
|
|
1030
1205
|
const principal = await authenticate(request)
|
|
1031
1206
|
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
@@ -1037,7 +1212,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1037
1212
|
// A definition is structure, like a content type: anyone who may open the
|
|
1038
1213
|
// section may read it, and the picker needs it in the Content section too.
|
|
1039
1214
|
if (action === '' && request.method === 'GET')
|
|
1040
|
-
return Response.json({
|
|
1215
|
+
return Response.json({
|
|
1216
|
+
items: definitions.map((form) => formSummary(form, formTypes.types)),
|
|
1217
|
+
})
|
|
1218
|
+
// Hyphenated, so no form alias (letters and digits) can be mistaken for it.
|
|
1219
|
+
if (action === 'available-types' && request.method === 'GET')
|
|
1220
|
+
return Response.json(describeFormTypes(formTypes.types))
|
|
1041
1221
|
|
|
1042
1222
|
const entries = new FormEntryRepository(db)
|
|
1043
1223
|
const byKey = formsByKey(definitions)
|
|
@@ -1048,6 +1228,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1048
1228
|
const formFiles = {
|
|
1049
1229
|
schemaDir: config.schemaDir,
|
|
1050
1230
|
writable: config.schemaWritable,
|
|
1231
|
+
formTypes: formTypes.types,
|
|
1051
1232
|
onChanged: config.schemaStore
|
|
1052
1233
|
? async () => {
|
|
1053
1234
|
await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
|
|
@@ -1124,7 +1305,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1124
1305
|
const form = byKey.get(key)
|
|
1125
1306
|
if (!form) return new Response('Not Found', { status: 404 })
|
|
1126
1307
|
const page = await entries.list({ formKey: key, take: 10_000 })
|
|
1127
|
-
const result = entriesCsv(form, page.items, {
|
|
1308
|
+
const result = entriesCsv(form, page.items, {
|
|
1309
|
+
includeSensitive: maySeeSensitive,
|
|
1310
|
+
types: formTypes.types,
|
|
1311
|
+
})
|
|
1128
1312
|
if (!result.ok)
|
|
1129
1313
|
return new Response(result.reason, {
|
|
1130
1314
|
status: 403,
|
|
@@ -1171,6 +1355,25 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1171
1355
|
return Response.json({ ...formDetail(form), definition: form })
|
|
1172
1356
|
}
|
|
1173
1357
|
|
|
1358
|
+
// What a choice source offers now, in the default language, for the
|
|
1359
|
+
// designer to show beside the source it is editing.
|
|
1360
|
+
if (action === 'choices' && request.method === 'POST') {
|
|
1361
|
+
if (!may(FormPermissions.Manage)) return new Response('Forbidden', { status: 403 })
|
|
1362
|
+
const body = (await request.json().catch(() => ({}))) as { source?: unknown }
|
|
1363
|
+
const source = body.source as { kind?: unknown; parent?: unknown } | undefined
|
|
1364
|
+
if (
|
|
1365
|
+
!source ||
|
|
1366
|
+
!CHOICE_SOURCE_KINDS.includes(source.kind as ChoiceSourceKind) ||
|
|
1367
|
+
typeof source.parent !== 'string'
|
|
1368
|
+
)
|
|
1369
|
+
return new Response('Bad Request', { status: 400 })
|
|
1370
|
+
const options = await cache.choiceOptions({
|
|
1371
|
+
kind: source.kind as ChoiceSourceKind,
|
|
1372
|
+
parent: source.parent,
|
|
1373
|
+
})
|
|
1374
|
+
return Response.json({ options })
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1174
1377
|
// Designing a form is a developer's job, so it is its own verb rather
|
|
1175
1378
|
// than something an entry reader can do.
|
|
1176
1379
|
if (request.method === 'POST' || request.method === 'PUT') {
|
|
@@ -1235,6 +1438,97 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1235
1438
|
if (response) return response
|
|
1236
1439
|
}
|
|
1237
1440
|
|
|
1441
|
+
// Non-contract, Settings: how page addresses are built (story 110).
|
|
1442
|
+
if (pathname === `${paths.pluginPath}/api/url-options`) {
|
|
1443
|
+
const principal = await authenticate(request)
|
|
1444
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1445
|
+
if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
|
|
1446
|
+
if (request.method === 'GET')
|
|
1447
|
+
return Response.json({
|
|
1448
|
+
...(await readUrlOptions(db)),
|
|
1449
|
+
editable: urlSettingEditable(config, existsSync(config.schemaDir)),
|
|
1450
|
+
})
|
|
1451
|
+
if (request.method !== 'PUT') return new Response('Method Not Allowed', { status: 405 })
|
|
1452
|
+
const body = (await request.json().catch(() => ({}))) as { hideTopLevel?: unknown }
|
|
1453
|
+
if (typeof body.hideTopLevel !== 'boolean')
|
|
1454
|
+
return Response.json(
|
|
1455
|
+
{ ok: false, message: 'Say whether to hide the top-level page.' },
|
|
1456
|
+
{ status: 400 },
|
|
1457
|
+
)
|
|
1458
|
+
const outcome = await changeUrlOptions(urlChange, { hideTopLevel: body.hideTopLevel })
|
|
1459
|
+
log.info('{user} set page addresses to {mode} the top-level page: {outcome}', {
|
|
1460
|
+
user: principal.email,
|
|
1461
|
+
mode: body.hideTopLevel ? 'leave out' : 'include',
|
|
1462
|
+
outcome: outcome.ok ? 'ok' : outcome.message,
|
|
1463
|
+
})
|
|
1464
|
+
return outcome.ok
|
|
1465
|
+
? Response.json({ ok: true, ...outcome.options })
|
|
1466
|
+
: Response.json({ ok: false, message: outcome.message }, { status: outcome.status })
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Non-contract: approval workflows (story 028) — the page's Workflow tab, the
|
|
1470
|
+
// Workflow section and its designer. Content or Workflow, as either screen is.
|
|
1471
|
+
if (pathname.startsWith(`${paths.pluginPath}/api/approvals/`)) {
|
|
1472
|
+
const principal = await authenticate(request)
|
|
1473
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1474
|
+
if (!hasSection(principal, 'content') && !hasSection(principal, 'workflow'))
|
|
1475
|
+
return new Response('Forbidden', { status: 403 })
|
|
1476
|
+
const approvals = managementDeps.approvals as Approvals | undefined
|
|
1477
|
+
if (!approvals) return new Response('Not Found', { status: 404 })
|
|
1478
|
+
const response = await handleApprovalsApi(
|
|
1479
|
+
request,
|
|
1480
|
+
pathname.slice(`${paths.pluginPath}/api/approvals/`.length),
|
|
1481
|
+
{
|
|
1482
|
+
db,
|
|
1483
|
+
approvals,
|
|
1484
|
+
principal,
|
|
1485
|
+
schemaDir: config.schemaDir,
|
|
1486
|
+
writable: config.schemaWritable,
|
|
1487
|
+
onChanged: config.schemaStore
|
|
1488
|
+
? async () => {
|
|
1489
|
+
await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
|
|
1490
|
+
}
|
|
1491
|
+
: undefined,
|
|
1492
|
+
},
|
|
1493
|
+
)
|
|
1494
|
+
if (response) {
|
|
1495
|
+
if (request.method !== 'GET')
|
|
1496
|
+
log.info('{user} {verb} on approvals: {status}', {
|
|
1497
|
+
user: principal.email,
|
|
1498
|
+
verb: pathname.split('/').slice(-1)[0],
|
|
1499
|
+
status: response.status,
|
|
1500
|
+
})
|
|
1501
|
+
return response
|
|
1502
|
+
}
|
|
1503
|
+
return new Response('Not Found', { status: 404 })
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Non-contract, Packages: answering a content import's conflicts and running
|
|
1507
|
+
// it, from the Changes dashboard. Packages, because installing is.
|
|
1508
|
+
if (pathname.startsWith(`${paths.pluginPath}/api/transfers/`)) {
|
|
1509
|
+
const principal = await authenticate(request)
|
|
1510
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1511
|
+
if (!hasSection(principal, 'packages')) return new Response('Forbidden', { status: 403 })
|
|
1512
|
+
const action = pathname.slice(`${paths.pluginPath}/api/transfers/`.length)
|
|
1513
|
+
const response = await handleTransferAction(request, action, {
|
|
1514
|
+
db,
|
|
1515
|
+
config,
|
|
1516
|
+
user: principal.email,
|
|
1517
|
+
onApplied: () => cache.invalidate(),
|
|
1518
|
+
})
|
|
1519
|
+
if (response) {
|
|
1520
|
+
if (request.method === 'POST')
|
|
1521
|
+
log.info('{user} {verb} for the content bundle {bundle}: {status}', {
|
|
1522
|
+
user: principal.email,
|
|
1523
|
+
verb: action.split('/').pop(),
|
|
1524
|
+
bundle: action.split('/')[0],
|
|
1525
|
+
status: response.status,
|
|
1526
|
+
})
|
|
1527
|
+
return response
|
|
1528
|
+
}
|
|
1529
|
+
return new Response('Not Found', { status: 404 })
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1238
1532
|
if (pathname === `${paths.pluginPath}/api/change-report`) {
|
|
1239
1533
|
const principal = await authenticate(request)
|
|
1240
1534
|
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
@@ -1254,7 +1548,24 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1254
1548
|
// A bundle's tables are part of what an upgrade changes, so the dashboard
|
|
1255
1549
|
// that shows an upgrade's findings shows their steps too.
|
|
1256
1550
|
const bundles = await bundleTablesReport(db, chains)
|
|
1257
|
-
|
|
1551
|
+
const upgrades = summariseUpgrades(await readLedger(db), VERSION)
|
|
1552
|
+
// The answers a finding can be given, offered only to those who may import.
|
|
1553
|
+
const mayImport = hasSection(principal, 'packages')
|
|
1554
|
+
const offered = findings.map((f) =>
|
|
1555
|
+
f.source === 'transfer' && f.status === 'open' && mayImport
|
|
1556
|
+
? { ...f, choices: choicesFor(f) }
|
|
1557
|
+
: f,
|
|
1558
|
+
)
|
|
1559
|
+
return Response.json({
|
|
1560
|
+
health: health(),
|
|
1561
|
+
counts,
|
|
1562
|
+
bySource,
|
|
1563
|
+
findings: offered,
|
|
1564
|
+
bundles,
|
|
1565
|
+
upgrades,
|
|
1566
|
+
transfers: await pendingTransfers(db),
|
|
1567
|
+
mayImport,
|
|
1568
|
+
})
|
|
1258
1569
|
}
|
|
1259
1570
|
|
|
1260
1571
|
const staticMatch = resolveStaticFile(paths, pathname, {
|
|
@@ -1471,6 +1782,21 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1471
1782
|
mediaFiles,
|
|
1472
1783
|
uploads: DEFAULT_UPLOAD_SETTINGS,
|
|
1473
1784
|
urlOf: async (contentKey) => (await cache.byKey(contentKey))?.url,
|
|
1785
|
+
// The page it was sent from names the culture; the keys a source offers
|
|
1786
|
+
// are the same in every culture, so only the stored labels depend on it.
|
|
1787
|
+
resolveForm: async (form) => {
|
|
1788
|
+
const referer = request.headers.get('referer')
|
|
1789
|
+
let culture: string | null = null
|
|
1790
|
+
try {
|
|
1791
|
+
if (referer) {
|
|
1792
|
+
const url = new URL(referer)
|
|
1793
|
+
culture = await cache.cultureFor(url.host, url.pathname)
|
|
1794
|
+
}
|
|
1795
|
+
} catch {
|
|
1796
|
+
culture = null
|
|
1797
|
+
}
|
|
1798
|
+
return cache.resolveForm(form, culture)
|
|
1799
|
+
},
|
|
1474
1800
|
},
|
|
1475
1801
|
request,
|
|
1476
1802
|
key,
|
|
@@ -1507,6 +1833,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1507
1833
|
? Response.json({ ok: false, error: message }, { status: 409 })
|
|
1508
1834
|
: plain(message, 409)
|
|
1509
1835
|
}
|
|
1836
|
+
// Only the script checks a page, so this always answers in JSON.
|
|
1837
|
+
case 'checked':
|
|
1838
|
+
return outcome.errors.length > 0
|
|
1839
|
+
? Response.json({ ok: false, errors: outcome.errors }, { status: 422 })
|
|
1840
|
+
: Response.json({ ok: true })
|
|
1510
1841
|
case 'invalid':
|
|
1511
1842
|
return json
|
|
1512
1843
|
? Response.json({ ok: false, errors: outcome.state.errors }, { status: 422 })
|