@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/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,25 @@ 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 { installBundleContent } from './bundle-install.ts'
|
|
87
|
+
import { CommandError, installBundleContent } from './bundle-install.ts'
|
|
78
88
|
import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
|
|
79
89
|
import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
|
|
90
|
+
import { busyResponse } from './busy.ts'
|
|
80
91
|
import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
|
|
81
92
|
import { attachDatabase, bootstrapDatabase } from './database.ts'
|
|
93
|
+
import { createDeliveryMemberSecurity } from './delivery-members.ts'
|
|
82
94
|
import { createDiagnostics } from './diagnostics.ts'
|
|
83
95
|
import { syncDomainsFile } from './domains.ts'
|
|
84
96
|
import { editorTypeLibs, siteJsxImportSource } from './editor-types.ts'
|
|
@@ -96,6 +108,7 @@ import {
|
|
|
96
108
|
wantsJson,
|
|
97
109
|
writeFlash,
|
|
98
110
|
} from './form-submit.ts'
|
|
111
|
+
import { describeFormTypes, formTypesFor, formTypesOf } from './form-types.ts'
|
|
99
112
|
import { createWorkflowRunner } from './form-workflows.ts'
|
|
100
113
|
import {
|
|
101
114
|
entriesCsv,
|
|
@@ -114,7 +127,18 @@ import { mediaStoreFromEnvironment } from './media-store.ts'
|
|
|
114
127
|
import { createMemberAuth, MEMBER_ROUTE_PREFIX } from './member-auth.ts'
|
|
115
128
|
import { noNodesPage } from './no-nodes.ts'
|
|
116
129
|
import { NotImplementedLog } from './not-implemented.ts'
|
|
117
|
-
import { createDeps, resolveUserLinkSender } from './ports.ts'
|
|
130
|
+
import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
|
|
131
|
+
import { createRedirectTracker } from './redirects.ts'
|
|
132
|
+
import { handleTransferAction, pendingTransfers } from './transfer-dashboard.ts'
|
|
133
|
+
import {
|
|
134
|
+
changeUrlOptions,
|
|
135
|
+
redirectAfterUrlChange,
|
|
136
|
+
type UrlChangeContext,
|
|
137
|
+
urlSettingEditable,
|
|
138
|
+
} from './url-settings.ts'
|
|
139
|
+
|
|
140
|
+
export { PREVIEW_COOKIE }
|
|
141
|
+
|
|
118
142
|
import { syncConfiguredRedirects } from './redirects.ts'
|
|
119
143
|
import {
|
|
120
144
|
bootSchema,
|
|
@@ -131,7 +155,10 @@ import {
|
|
|
131
155
|
type HubSocketData,
|
|
132
156
|
type ServerEventHub,
|
|
133
157
|
} from './server-events.ts'
|
|
158
|
+
import { createSetupRoutes } from './setup.ts'
|
|
159
|
+
import { summariseUpgrades } from './upgrade-summary.ts'
|
|
134
160
|
import { assertViewRuntime } from './view-runtime.ts'
|
|
161
|
+
import { bundleEventProblems, createWebhookService, webhookStoreView } from './webhooks.ts'
|
|
135
162
|
|
|
136
163
|
const CONTRACT_PATH = Bun.resolveSync('@crombolo/contracts/OpenApi.json', import.meta.dir)
|
|
137
164
|
|
|
@@ -183,9 +210,6 @@ export interface ServerHandle {
|
|
|
183
210
|
close(): Promise<void>
|
|
184
211
|
}
|
|
185
212
|
|
|
186
|
-
/** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
|
|
187
|
-
export const PREVIEW_COOKIE = 'UMB_PREVIEW'
|
|
188
|
-
|
|
189
213
|
export async function createServer(config: CromboloConfig = loadConfig()): Promise<ServerHandle> {
|
|
190
214
|
configureLogging({
|
|
191
215
|
logsDir: config.logsDir,
|
|
@@ -209,6 +233,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
209
233
|
}
|
|
210
234
|
}
|
|
211
235
|
const domainLog = logger('domains')
|
|
236
|
+
const apiLog = logger('management-api')
|
|
212
237
|
// Extensions are the site's own npm dependencies (`docs/bundles.md`).
|
|
213
238
|
// Uncached in development so an author editing one sees a reload pick it up.
|
|
214
239
|
const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
|
|
@@ -223,7 +248,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
223
248
|
(await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
|
|
224
249
|
applyContentOnInstall: config.applyBundleContentOnInstall,
|
|
225
250
|
applyContent: async (packageName) => {
|
|
226
|
-
|
|
251
|
+
// A package from the marketplace is for anyone, so nothing in it can be
|
|
252
|
+
// sealed; one that is is refused rather than half applied.
|
|
253
|
+
const result = await installBundleContent(db, config, { bundle: packageName }).catch(
|
|
254
|
+
(error: unknown) => {
|
|
255
|
+
if (error instanceof CommandError) return error
|
|
256
|
+
throw error
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
if (result instanceof CommandError)
|
|
260
|
+
return { applied: false, message: `Its content was not applied: ${result.message}` }
|
|
227
261
|
if (!result.runId) {
|
|
228
262
|
const reasons = [
|
|
229
263
|
...result.problems,
|
|
@@ -231,7 +265,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
231
265
|
]
|
|
232
266
|
return {
|
|
233
267
|
applied: false,
|
|
234
|
-
message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}.
|
|
268
|
+
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
269
|
}
|
|
236
270
|
}
|
|
237
271
|
// The content landed under a cache this process had already constructed.
|
|
@@ -247,12 +281,20 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
247
281
|
const owns = config.role !== 'web'
|
|
248
282
|
// Bundles are checked before the database is touched, because a bundle that
|
|
249
283
|
// owns tables is migrated at boot, and a bad declaration must not get that far.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
284
|
+
// On every role: a bundle's form types are needed wherever forms render and
|
|
285
|
+
// submit. Its routes and tables stay with the nodes that serve the backoffice.
|
|
286
|
+
const bundlePackages = extensions.bundles().bundles
|
|
287
|
+
const formTypes = formTypesOf(config)
|
|
288
|
+
{
|
|
289
|
+
const problems = [
|
|
290
|
+
...validateServerBundles(config.bundles, bundlePackages, VERSION),
|
|
291
|
+
...formTypes.problems,
|
|
292
|
+
...bundleEventProblems(config.bundles),
|
|
293
|
+
]
|
|
253
294
|
if (problems.length > 0)
|
|
254
295
|
throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
|
|
255
296
|
}
|
|
297
|
+
setFormFieldTypes(formTypes.types, formTypes.renderers)
|
|
256
298
|
const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
|
|
257
299
|
const { db, seededAdminPassword } = owns
|
|
258
300
|
? await bootstrapDatabase(config, chains)
|
|
@@ -283,6 +325,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
283
325
|
ipAddress: peers.get(request) ?? null,
|
|
284
326
|
forwardedFor: request.headers.get('x-forwarded-for'),
|
|
285
327
|
})
|
|
328
|
+
// The global cap behind the per-caller one is what holds when a forwarded
|
|
329
|
+
// address is spoofed to dodge it.
|
|
330
|
+
const setup = createSetupRoutes({
|
|
331
|
+
db,
|
|
332
|
+
setupCode: config.setupCode,
|
|
333
|
+
siteName: config.siteName,
|
|
334
|
+
backOfficePath: paths.backOfficePath,
|
|
335
|
+
clientKey: (request) =>
|
|
336
|
+
peers.get(request) ?? request.headers.get('x-forwarded-for') ?? 'unknown',
|
|
337
|
+
})
|
|
286
338
|
const authRoutes = createAuthRoutes({
|
|
287
339
|
backOfficePath: paths.backOfficePath,
|
|
288
340
|
service: auth,
|
|
@@ -333,15 +385,39 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
333
385
|
|
|
334
386
|
// Form definitions are files, so they are re-read rather than cached with the
|
|
335
387
|
// content: nothing about a publish tells this node that a form changed.
|
|
336
|
-
const forms = createFormRegistry(config.schemaDir)
|
|
388
|
+
const forms = createFormRegistry(config.schemaDir, { formTypes: formTypes.types })
|
|
337
389
|
// Every form this node renders carries a token signed with the site's key, so
|
|
338
390
|
// the timing guard cannot be bypassed by posting a made-up render time.
|
|
339
391
|
const formSecret = await formSigningSecret(db)
|
|
340
392
|
setFormTokenSigner((formKey, renderedAt) => signFormToken(formSecret, formKey, renderedAt))
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
393
|
+
const publishedSource = createPublishedContentSource(db, {
|
|
394
|
+
nodeState: schema.nodeState,
|
|
395
|
+
nodeId: schema.nodeId,
|
|
396
|
+
})
|
|
397
|
+
const cacheOptions = { form: (key: string) => forms.byKey(key) }
|
|
398
|
+
const cache = new PublishedCache(publishedSource, cacheOptions)
|
|
399
|
+
// A sync or a Settings change that alters how URLs are built moves every URL:
|
|
400
|
+
// the old ones redirect, as a rename's do.
|
|
401
|
+
const urlChange: UrlChangeContext = {
|
|
402
|
+
db,
|
|
403
|
+
config,
|
|
404
|
+
source: publishedSource,
|
|
405
|
+
cacheOptions,
|
|
406
|
+
cache,
|
|
407
|
+
tracker: createRedirectTracker({
|
|
408
|
+
cache,
|
|
409
|
+
redirects: new RedirectRepository(db),
|
|
410
|
+
enabled: config.trackRedirects,
|
|
411
|
+
}),
|
|
412
|
+
}
|
|
413
|
+
const afterSync = async (report: SyncReport | undefined) => {
|
|
414
|
+
if (!report?.urls) return
|
|
415
|
+
await redirectAfterUrlChange(urlChange, report.urls.before)
|
|
416
|
+
log.info('Page addresses now {mode} the top-level page; old addresses redirect', {
|
|
417
|
+
mode: report.urls.after.hideTopLevel ? 'leave out' : 'include',
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
await afterSync(schema.report)
|
|
345
421
|
// A snapshot of the views, so a template edited anywhere — the backoffice, a
|
|
346
422
|
// deploy, a bucket synced underneath — is picked up without restarting this
|
|
347
423
|
// node. `assertViewRuntime` first, because a cache directory the JSX runtime
|
|
@@ -462,6 +538,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
462
538
|
{ nodeId: schema.nodeId, revision: config.schemaRevision },
|
|
463
539
|
(report: SyncReport) => {
|
|
464
540
|
cache.invalidate()
|
|
541
|
+
void afterSync(report).catch((error: unknown) =>
|
|
542
|
+
schemaLog.error('Recording redirects after an address change failed: {error}', {
|
|
543
|
+
error: String(error),
|
|
544
|
+
}),
|
|
545
|
+
)
|
|
465
546
|
if (report.action === 'refused')
|
|
466
547
|
schemaLog.error('Schema change refused: {reason}', {
|
|
467
548
|
reason:
|
|
@@ -486,6 +567,26 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
486
567
|
nodeState: schema.nodeState,
|
|
487
568
|
nodeId: schema.nodeId,
|
|
488
569
|
})
|
|
570
|
+
const deliverySettings = resolveDeliveryApiSettings(config.deliveryApi)
|
|
571
|
+
const deliveryPrefix = DELIVERY_API_PREFIX
|
|
572
|
+
const deliveryMembers = createDeliveryMemberSecurity({
|
|
573
|
+
db,
|
|
574
|
+
settings: deliverySettings,
|
|
575
|
+
members,
|
|
576
|
+
})
|
|
577
|
+
for (const problem of deliveryMembers.problems)
|
|
578
|
+
log.warning('Delivery API: {problem}', { problem })
|
|
579
|
+
const deliveryApi =
|
|
580
|
+
config.role === 'api'
|
|
581
|
+
? undefined
|
|
582
|
+
: createDeliveryApi({
|
|
583
|
+
settings: deliverySettings,
|
|
584
|
+
cache,
|
|
585
|
+
prefix: deliveryPrefix,
|
|
586
|
+
protection: () => new PublicAccessRepository(db).allRules(),
|
|
587
|
+
member: (token) => deliveryMembers.member(token),
|
|
588
|
+
security: (request, action) => deliveryMembers.handle(request, action),
|
|
589
|
+
})
|
|
489
590
|
|
|
490
591
|
/** A document's latest saved version, as preview shows it, in the published tree's context. */
|
|
491
592
|
async function draftModel(key: string, requested: string | null) {
|
|
@@ -531,12 +632,6 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
531
632
|
...entry,
|
|
532
633
|
})
|
|
533
634
|
})
|
|
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
635
|
// The site's config may name a store; without one the environment decides,
|
|
541
636
|
// and that falls back to the media directory on this disk.
|
|
542
637
|
const mediaStore = config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir))
|
|
@@ -550,6 +645,18 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
550
645
|
const canSendUserLinks = Boolean(resolveUserLinkSender(config, email))
|
|
551
646
|
const mediaFiles = new MediaFileStore(mediaStore)
|
|
552
647
|
const images = new ImageProcessor(mediaStore)
|
|
648
|
+
const webhooks = createWebhookService({
|
|
649
|
+
db,
|
|
650
|
+
schemaDir: config.schemaDir,
|
|
651
|
+
filesWritable: config.schemaWritable,
|
|
652
|
+
secretKey: config.secretKey,
|
|
653
|
+
bundles: config.bundles,
|
|
654
|
+
onFilesChanged: config.schemaStore
|
|
655
|
+
? async () => {
|
|
656
|
+
await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
|
|
657
|
+
}
|
|
658
|
+
: undefined,
|
|
659
|
+
})
|
|
553
660
|
const api = createManagementApiRouter({
|
|
554
661
|
deps: {
|
|
555
662
|
...createDeps({
|
|
@@ -557,6 +664,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
557
664
|
paths,
|
|
558
665
|
extensions,
|
|
559
666
|
db,
|
|
667
|
+
webhooks,
|
|
560
668
|
cache,
|
|
561
669
|
schema,
|
|
562
670
|
mediaFiles,
|
|
@@ -579,16 +687,24 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
579
687
|
})
|
|
580
688
|
},
|
|
581
689
|
}),
|
|
582
|
-
preview: previewPort,
|
|
583
690
|
},
|
|
584
691
|
authenticate,
|
|
585
692
|
nodes: createNodeLookup(db),
|
|
586
693
|
allowLocalLogin: config.allowLocalLogin,
|
|
587
694
|
audit: createAuditSink(db),
|
|
588
695
|
clientAddress,
|
|
696
|
+
onError: (error) => {
|
|
697
|
+
const answered = busyResponse(error)
|
|
698
|
+
if (answered) apiLog.warn('A write gave up waiting for another: {error}', { error })
|
|
699
|
+
return answered
|
|
700
|
+
},
|
|
589
701
|
onNotImplemented: config.development
|
|
590
702
|
? (operation, request) => notImplemented.record(operation, request)
|
|
591
703
|
: undefined,
|
|
704
|
+
// Queued after the response, so a slow receiver never holds an editor up.
|
|
705
|
+
onWebhookEvent: (event) => {
|
|
706
|
+
void webhooks.raise(event)
|
|
707
|
+
},
|
|
592
708
|
onServerEvent: (event) => {
|
|
593
709
|
events.broadcast('notify', [event])
|
|
594
710
|
// Saves, deletes and moves all announce themselves; whatever they queued gets indexed now.
|
|
@@ -641,6 +757,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
641
757
|
mediaFiles,
|
|
642
758
|
// The workflow queue drains here rather than in the request: a submission is
|
|
643
759
|
// stored first, and an e-mail that cannot be sent yet must not cost it.
|
|
760
|
+
webhooks,
|
|
644
761
|
formWorkflows: createWorkflowRunner({
|
|
645
762
|
db,
|
|
646
763
|
forms,
|
|
@@ -649,6 +766,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
649
766
|
nodeState: schema.nodeState,
|
|
650
767
|
nodeId: schema.nodeId,
|
|
651
768
|
onContentChanged: () => cache.invalidate(),
|
|
769
|
+
bundleWorkflows: formTypes.workflows,
|
|
652
770
|
}),
|
|
653
771
|
onDocumentChanged: (key) =>
|
|
654
772
|
events.broadcast('notify', [
|
|
@@ -667,6 +785,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
667
785
|
// App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
|
|
668
786
|
// extensions silently stopped loading after an upgrade; remove at 1.0.
|
|
669
787
|
warnAboutAppPlugins(config.siteDir, log)
|
|
788
|
+
// Named, never shown: the value is the one thing this must not print.
|
|
789
|
+
if (config.setupCode && (await setup.pending()) === false)
|
|
790
|
+
log.warning('CROMBOLO_SETUP_CODE is still set, but the site is set up. Remove the setting.')
|
|
670
791
|
noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
|
|
671
792
|
const hubPath = SERVER_EVENT_HUB_PATH
|
|
672
793
|
const previewHubPath = PREVIEW_HUB_PATH
|
|
@@ -680,7 +801,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
680
801
|
module.createAssistantHost({
|
|
681
802
|
config: config.assistant as NonNullable<typeof config.assistant>,
|
|
682
803
|
db,
|
|
683
|
-
schema: createSchemaProposals({ db, config, boot: schema }),
|
|
804
|
+
schema: createSchemaProposals({ db, config, boot: schema, afterSync }),
|
|
684
805
|
siteName: config.siteName,
|
|
685
806
|
version: VERSION,
|
|
686
807
|
dispatch: (inner) => api.dispatch(inner),
|
|
@@ -747,6 +868,13 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
747
868
|
// `web` node falls straight through to the public site, so the backoffice,
|
|
748
869
|
// the Management API and the sign-in routes are not merely unauthorised
|
|
749
870
|
// there — they are not routes at all, and 404 like any other unknown path.
|
|
871
|
+
// Umbraco's Delivery API, below the backoffice path but served wherever the
|
|
872
|
+
// site is, from the published cache: it scales with rendering.
|
|
873
|
+
if (deliveryApi && pathname.startsWith(`${deliveryPrefix}/`)) {
|
|
874
|
+
const response = await deliveryApi.handle(request)
|
|
875
|
+
if (response) return response
|
|
876
|
+
}
|
|
877
|
+
|
|
750
878
|
if (servesBackOffice) {
|
|
751
879
|
const response = await serveBackOffice(request, url)
|
|
752
880
|
if (response) {
|
|
@@ -819,6 +947,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
819
947
|
async function serveBackOffice(request: Request, url: URL): Promise<Response | undefined> {
|
|
820
948
|
const { pathname } = url
|
|
821
949
|
|
|
950
|
+
if (pathname === setup.path) return setup.handle(request)
|
|
951
|
+
// Until the first administrator exists there is nobody to sign in as.
|
|
952
|
+
if (
|
|
953
|
+
request.method === 'GET' &&
|
|
954
|
+
(pathname === `${paths.backOfficePath}/login` ||
|
|
955
|
+
isSpaRoute(paths.backOfficePath, pathname)) &&
|
|
956
|
+
(await setup.pending())
|
|
957
|
+
)
|
|
958
|
+
return new Response(null, { status: 303, headers: { location: setup.path } })
|
|
959
|
+
|
|
822
960
|
// Non-contract, signed-in: the assistant's chat, review and MCP endpoints.
|
|
823
961
|
// Absent configuration there is no `assistant`, so these paths 404 like any
|
|
824
962
|
// other unknown route.
|
|
@@ -868,6 +1006,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
868
1006
|
}
|
|
869
1007
|
const dryRun = request.method === 'GET'
|
|
870
1008
|
const result = await importSchema(db, config, schema, { dryRun })
|
|
1009
|
+
if (!dryRun) await afterSync(result.report)
|
|
871
1010
|
if (!dryRun) {
|
|
872
1011
|
log.info('{user} imported the schema: {action}, {classification}', {
|
|
873
1012
|
user: principal.userName,
|
|
@@ -899,7 +1038,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
899
1038
|
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
900
1039
|
if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
|
|
901
1040
|
const body = JSON.stringify({
|
|
902
|
-
libs: await editorTypeLibs({
|
|
1041
|
+
libs: await editorTypeLibs({
|
|
1042
|
+
schemaDir: config.schemaDir,
|
|
1043
|
+
formTypes: formTypesFor(config),
|
|
1044
|
+
}),
|
|
903
1045
|
jsxImportSource: siteJsxImportSource(config.siteDir),
|
|
904
1046
|
})
|
|
905
1047
|
// Revalidated on every open rather than kept: the generated types follow
|
|
@@ -1026,6 +1168,33 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1026
1168
|
* files are the truth, the backoffice may have just rewritten one, and a
|
|
1027
1169
|
* picker that disagreed with what a render uses would be worse than slow.
|
|
1028
1170
|
*/
|
|
1171
|
+
// Non-contract, Settings: the "Where it's kept" panel on a webhook's screen.
|
|
1172
|
+
const kept = new RegExp(`^${paths.pluginPath}/api/webhooks/([0-9a-fA-F-]{36})/store$`).exec(
|
|
1173
|
+
pathname,
|
|
1174
|
+
)
|
|
1175
|
+
if (kept) {
|
|
1176
|
+
const principal = await authenticate(request)
|
|
1177
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1178
|
+
if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
|
|
1179
|
+
const key = (kept[1] as string).toLowerCase()
|
|
1180
|
+
if (request.method === 'PUT') {
|
|
1181
|
+
const body = (await request.json().catch(() => ({}))) as { store?: unknown }
|
|
1182
|
+
if (body.store !== 'files' && body.store !== 'database')
|
|
1183
|
+
return Response.json({ reason: 'Choose files or database.' }, { status: 400 })
|
|
1184
|
+
const moved = await webhooks.move(key, body.store)
|
|
1185
|
+
return moved.ok
|
|
1186
|
+
? Response.json({ ok: true })
|
|
1187
|
+
: Response.json(
|
|
1188
|
+
{ reason: moved.reason, problems: moved.problems ?? [] },
|
|
1189
|
+
{ status: moved.status },
|
|
1190
|
+
)
|
|
1191
|
+
}
|
|
1192
|
+
if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
|
|
1193
|
+
const webhook = await webhooks.get(key)
|
|
1194
|
+
if (!webhook) return new Response('Not Found', { status: 404 })
|
|
1195
|
+
return Response.json(webhookStoreView(webhook.store, webhooks.stores()))
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1029
1198
|
if (pathname.startsWith(`${paths.pluginPath}/api/forms`)) {
|
|
1030
1199
|
const principal = await authenticate(request)
|
|
1031
1200
|
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
@@ -1037,7 +1206,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1037
1206
|
// A definition is structure, like a content type: anyone who may open the
|
|
1038
1207
|
// section may read it, and the picker needs it in the Content section too.
|
|
1039
1208
|
if (action === '' && request.method === 'GET')
|
|
1040
|
-
return Response.json({
|
|
1209
|
+
return Response.json({
|
|
1210
|
+
items: definitions.map((form) => formSummary(form, formTypes.types)),
|
|
1211
|
+
})
|
|
1212
|
+
// Hyphenated, so no form alias (letters and digits) can be mistaken for it.
|
|
1213
|
+
if (action === 'available-types' && request.method === 'GET')
|
|
1214
|
+
return Response.json(describeFormTypes(formTypes.types))
|
|
1041
1215
|
|
|
1042
1216
|
const entries = new FormEntryRepository(db)
|
|
1043
1217
|
const byKey = formsByKey(definitions)
|
|
@@ -1048,6 +1222,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1048
1222
|
const formFiles = {
|
|
1049
1223
|
schemaDir: config.schemaDir,
|
|
1050
1224
|
writable: config.schemaWritable,
|
|
1225
|
+
formTypes: formTypes.types,
|
|
1051
1226
|
onChanged: config.schemaStore
|
|
1052
1227
|
? async () => {
|
|
1053
1228
|
await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
|
|
@@ -1124,7 +1299,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1124
1299
|
const form = byKey.get(key)
|
|
1125
1300
|
if (!form) return new Response('Not Found', { status: 404 })
|
|
1126
1301
|
const page = await entries.list({ formKey: key, take: 10_000 })
|
|
1127
|
-
const result = entriesCsv(form, page.items, {
|
|
1302
|
+
const result = entriesCsv(form, page.items, {
|
|
1303
|
+
includeSensitive: maySeeSensitive,
|
|
1304
|
+
types: formTypes.types,
|
|
1305
|
+
})
|
|
1128
1306
|
if (!result.ok)
|
|
1129
1307
|
return new Response(result.reason, {
|
|
1130
1308
|
status: 403,
|
|
@@ -1171,6 +1349,25 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1171
1349
|
return Response.json({ ...formDetail(form), definition: form })
|
|
1172
1350
|
}
|
|
1173
1351
|
|
|
1352
|
+
// What a choice source offers now, in the default language, for the
|
|
1353
|
+
// designer to show beside the source it is editing.
|
|
1354
|
+
if (action === 'choices' && request.method === 'POST') {
|
|
1355
|
+
if (!may(FormPermissions.Manage)) return new Response('Forbidden', { status: 403 })
|
|
1356
|
+
const body = (await request.json().catch(() => ({}))) as { source?: unknown }
|
|
1357
|
+
const source = body.source as { kind?: unknown; parent?: unknown } | undefined
|
|
1358
|
+
if (
|
|
1359
|
+
!source ||
|
|
1360
|
+
!CHOICE_SOURCE_KINDS.includes(source.kind as ChoiceSourceKind) ||
|
|
1361
|
+
typeof source.parent !== 'string'
|
|
1362
|
+
)
|
|
1363
|
+
return new Response('Bad Request', { status: 400 })
|
|
1364
|
+
const options = await cache.choiceOptions({
|
|
1365
|
+
kind: source.kind as ChoiceSourceKind,
|
|
1366
|
+
parent: source.parent,
|
|
1367
|
+
})
|
|
1368
|
+
return Response.json({ options })
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1174
1371
|
// Designing a form is a developer's job, so it is its own verb rather
|
|
1175
1372
|
// than something an entry reader can do.
|
|
1176
1373
|
if (request.method === 'POST' || request.method === 'PUT') {
|
|
@@ -1235,6 +1432,60 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1235
1432
|
if (response) return response
|
|
1236
1433
|
}
|
|
1237
1434
|
|
|
1435
|
+
// Non-contract, Settings: how page addresses are built (story 110).
|
|
1436
|
+
if (pathname === `${paths.pluginPath}/api/url-options`) {
|
|
1437
|
+
const principal = await authenticate(request)
|
|
1438
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1439
|
+
if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
|
|
1440
|
+
if (request.method === 'GET')
|
|
1441
|
+
return Response.json({
|
|
1442
|
+
...(await readUrlOptions(db)),
|
|
1443
|
+
editable: urlSettingEditable(config, existsSync(config.schemaDir)),
|
|
1444
|
+
})
|
|
1445
|
+
if (request.method !== 'PUT') return new Response('Method Not Allowed', { status: 405 })
|
|
1446
|
+
const body = (await request.json().catch(() => ({}))) as { hideTopLevel?: unknown }
|
|
1447
|
+
if (typeof body.hideTopLevel !== 'boolean')
|
|
1448
|
+
return Response.json(
|
|
1449
|
+
{ ok: false, message: 'Say whether to hide the top-level page.' },
|
|
1450
|
+
{ status: 400 },
|
|
1451
|
+
)
|
|
1452
|
+
const outcome = await changeUrlOptions(urlChange, { hideTopLevel: body.hideTopLevel })
|
|
1453
|
+
log.info('{user} set page addresses to {mode} the top-level page: {outcome}', {
|
|
1454
|
+
user: principal.email,
|
|
1455
|
+
mode: body.hideTopLevel ? 'leave out' : 'include',
|
|
1456
|
+
outcome: outcome.ok ? 'ok' : outcome.message,
|
|
1457
|
+
})
|
|
1458
|
+
return outcome.ok
|
|
1459
|
+
? Response.json({ ok: true, ...outcome.options })
|
|
1460
|
+
: Response.json({ ok: false, message: outcome.message }, { status: outcome.status })
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Non-contract, Packages: answering a content import's conflicts and running
|
|
1464
|
+
// it, from the Changes dashboard. Packages, because installing is.
|
|
1465
|
+
if (pathname.startsWith(`${paths.pluginPath}/api/transfers/`)) {
|
|
1466
|
+
const principal = await authenticate(request)
|
|
1467
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1468
|
+
if (!hasSection(principal, 'packages')) return new Response('Forbidden', { status: 403 })
|
|
1469
|
+
const action = pathname.slice(`${paths.pluginPath}/api/transfers/`.length)
|
|
1470
|
+
const response = await handleTransferAction(request, action, {
|
|
1471
|
+
db,
|
|
1472
|
+
config,
|
|
1473
|
+
user: principal.email,
|
|
1474
|
+
onApplied: () => cache.invalidate(),
|
|
1475
|
+
})
|
|
1476
|
+
if (response) {
|
|
1477
|
+
if (request.method === 'POST')
|
|
1478
|
+
log.info('{user} {verb} for the content bundle {bundle}: {status}', {
|
|
1479
|
+
user: principal.email,
|
|
1480
|
+
verb: action.split('/').pop(),
|
|
1481
|
+
bundle: action.split('/')[0],
|
|
1482
|
+
status: response.status,
|
|
1483
|
+
})
|
|
1484
|
+
return response
|
|
1485
|
+
}
|
|
1486
|
+
return new Response('Not Found', { status: 404 })
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1238
1489
|
if (pathname === `${paths.pluginPath}/api/change-report`) {
|
|
1239
1490
|
const principal = await authenticate(request)
|
|
1240
1491
|
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
@@ -1254,7 +1505,24 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1254
1505
|
// A bundle's tables are part of what an upgrade changes, so the dashboard
|
|
1255
1506
|
// that shows an upgrade's findings shows their steps too.
|
|
1256
1507
|
const bundles = await bundleTablesReport(db, chains)
|
|
1257
|
-
|
|
1508
|
+
const upgrades = summariseUpgrades(await readLedger(db), VERSION)
|
|
1509
|
+
// The answers a finding can be given, offered only to those who may import.
|
|
1510
|
+
const mayImport = hasSection(principal, 'packages')
|
|
1511
|
+
const offered = findings.map((f) =>
|
|
1512
|
+
f.source === 'transfer' && f.status === 'open' && mayImport
|
|
1513
|
+
? { ...f, choices: choicesFor(f) }
|
|
1514
|
+
: f,
|
|
1515
|
+
)
|
|
1516
|
+
return Response.json({
|
|
1517
|
+
health: health(),
|
|
1518
|
+
counts,
|
|
1519
|
+
bySource,
|
|
1520
|
+
findings: offered,
|
|
1521
|
+
bundles,
|
|
1522
|
+
upgrades,
|
|
1523
|
+
transfers: await pendingTransfers(db),
|
|
1524
|
+
mayImport,
|
|
1525
|
+
})
|
|
1258
1526
|
}
|
|
1259
1527
|
|
|
1260
1528
|
const staticMatch = resolveStaticFile(paths, pathname, {
|
|
@@ -1471,6 +1739,21 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1471
1739
|
mediaFiles,
|
|
1472
1740
|
uploads: DEFAULT_UPLOAD_SETTINGS,
|
|
1473
1741
|
urlOf: async (contentKey) => (await cache.byKey(contentKey))?.url,
|
|
1742
|
+
// The page it was sent from names the culture; the keys a source offers
|
|
1743
|
+
// are the same in every culture, so only the stored labels depend on it.
|
|
1744
|
+
resolveForm: async (form) => {
|
|
1745
|
+
const referer = request.headers.get('referer')
|
|
1746
|
+
let culture: string | null = null
|
|
1747
|
+
try {
|
|
1748
|
+
if (referer) {
|
|
1749
|
+
const url = new URL(referer)
|
|
1750
|
+
culture = await cache.cultureFor(url.host, url.pathname)
|
|
1751
|
+
}
|
|
1752
|
+
} catch {
|
|
1753
|
+
culture = null
|
|
1754
|
+
}
|
|
1755
|
+
return cache.resolveForm(form, culture)
|
|
1756
|
+
},
|
|
1474
1757
|
},
|
|
1475
1758
|
request,
|
|
1476
1759
|
key,
|
|
@@ -1507,6 +1790,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1507
1790
|
? Response.json({ ok: false, error: message }, { status: 409 })
|
|
1508
1791
|
: plain(message, 409)
|
|
1509
1792
|
}
|
|
1793
|
+
// Only the script checks a page, so this always answers in JSON.
|
|
1794
|
+
case 'checked':
|
|
1795
|
+
return outcome.errors.length > 0
|
|
1796
|
+
? Response.json({ ok: false, errors: outcome.errors }, { status: 422 })
|
|
1797
|
+
: Response.json({ ok: true })
|
|
1510
1798
|
case 'invalid':
|
|
1511
1799
|
return json
|
|
1512
1800
|
? Response.json({ ok: false, errors: outcome.state.errors }, { status: 422 })
|