@crombolo/server 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
package/src/server.ts ADDED
@@ -0,0 +1,1579 @@
1
+ /**
2
+ * The Crombolo server.
3
+ *
4
+ * Route order matters and mirrors docs/01-architecture.md: static assets, the
5
+ * non-contract security and branding endpoints, the contract-first Management
6
+ * API, the SPA shells, then the front-end site.
7
+ */
8
+
9
+ import { join } from 'node:path'
10
+ import { createManagementApiRouter, hasSection, type Principal } from '@crombolo/api-management'
11
+ import {
12
+ AuthService,
13
+ AuthStore,
14
+ cookieNames,
15
+ createAuthRoutes,
16
+ expireCookie,
17
+ REDACTED,
18
+ readCookie,
19
+ serializeCookie,
20
+ } from '@crombolo/auth'
21
+ import {
22
+ collectManifests,
23
+ createBackOfficePaths,
24
+ createExtensionRegistry,
25
+ isSpaRoute,
26
+ renderBackOfficeShell,
27
+ renderLoginShell,
28
+ resolveGraphic,
29
+ resolveLoginAsset,
30
+ resolveStaticFile,
31
+ serveStaticFile,
32
+ } from '@crombolo/backoffice-host'
33
+ import {
34
+ DEFAULT_UPLOAD_SETTINGS,
35
+ FormPermissions,
36
+ hasAccessToSensitiveData,
37
+ localReturnUrl,
38
+ MANAGEMENT_API_PREFIX,
39
+ PREVIEW_HUB_PATH,
40
+ SERVER_EVENT_HUB_PATH,
41
+ } from '@crombolo/core'
42
+ import {
43
+ appendCacheInstruction,
44
+ type Db,
45
+ FormEntryRepository,
46
+ FormWorkflowRepository,
47
+ RedirectRepository,
48
+ readReport,
49
+ type WritesPaused,
50
+ } from '@crombolo/data'
51
+ import {
52
+ contentForNode,
53
+ DEVELOPMENT_LIMITS,
54
+ FORM_SCRIPT,
55
+ FORM_SCRIPT_PATH,
56
+ FORM_SUBMIT_PATH,
57
+ PublishedCache,
58
+ Renderer,
59
+ type SnapshotStatus,
60
+ setFormTokenSigner,
61
+ ViewSnapshots,
62
+ } from '@crombolo/render'
63
+ import type { SyncReport } from '@crombolo/schema'
64
+ import type { Server } from 'bun'
65
+ import { createNodeLookup, loadAccess } from './access.ts'
66
+ import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
67
+ import { createSchemaProposals } from './adapters/schema-proposals.ts'
68
+ import { createBundleRedirects, createServerBundles } from './bundles.ts'
69
+ import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
70
+ import { attachDatabase, bootstrapDatabase } from './database.ts'
71
+ import { createDiagnostics } from './diagnostics.ts'
72
+ import { syncDomainsFile } from './domains.ts'
73
+ import { editorTypeLibs, siteJsxImportSource } from './editor-types.ts'
74
+ import { emailAvailability, resolveEmailPort } from './email.ts'
75
+ import { noticeAboutEmail } from './email-notice.ts'
76
+ import { warnAboutAppPlugins } from './extensions-notice.ts'
77
+ import { coerceForm, deleteForm, saveForm } from './form-files.ts'
78
+ import {
79
+ createFormRegistry,
80
+ FORM_FLASH_COOKIE,
81
+ formSigningSecret,
82
+ readFlash,
83
+ signFormToken,
84
+ submitForm,
85
+ wantsJson,
86
+ writeFlash,
87
+ } from './form-submit.ts'
88
+ import { createWorkflowRunner } from './form-workflows.ts'
89
+ import {
90
+ entriesCsv,
91
+ formDetail,
92
+ formSummary,
93
+ formsByKey,
94
+ parseEntryState,
95
+ redactSensitive,
96
+ } from './forms.ts'
97
+ import { ImageProcessor, parseImagingQuery } from './imaging.ts'
98
+ import { type BackgroundJobs, createBackgroundJobs } from './jobs.ts'
99
+ import { configureLogging, logger } from './logging.ts'
100
+ import { createMarketplace } from './marketplace.ts'
101
+ import { MediaFileStore } from './media-files.ts'
102
+ import { mediaStoreFromEnvironment } from './media-store.ts'
103
+ import { createMemberAuth, MEMBER_ROUTE_PREFIX } from './member-auth.ts'
104
+ import { noNodesPage } from './no-nodes.ts'
105
+ import { NotImplementedLog } from './not-implemented.ts'
106
+ import { createDeps, resolveUserLinkSender } from './ports.ts'
107
+ import { syncConfiguredRedirects } from './redirects.ts'
108
+ import {
109
+ bootSchema,
110
+ CacheInstructionPoller,
111
+ importSchema,
112
+ type SchemaBoot,
113
+ watchSchema,
114
+ } from './schema.ts'
115
+ import { materialiseSchema, publishSchema, type SchemaStore } from './schema-store.ts'
116
+ import { createSeoHost, originOf } from './seo.ts'
117
+ import {
118
+ combineHubs,
119
+ createServerEventHub,
120
+ type HubSocketData,
121
+ type ServerEventHub,
122
+ } from './server-events.ts'
123
+ import { assertViewRuntime } from './view-runtime.ts'
124
+
125
+ const CONTRACT_PATH = Bun.resolveSync('@crombolo/contracts/OpenApi.json', import.meta.dir)
126
+
127
+ export interface ServerHandle {
128
+ config: CromboloConfig
129
+ fetch(request: Request): Promise<Response>
130
+ /** Everything `Bun.serve` needs, the live-update WebSocket included. */
131
+ serveOptions: {
132
+ port: number
133
+ maxRequestBodySize: number
134
+ fetch(request: Request, server: Server<HubSocketData>): Promise<Response | undefined>
135
+ websocket: ServerEventHub['websocket']
136
+ }
137
+ /** The live-update channel the backoffice holds open. */
138
+ events: ServerEventHub
139
+ paths: ReturnType<typeof createBackOfficePaths>
140
+ db: Db
141
+ auth: AuthService
142
+ cache: PublishedCache
143
+ renderer: Renderer
144
+ /** The views generations this node has taken; `status()` is what /health reports. */
145
+ snapshots: ViewSnapshots
146
+ seededAdminPassword: string | undefined
147
+ schema: SchemaBoot
148
+ /** Applies cache instructions other nodes appended; the interval calls this too. */
149
+ poll(): Promise<number>
150
+ /** Development only: every operation the client asked for that answered 501, in first-seen order. */
151
+ notImplemented: NotImplementedLog
152
+ /**
153
+ * What /health reports. Not ok once the database is ahead of a node that
154
+ * serves the editor; a `web` node stays ok while behind until a contract has
155
+ * run past it, because its reads stay correct until then.
156
+ */
157
+ health(): {
158
+ ok: boolean
159
+ readOnly: boolean
160
+ /** Set while an operator has paused editing cluster-wide; drains nothing. */
161
+ paused: WritesPaused | null
162
+ version: string
163
+ revision: string
164
+ nodeId: string
165
+ role: ServerRole
166
+ /** The views generation in use, and whether newer ones are being refused. */
167
+ components: SnapshotStatus
168
+ }
169
+ /** Scheduled publishing and version cleanup; each run can be called directly. */
170
+ jobs: BackgroundJobs
171
+ /** Stops the watcher and the poll, then closes the database. */
172
+ close(): Promise<void>
173
+ }
174
+
175
+ /** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
176
+ export const PREVIEW_COOKIE = 'UMB_PREVIEW'
177
+
178
+ export async function createServer(config: CromboloConfig = loadConfig()): Promise<ServerHandle> {
179
+ configureLogging({
180
+ logsDir: config.logsDir,
181
+ level: config.logLevel,
182
+ console: config.logToConsole,
183
+ })
184
+ const diagnostics = createDiagnostics(config.diagnostics)
185
+ const log = logger('server')
186
+ const schemaLog = logger('schema')
187
+ const renderLog = logger('render')
188
+ const redirectLog = logger('redirects')
189
+ const bundleLog = (id: string) => {
190
+ const named = logger('bundle', id)
191
+ return {
192
+ info: (message: string, properties?: Record<string, unknown>) =>
193
+ named.info(message, properties),
194
+ warn: (message: string, properties?: Record<string, unknown>) =>
195
+ named.warn(message, properties),
196
+ error: (message: string, properties?: Record<string, unknown>) =>
197
+ named.error(message, properties),
198
+ }
199
+ }
200
+ const domainLog = logger('domains')
201
+ // Extensions are the site's own npm dependencies (`docs/17-bundles.md`).
202
+ // Uncached in development so an author editing one sees a reload pick it up.
203
+ const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
204
+ const marketplace = createMarketplace({
205
+ registry: config.npmRegistry,
206
+ keyword: config.packageKeyword,
207
+ marketplaceUrl: config.marketplaceUrl,
208
+ extensions,
209
+ siteDir: config.siteDir,
210
+ })
211
+ const paths = createBackOfficePaths({ backOfficePath: config.backOfficePath })
212
+ // Two questions, not one: what this process answers, and what it is
213
+ // responsible for converging at boot. `all` and `api` are both owners; only
214
+ // `all` and `web` serve the public site.
215
+ const servesBackOffice = config.role !== 'web'
216
+ const owns = config.role !== 'web'
217
+ const { db, seededAdminPassword } = owns
218
+ ? await bootstrapDatabase(config)
219
+ : await attachDatabase(config)
220
+ // A configured schema store is copied down before anything reads `schema/`, and
221
+ // becomes the schema directory for this boot. Everything after here — loading,
222
+ // validation, hashing, sync, the writer — works on files exactly as it always has.
223
+ if (config.schemaStore) {
224
+ const copied = await materialiseSchema(config.schemaStore, config.schemaCacheDir)
225
+ config.schemaDir = config.schemaCacheDir
226
+ schemaLog.info('Schema read from {store}: {count} files in {dir}', {
227
+ store: config.schemaStore.description,
228
+ count: copied.length,
229
+ dir: config.schemaCacheDir,
230
+ })
231
+ }
232
+ // A node that does not own the schema still loads it — the renderer needs the
233
+ // property definitions — but writes none of it.
234
+ const schema = await bootSchema(db, owns ? config : { ...config, syncSchemaAtBoot: false })
235
+
236
+ const auth = new AuthService(new AuthStore(db))
237
+ const cookies = { siteName: config.siteName, securePrefix: config.secureCookies }
238
+ const names = cookieNames(cookies)
239
+ const authRoutes = createAuthRoutes({
240
+ backOfficePath: paths.backOfficePath,
241
+ service: auth,
242
+ cookies,
243
+ })
244
+
245
+ /**
246
+ * Resolves the caller from the reference access token. The client sends
247
+ * `Authorization: Bearer [redacted]`, so the cookie is the real source; a
248
+ * non-browser client may still present a real bearer token.
249
+ */
250
+ async function authenticate(request: Request): Promise<Principal | undefined> {
251
+ const header = request.headers.get('authorization')
252
+ const bearer = header?.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : undefined
253
+ const token = bearer && bearer !== REDACTED ? bearer : readCookie(request, names.accessToken)
254
+ if (!token) return undefined
255
+
256
+ const identity = await auth.resolveAccessToken(token)
257
+ if (!identity) return undefined
258
+ return {
259
+ id: identity.user.key,
260
+ userName: identity.user.login,
261
+ name: identity.user.userName,
262
+ email: identity.user.email,
263
+ isAdmin: identity.isAdmin,
264
+ languageIsoCode: identity.user.language ?? undefined,
265
+ avatarUrls: identity.user.avatar ? [identity.user.avatar] : [],
266
+ allowedSections: identity.allowedSections,
267
+ permissions: identity.permissions,
268
+ groupKeys: identity.groupKeys,
269
+ hasAccessToAllLanguages: identity.hasAccessToAllLanguages,
270
+ ...(await loadAccess(db, identity.user.id)),
271
+ }
272
+ }
273
+
274
+ // Form definitions are files, so they are re-read rather than cached with the
275
+ // content: nothing about a publish tells this node that a form changed.
276
+ const forms = createFormRegistry(config.schemaDir)
277
+ // Every form this node renders carries a token signed with the site's key, so
278
+ // the timing guard cannot be bypassed by posting a made-up render time.
279
+ const formSecret = await formSigningSecret(db)
280
+ setFormTokenSigner((formKey, renderedAt) => signFormToken(formSecret, formKey, renderedAt))
281
+ const cache = new PublishedCache(
282
+ createPublishedContentSource(db, { nodeState: schema.nodeState, nodeId: schema.nodeId }),
283
+ { form: (key) => forms.byKey(key) },
284
+ )
285
+ // A snapshot of the views, so a template edited anywhere — the backoffice, a
286
+ // deploy, a bucket synced underneath — is picked up without restarting this
287
+ // node. `assertViewRuntime` first, because a cache directory the JSX runtime
288
+ // cannot be resolved from fails every render rather than the boot.
289
+ assertViewRuntime(config.componentsCacheDir)
290
+ const snapshots = new ViewSnapshots({
291
+ sourceDir: config.componentsDir,
292
+ cacheDir: config.componentsCacheDir,
293
+ limits: { ...(config.development ? DEVELOPMENT_LIMITS : {}), ...config.componentsSnapshot },
294
+ onProblem: (message) => renderLog.error('{message}', { message }),
295
+ // Only a change: every boot takes a generation, and saying so each time is
296
+ // noise. The start banner names the one a node came up on.
297
+ onSwap: (hash, previous) => {
298
+ if (previous)
299
+ renderLog.info('Views changed: generation {previous} to {hash}', {
300
+ previous: previous.slice(0, 8),
301
+ hash: hash.slice(0, 8),
302
+ })
303
+ },
304
+ })
305
+ await snapshots.prepare()
306
+ // Before the poller, which tells it to look again on a `views` instruction
307
+ // from another node: a closure over a `const` declared later would be a
308
+ // temporal dead zone waiting for the first instruction to land.
309
+ const renderer = new Renderer({
310
+ cache,
311
+ componentsDir: config.componentsDir,
312
+ snapshots,
313
+ renderTimeoutMs: config.renderTimeoutMs,
314
+ })
315
+ // The site's own redirects are code, so the database follows the file: rules it
316
+ // no longer declares are removed, which is what makes deleting one from config
317
+ // actually take effect.
318
+ //
319
+ // File-to-database convergence belongs to one node. Two of them racing would
320
+ // each remove what the other had just written, so a `web` node reads these
321
+ // rules through the cache and converges nothing.
322
+ if (owns) {
323
+ const configuredRedirects = await syncConfiguredRedirects(
324
+ new RedirectRepository(db),
325
+ config.redirects,
326
+ )
327
+ if (configuredRedirects.stored > 0 || configuredRedirects.removed > 0)
328
+ redirectLog.info('Configured redirects: {stored} in force, {removed} removed', {
329
+ stored: configuredRedirects.stored,
330
+ removed: configuredRedirects.removed,
331
+ })
332
+ // The same bargain for hostnames, from `domains.toml`: the file is the truth,
333
+ // so an entry deleted from it is unbound here. A site without the file is left
334
+ // alone, which is every site that has never written one.
335
+ const domains = await syncDomainsFile(db, config.siteDir)
336
+ for (const problem of domains.problems) domainLog.warn('{problem}', { problem })
337
+ if (domains.action === 'applied')
338
+ domainLog.info('Domains from domains.toml: {applied} bound, {removed} removed', {
339
+ applied: domains.applied.length,
340
+ removed: domains.removed,
341
+ })
342
+ }
343
+ /**
344
+ * The server halves of the site's bundles, mounted only where the backoffice
345
+ * is: these are backoffice endpoints, and a `web` node serves none of them.
346
+ *
347
+ * Capabilities are built here, from what this node already holds, so a bundle
348
+ * is handed a narrow interface rather than the database. A bad bundle fails the
349
+ * boot — see `createServerBundles`.
350
+ */
351
+ const bundles = servesBackOffice
352
+ ? createServerBundles({
353
+ bundles: config.bundles,
354
+ pluginApiPath: `${paths.pluginPath}/api`,
355
+ host: {
356
+ redirects: createBundleRedirects({ db, cache }),
357
+ documents: {
358
+ url: async (key, culture) => {
359
+ const content = await cache.byKey(key, culture ?? null)
360
+ return content && content.url !== '#' ? content.url : undefined
361
+ },
362
+ },
363
+ log: bundleLog('bundle'),
364
+ },
365
+ log: (id) => bundleLog(id),
366
+ })
367
+ : undefined
368
+ for (const bundle of bundles?.mounted ?? [])
369
+ log.info('Bundle {name} answering below {prefix} for the {section} section', {
370
+ name: bundle.name,
371
+ prefix: bundle.prefix,
372
+ section: bundle.section,
373
+ })
374
+
375
+ const poller = new CacheInstructionPoller({
376
+ db,
377
+ nodeId: schema.nodeId,
378
+ nodeState: schema.nodeState,
379
+ role: config.role,
380
+ // `schema` and `content` change what renders: schema alters property
381
+ // definitions. `views` changes the code that renders it, which is a new
382
+ // snapshot rather than anything in the published cache.
383
+ onInstruction: (kind, payload) => {
384
+ if (kind !== 'components') return cache.invalidate()
385
+ // The hash only says when to stop looking eagerly; the snapshot is always
386
+ // of the bytes this node reads, so a superseded one is harmless.
387
+ snapshots.announce(typeof payload.hash === 'string' ? payload.hash : undefined)
388
+ },
389
+ })
390
+ await poller.start(config.development ? 2_000 : 5_000)
391
+ const watcher =
392
+ config.development && owns
393
+ ? watchSchema(
394
+ db,
395
+ config,
396
+ { nodeId: schema.nodeId, revision: config.schemaRevision },
397
+ (report: SyncReport) => {
398
+ cache.invalidate()
399
+ if (report.action === 'refused')
400
+ schemaLog.error('Schema change refused: {reason}', {
401
+ reason:
402
+ report.reason ?? report.problems.map((p) => `${p.file}: ${p.message}`).join('; '),
403
+ })
404
+ else if (report.action === 'applied') {
405
+ // Move this node on with the database it has just synced. The
406
+ // repositories hold `schema.nodeState`, and `assertNodeMayWrite`
407
+ // refuses a write from a node behind the database — so without
408
+ // this, a schema change made through this server's own API (the
409
+ // backoffice creating a type, which bumps `schema.toml` and lands
410
+ // here through the watcher) leaves every later write refused until
411
+ // the process restarts. `importSchema` already does the same for
412
+ // the path it owns.
413
+ if (report.state?.version) schema.nodeState.version = report.state.version
414
+ schemaLog.info('Schema {version} applied', { version: report.state?.version ?? '' })
415
+ }
416
+ },
417
+ )
418
+ : undefined
419
+ const members = createMemberAuth(db, config, {
420
+ nodeState: schema.nodeState,
421
+ nodeId: schema.nodeId,
422
+ })
423
+
424
+ /** A document's latest saved version, as preview shows it, in the published tree's context. */
425
+ async function draftModel(key: string, requested: string | null) {
426
+ const draft = await loadDraftNode(db, key, {
427
+ nodeState: schema.nodeState,
428
+ nodeId: schema.nodeId,
429
+ })
430
+ if (!draft) return undefined
431
+ const snapshot = await cache.snapshot()
432
+ const culture =
433
+ requested ||
434
+ (draft.cultures.length > 0 ? (snapshot.defaultCulture ?? draft.cultures[0]) : null) ||
435
+ null
436
+ const published = await cache.byKey(key, culture)
437
+ const content = contentForNode(
438
+ draft,
439
+ published?.url ?? `/${draft.key}`,
440
+ culture,
441
+ snapshot.languages,
442
+ ).withValueContext(await cache.valueContext(culture))
443
+ return {
444
+ content,
445
+ contentTypeAlias: draft.contentTypeAlias,
446
+ published: Boolean(published),
447
+ culture,
448
+ }
449
+ }
450
+
451
+ const seo = createSeoHost({
452
+ db,
453
+ config,
454
+ cache,
455
+ isProtected: async (content) => (await members.decide(content, undefined)).status !== 'allowed',
456
+ draft: draftModel,
457
+ })
458
+ const seoPath = `${paths.pluginPath}/api/seo`
459
+
460
+ const notImplementedLog = logger('not-implemented')
461
+ const notImplemented = new NotImplementedLog((line, entry) => {
462
+ // The console line is the development aid; the file keeps it for the log viewer
463
+ console.warn(line)
464
+ notImplementedLog.warning('Operation {operationId} ({method} {path}) is not implemented', {
465
+ ...entry,
466
+ })
467
+ })
468
+ const previewPort = {
469
+ enter: () => [
470
+ serializeCookie({ name: PREVIEW_COOKIE, value: 'preview', secure: config.secureCookies }),
471
+ ],
472
+ exit: () => [expireCookie(PREVIEW_COOKIE, config.secureCookies)],
473
+ }
474
+ // The site's config may name a store; without one the environment decides,
475
+ // and that falls back to the media directory on this disk.
476
+ const mediaStore = config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir))
477
+ // Resolved once: constructing it twice would warn twice about a provider that
478
+ // is half-configured, and the adapters are cheap but not free of side effects.
479
+ const email = resolveEmailPort(config)
480
+ const emailStatus = emailAvailability(email)
481
+ // Whether an invitation or a reset link can reach anyone at all, which is what
482
+ // gates the options rather than `emailStatus.available`: development delivers
483
+ // to the console, and that is a working flow for the person using it.
484
+ const canSendUserLinks = Boolean(resolveUserLinkSender(config, email))
485
+ const mediaFiles = new MediaFileStore(mediaStore)
486
+ const images = new ImageProcessor(mediaStore)
487
+ const api = createManagementApiRouter({
488
+ deps: {
489
+ ...createDeps({
490
+ config,
491
+ paths,
492
+ extensions,
493
+ db,
494
+ cache,
495
+ schema,
496
+ mediaFiles,
497
+ email,
498
+ // This node looks again at once; the others are told to. The write has
499
+ // already landed wherever `componentsDir` points, so there is nothing to
500
+ // distribute — only the fact that it happened.
501
+ onViewsChange: async (alias) => {
502
+ // The snapshot first, and awaited: the hash announced is then the one
503
+ // this write produced rather than the generation it replaced, and the
504
+ // editor's next request renders what they just saved.
505
+ const hash = await snapshots.refresh()
506
+ void appendCacheInstruction(db, {
507
+ kind: 'components',
508
+ payload: hash ? { alias, hash } : { alias },
509
+ by: schema.nodeId,
510
+ }).catch((error: unknown) => {
511
+ renderLog.error('Could not announce the template {alias}: {error}', { alias, error })
512
+ })
513
+ },
514
+ }),
515
+ preview: previewPort,
516
+ },
517
+ authenticate,
518
+ nodes: createNodeLookup(db),
519
+ allowLocalLogin: config.allowLocalLogin,
520
+ onNotImplemented: config.development
521
+ ? (operation, request) => notImplemented.record(operation, request)
522
+ : undefined,
523
+ onServerEvent: (event) => {
524
+ events.broadcast('notify', [event])
525
+ // An open preview of the document reloads when it changes.
526
+ if (event.eventSource === 'Umbraco:CMS:Document' && event.eventType !== 'Deleted')
527
+ previewHub.broadcast('refreshed', [event.key])
528
+ },
529
+ })
530
+
531
+ // Fixed by the contract and the client's SDK, wherever the editor is mounted.
532
+ const apiPrefix = MANAGEMENT_API_PREFIX
533
+
534
+ const health = () => {
535
+ const paused = poller.paused
536
+ const readOnly = schema.compatibilityMode || poller.behind || paused !== undefined
537
+ // A `web` node behind the database still answers its readers correctly,
538
+ // as-of its own state, until a contract removes something it reads. Draining
539
+ // it any earlier would drain every renderer the moment an upgrade cuts over —
540
+ // before a single new node can boot — and readers would see the gap. A node
541
+ // that serves the editor drains as soon as it is behind, so editors land on
542
+ // a node that can save. A pause drains nothing: it is for editors to see.
543
+ const serving =
544
+ !schema.compatibilityMode && (config.role === 'web' ? !poller.readsUnsafe : !poller.behind)
545
+ return {
546
+ // A frozen views snapshot deliberately does not make this false. Draining
547
+ // every node because they are all serving a slightly old template would
548
+ // turn a stale view into an outage; the `views` block below is what says
549
+ // so, and restarting the node clears it.
550
+ ok: serving,
551
+ readOnly,
552
+ paused: paused ?? null,
553
+ version: schema.nodeState.version,
554
+ revision: schema.nodeState.revision,
555
+ nodeId: schema.nodeId,
556
+ role: config.role,
557
+ components: snapshots.status(),
558
+ }
559
+ }
560
+
561
+ const events = createServerEventHub()
562
+ const previewHub = createServerEventHub({ name: 'PreviewHub' })
563
+ const jobs = createBackgroundJobs({
564
+ db,
565
+ cache,
566
+ nodeState: schema.nodeState,
567
+ nodeId: schema.nodeId,
568
+ versionCleanup: config.versionCleanup,
569
+ mediaFiles,
570
+ // The workflow queue drains here rather than in the request: a submission is
571
+ // stored first, and an e-mail that cannot be sent yet must not cost it.
572
+ formWorkflows: createWorkflowRunner({
573
+ db,
574
+ forms,
575
+ email,
576
+ mediaFiles,
577
+ nodeState: schema.nodeState,
578
+ nodeId: schema.nodeId,
579
+ onContentChanged: () => cache.invalidate(),
580
+ }),
581
+ onDocumentChanged: (key) =>
582
+ events.broadcast('notify', [
583
+ { eventSource: 'Umbraco:CMS:Document', eventType: 'Updated', key },
584
+ ]),
585
+ })
586
+ // Scheduled publishing and version cleanup are writes on a timer. Running them
587
+ // on every node would have two processes publishing the same document in the
588
+ // same second, so only an owner starts them.
589
+ if (owns) jobs.start()
590
+ log.info('Crombolo {version} started on node {nodeId} as {role}', {
591
+ version: VERSION,
592
+ nodeId: schema.nodeId,
593
+ role: config.role,
594
+ })
595
+ // App_Plugins is gone (`docs/17-bundles.md`). Saying so beats a site whose
596
+ // extensions silently stopped loading after an upgrade; remove at 1.0.
597
+ warnAboutAppPlugins(config.siteDir, log)
598
+ noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
599
+ const hubPath = SERVER_EVENT_HUB_PATH
600
+ const previewHubPath = PREVIEW_HUB_PATH
601
+
602
+ // Imported only when the feature is configured, so an unconfigured site never
603
+ // loads the assistant, its provider or its tool definitions.
604
+ const assistantPath = `${paths.pluginPath}/api/assistant`
605
+ const assistant =
606
+ config.assistant && servesBackOffice
607
+ ? await import('./assistant.ts').then((module) =>
608
+ module.createAssistantHost({
609
+ config: config.assistant as NonNullable<typeof config.assistant>,
610
+ db,
611
+ schema: createSchemaProposals({ db, config, boot: schema }),
612
+ siteName: config.siteName,
613
+ version: VERSION,
614
+ dispatch: (inner) => api.dispatch(inner),
615
+ }),
616
+ )
617
+ : undefined
618
+ if (assistant) {
619
+ log.info('Assistant enabled with {provider}{mcp}', {
620
+ provider: config.assistant?.provider.name,
621
+ mcp: config.assistant?.mcp === true ? ', MCP endpoint served' : '',
622
+ })
623
+ // Checked once at boot so an expired SSO session is reported here rather than
624
+ // as a failed message later. Never awaited into the boot path: the assistant
625
+ // is optional and the site must come up either way.
626
+ void assistant.status(true).then((status) => {
627
+ if (status.ok) {
628
+ log.info('Assistant credentials resolved: {detail}{expires}', {
629
+ detail: status.detail,
630
+ expires: status.expires ? `, expiring ${status.expires}` : '',
631
+ })
632
+ } else {
633
+ log.warning('Assistant cannot reach its model: {detail} {remedy}', {
634
+ detail: status.detail,
635
+ remedy: status.remedy ?? '',
636
+ })
637
+ }
638
+ })
639
+ }
640
+
641
+ // Imported only when configured, like the assistant: an unconfigured site has no
642
+ // git route and no credentials anywhere near it.
643
+ const gitPath = `${paths.pluginPath}/api/git`
644
+ const git =
645
+ config.git && servesBackOffice
646
+ ? await import('./git-routes.ts').then((module) =>
647
+ module.createGitHost({
648
+ config: config.git as NonNullable<typeof config.git>,
649
+ schemaDir: config.schemaDir,
650
+ siteName: config.siteName,
651
+ }),
652
+ )
653
+ : undefined
654
+ if (git) {
655
+ log.info('Source control enabled: {provider} on {branch}', {
656
+ provider: config.git?.provider.description,
657
+ branch: config.git?.provider.branch,
658
+ })
659
+ }
660
+
661
+ async function fetch(request: Request): Promise<Response> {
662
+ const url = new URL(request.url)
663
+ const { pathname } = url
664
+
665
+ // Non-contract, unauthenticated: a load balancer drains a node that has fallen behind.
666
+ if (pathname === '/health') {
667
+ const status = health()
668
+ return Response.json(status, { status: status.ok ? 200 : 503 })
669
+ }
670
+
671
+ // Everything the editor talks to, answered only by a node that serves it. A
672
+ // `web` node falls straight through to the public site, so the backoffice,
673
+ // the Management API and the sign-in routes are not merely unauthorised
674
+ // there — they are not routes at all, and 404 like any other unknown path.
675
+ if (servesBackOffice) {
676
+ const response = await serveBackOffice(request, url)
677
+ if (response) return response
678
+ }
679
+
680
+ if (pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) {
681
+ const response = await members.handle(request, pathname)
682
+ if (response) return response
683
+ }
684
+
685
+ // The progressive-enhancement script. A site adds it to a layout; a form
686
+ // works without it, so nothing emits it automatically.
687
+ if (pathname === FORM_SCRIPT_PATH)
688
+ return new Response(FORM_SCRIPT, {
689
+ headers: {
690
+ 'content-type': 'text/javascript; charset=utf-8',
691
+ 'cache-control': config.development ? 'no-cache' : 'public, max-age=3600',
692
+ },
693
+ })
694
+
695
+ // A visitor posting a form. Before the renderer, which would otherwise try
696
+ // to resolve `/crombolo/forms/...` as a page.
697
+ if (pathname.startsWith(`${FORM_SUBMIT_PATH}/`))
698
+ return request.method === 'POST'
699
+ ? submitFormRoute(request, pathname)
700
+ : new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } })
701
+
702
+ // Not on an `api` node, which serves no public site to describe.
703
+ if ((pathname === '/sitemap.xml' || pathname === '/robots.txt') && config.role !== 'api')
704
+ return pathname === '/sitemap.xml' ? seo.sitemap(request, url.host) : seo.robots(request)
705
+
706
+ if (pathname.startsWith('/media/')) return serveMedia(pathname, url.searchParams)
707
+ if (pathname.startsWith('/css/')) return serveSiteFile(config.stylesheetsDir, pathname, '.css')
708
+ if (pathname.startsWith('/scripts/')) return serveSiteFile(config.scriptsDir, pathname, '.js')
709
+ return renderSite(request, pathname, url.host)
710
+ }
711
+
712
+ /**
713
+ * The backoffice, the Management API and everything that serves an editor.
714
+ *
715
+ * Ordering within this function is load-bearing — `authRoutes` intercepts the
716
+ * security paths before the generic API dispatch — but its position relative
717
+ * to the member routes is not: `/umbraco/members` collides with neither the
718
+ * management prefix, the SPA route list, nor the auth prefix.
719
+ */
720
+ async function serveBackOffice(request: Request, url: URL): Promise<Response | undefined> {
721
+ const { pathname } = url
722
+
723
+ // Non-contract, signed-in: the assistant's chat, review and MCP endpoints.
724
+ // Absent configuration there is no `assistant`, so these paths 404 like any
725
+ // other unknown route.
726
+ if (assistant && pathname.startsWith(`${assistantPath}/`)) {
727
+ const principal = await authenticate(request)
728
+ if (!principal) return new Response('Unauthorized', { status: 401 })
729
+ const response = await assistant.handle(
730
+ request,
731
+ pathname.slice(assistantPath.length),
732
+ principal,
733
+ )
734
+ if (response) return response
735
+ }
736
+
737
+ // Non-contract, Settings: what the repository has against what this site has.
738
+ // Settings and not merely signed in, because the diff is the schema's full
739
+ // text and a commit pushes it under this site's credentials — the same
740
+ // authority `authorization.ts` demands for changing a type in the first place.
741
+ // Non-contract: search and sharing settings (Settings) and a page's preview (any editor).
742
+ if (pathname.startsWith(`${seoPath}/`)) {
743
+ const principal = await authenticate(request)
744
+ if (!principal) return new Response('Unauthorized', { status: 401 })
745
+ const response = await seo.handle(request, pathname.slice(seoPath.length), principal)
746
+ if (response) return response
747
+ }
748
+
749
+ if (git && pathname.startsWith(`${gitPath}/`)) {
750
+ const principal = await authenticate(request)
751
+ if (!principal) return new Response('Unauthorized', { status: 401 })
752
+ if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
753
+ const response = await git.handle(request, pathname.slice(gitPath.length), principal)
754
+ if (response) return response
755
+ }
756
+
757
+ // Non-contract, signed-in: import the schema files into this database. A GET
758
+ // asks what would happen and changes nothing; a POST applies it. Never
759
+ // automatic — see `importSchema`.
760
+ if (pathname === `${paths.pluginPath}/api/schema-import`) {
761
+ const principal = await authenticate(request)
762
+ if (!principal) return new Response('Unauthorized', { status: 401 })
763
+ // Settings: this applies type changes to the database, which is what every
764
+ // `document-type` write requires, and what the assistant asks of a schema
765
+ // proposal before it writes one.
766
+ if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
767
+ if (request.method !== 'GET' && request.method !== 'POST') {
768
+ return new Response('Method Not Allowed', { status: 405 })
769
+ }
770
+ const dryRun = request.method === 'GET'
771
+ const result = await importSchema(db, config, schema, { dryRun })
772
+ if (!dryRun) {
773
+ log.info('{user} imported the schema: {action}, {classification}', {
774
+ user: principal.userName,
775
+ action: result.report?.action ?? 'nothing to do',
776
+ classification: result.classification,
777
+ })
778
+ }
779
+ return Response.json({
780
+ dryRun,
781
+ classification: result.classification,
782
+ materialised: result.materialised,
783
+ action: result.report?.action,
784
+ reason: result.report?.reason,
785
+ findings: result.findings,
786
+ state: { version: schema.nodeState.version, revision: schema.nodeState.revision },
787
+ })
788
+ }
789
+
790
+ // Non-contract, signed-in: the declarations the view editor type-checks a
791
+ // template against — `@crombolo/render`'s own sources and the schema's
792
+ // generated content types.
793
+ //
794
+ // Signed in and not Settings, although only Settings can open a template:
795
+ // this is the same vocabulary of type and property aliases that the content
796
+ // editor in front of every backoffice user already shows them, and the
797
+ // assistant's review pane loads a template for anyone who may use it.
798
+ if (pathname === `${paths.pluginPath}/api/editor-types`) {
799
+ const principal = await authenticate(request)
800
+ if (!principal) return new Response('Unauthorized', { status: 401 })
801
+ if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
802
+ const body = JSON.stringify({
803
+ libs: await editorTypeLibs({ schemaDir: config.schemaDir }),
804
+ jsxImportSource: siteJsxImportSource(config.siteDir),
805
+ })
806
+ // Revalidated on every open rather than kept: the generated types follow
807
+ // the schema, which a colleague may have changed since this tab was
808
+ // opened. The tag spares the megabyte of React and CSS types when nothing
809
+ // has.
810
+ const etag = `"${Bun.hash(body).toString(36)}"`
811
+ const headers = { 'cache-control': 'private, no-cache', etag }
812
+ if (request.headers.get('if-none-match') === etag)
813
+ return new Response(null, { status: 304, headers })
814
+ return new Response(body, {
815
+ headers: { ...headers, 'content-type': 'application/json' },
816
+ })
817
+ }
818
+
819
+ /**
820
+ * A bundle's own endpoints, below `api/bundle/<id>/`.
821
+ *
822
+ * Authentication happens here and authorisation happens in the registry
823
+ * against the section the bundle declared, so a bundle never sees an
824
+ * anonymous request and cannot widen its own access. `undefined` means no
825
+ * bundle claims the path, which falls through to the 404 below.
826
+ */
827
+ if (bundles && pathname.startsWith(bundles.basePath)) {
828
+ const principal = await authenticate(request)
829
+ if (!principal) return new Response('Unauthorized', { status: 401 })
830
+ const answered = await bundles.handle(request, url, principal)
831
+ if (answered) return answered
832
+ }
833
+
834
+ /**
835
+ * Non-contract: the Packages section's marketplace, installed list and
836
+ * install. There is no operation in the contract for any of it — Umbraco's
837
+ * marketplace is an iframe — so this is ours, and it answers to the
838
+ * Packages section exactly as the operations beside it would.
839
+ */
840
+ if (pathname.startsWith(`${paths.pluginPath}/api/bundles/`)) {
841
+ const principal = await authenticate(request)
842
+ if (!principal) return new Response('Unauthorized', { status: 401 })
843
+ if (!hasSection(principal, 'packages')) return new Response('Forbidden', { status: 403 })
844
+ const action = pathname.slice(`${paths.pluginPath}/api/bundles/`.length)
845
+
846
+ if (action === 'marketplace' && request.method === 'GET') {
847
+ return Response.json({
848
+ url: marketplace.url(),
849
+ keyword: config.packageKeyword,
850
+ items: await marketplace.search(url.searchParams.get('q') ?? undefined),
851
+ })
852
+ }
853
+
854
+ if (action === 'installed' && request.method === 'GET') {
855
+ return Response.json({ items: marketplace.installed() })
856
+ }
857
+
858
+ if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
859
+ const body = (await request.json().catch(() => ({}))) as {
860
+ name?: unknown
861
+ version?: unknown
862
+ }
863
+ const name = typeof body.name === 'string' ? body.name : ''
864
+ const version = typeof body.version === 'string' ? body.version : undefined
865
+ const outcome =
866
+ action === 'install'
867
+ ? await marketplace.install(name, version)
868
+ : await marketplace.uninstall(name)
869
+ log.info('{user} {action} the package {name}: {outcome}', {
870
+ user: principal.email,
871
+ action: action === 'install' ? 'installed' : 'removed',
872
+ name,
873
+ outcome: outcome.ok ? 'ok' : outcome.message,
874
+ })
875
+ return Response.json(outcome, { status: outcome.ok ? 200 : 400 })
876
+ }
877
+
878
+ return new Response('Not Found', { status: 404 })
879
+ }
880
+
881
+ /**
882
+ * Non-contract: whether this site can send e-mail, and what is unavailable
883
+ * because it cannot.
884
+ *
885
+ * Any signed-in user may ask, because the features it gates are spread
886
+ * across sections — invitations live in Users, the explanation belongs
887
+ * wherever someone finds the option missing. The provider's `description`
888
+ * is deliberately left out: for a custom adapter it is an internal URL.
889
+ */
890
+ if (pathname === `${paths.pluginPath}/api/email` && request.method === 'GET') {
891
+ if (!(await authenticate(request))) return new Response('Unauthorized', { status: 401 })
892
+ return Response.json({
893
+ available: emailStatus.available,
894
+ provider: emailStatus.provider,
895
+ reason: emailStatus.reason,
896
+ affects: emailStatus.affects,
897
+ canSendUserLinks,
898
+ })
899
+ }
900
+
901
+ /**
902
+ * Non-contract: forms and their entries. There is no form operation in the
903
+ * vendored contract — Umbraco Forms ships its own API and its own client,
904
+ * and neither is here — so this is ours (`docs/18-forms.md`).
905
+ *
906
+ * Definitions are read from `schema/` per request rather than cached: the
907
+ * files are the truth, the backoffice may have just rewritten one, and a
908
+ * picker that disagreed with what a render uses would be worse than slow.
909
+ */
910
+ if (pathname.startsWith(`${paths.pluginPath}/api/forms`)) {
911
+ const principal = await authenticate(request)
912
+ if (!principal) return new Response('Unauthorized', { status: 401 })
913
+ const action = pathname.slice(`${paths.pluginPath}/api/forms`.length).replace(/^\//, '')
914
+ // Through the registry the renderer uses, so the designer and a render
915
+ // cannot disagree; a save invalidates it rather than waiting out the memo.
916
+ const definitions = forms.all()
917
+
918
+ // A definition is structure, like a content type: anyone who may open the
919
+ // section may read it, and the picker needs it in the Content section too.
920
+ if (action === '' && request.method === 'GET')
921
+ return Response.json({ items: definitions.map(formSummary) })
922
+
923
+ const entries = new FormEntryRepository(db)
924
+ const byKey = formsByKey(definitions)
925
+ // Submissions are personal data, so reading them asks for more than a
926
+ // session. Four verbs, because designing a form and reading what people
927
+ // sent it are different jobs (`docs/18-forms.md`).
928
+ const may = (verb: string) => principal.isAdmin || principal.permissions.includes(verb)
929
+ const formFiles = {
930
+ schemaDir: config.schemaDir,
931
+ writable: config.schemaWritable,
932
+ onChanged: config.schemaStore
933
+ ? async () => {
934
+ await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
935
+ }
936
+ : undefined,
937
+ }
938
+ const maySeeEntries = may(FormPermissions.EntriesView)
939
+ const mayManageEntries = may(FormPermissions.EntriesManage)
940
+ // The group alias still counts: `sensitiveData` is how Umbraco carries
941
+ // this and how members already work, so both routes to it are honoured.
942
+ const maySeeSensitive =
943
+ may(FormPermissions.EntriesSensitive) || hasAccessToSensitiveData(principal.groups)
944
+
945
+ const entryMatch = /^entries\/([^/]+)(?:\/(state))?$/.exec(action)
946
+ if (entryMatch) {
947
+ if (!maySeeEntries) return new Response('Forbidden', { status: 403 })
948
+ const id = decodeURIComponent(entryMatch[1] as string)
949
+ if (entryMatch[2] === 'state' && request.method === 'POST') {
950
+ if (!mayManageEntries) return new Response('Forbidden', { status: 403 })
951
+ const body = (await request.json().catch(() => ({}))) as { state?: unknown }
952
+ const next = parseEntryState(typeof body.state === 'string' ? body.state : null)
953
+ if (!next) return new Response('Bad Request', { status: 400 })
954
+ const updated = await entries.setState(id, next)
955
+ if (!updated) return new Response('Not Found', { status: 404 })
956
+ // Approval is the other trigger: a workflow declared `on = "approve"`
957
+ // runs now, from the values the entry holds.
958
+ if (next === 'approved') {
959
+ const form = byKey.get(updated.formKey)
960
+ const onApprove = form?.workflows.filter((workflow) => workflow.on === 'approve') ?? []
961
+ if (onApprove.length > 0)
962
+ await new FormWorkflowRepository(db).enqueue(
963
+ onApprove.map((workflow) => ({
964
+ formKey: updated.formKey,
965
+ formAlias: updated.formAlias,
966
+ workflowName: workflow.name,
967
+ workflowType: workflow.type,
968
+ runOn: workflow.on,
969
+ entryId: updated.id,
970
+ payload: updated.values.map((value) => ({
971
+ fieldAlias: value.fieldAlias,
972
+ values: value.values,
973
+ })),
974
+ })),
975
+ )
976
+ }
977
+ log.info('{user} set form entry {id} to {state}', {
978
+ user: principal.email,
979
+ id,
980
+ state: next,
981
+ })
982
+ return Response.json({ id: updated.id, state: updated.state })
983
+ }
984
+ if (request.method === 'DELETE') {
985
+ if (!mayManageEntries) return new Response('Forbidden', { status: 403 })
986
+ if (!(await entries.remove(id))) return new Response('Not Found', { status: 404 })
987
+ log.info('{user} deleted form entry {id}', { user: principal.email, id })
988
+ return new Response(null, { status: 200 })
989
+ }
990
+ if (request.method === 'GET') {
991
+ const entry = await entries.byId(id)
992
+ if (!entry) return new Response('Not Found', { status: 404 })
993
+ return Response.json({
994
+ ...entry,
995
+ values: redactSensitive(entry, byKey.get(entry.formKey), maySeeSensitive),
996
+ })
997
+ }
998
+ return new Response('Method Not Allowed', { status: 405 })
999
+ }
1000
+
1001
+ const csvMatch = /^([^/]+)\/entries\.csv$/.exec(action)
1002
+ if (csvMatch && request.method === 'GET') {
1003
+ if (!maySeeEntries) return new Response('Forbidden', { status: 403 })
1004
+ const key = decodeURIComponent(csvMatch[1] as string)
1005
+ const form = byKey.get(key)
1006
+ if (!form) return new Response('Not Found', { status: 404 })
1007
+ const page = await entries.list({ formKey: key, take: 10_000 })
1008
+ const result = entriesCsv(form, page.items, { includeSensitive: maySeeSensitive })
1009
+ if (!result.ok)
1010
+ return new Response(result.reason, {
1011
+ status: 403,
1012
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1013
+ })
1014
+ return new Response(result.csv, {
1015
+ headers: {
1016
+ 'content-type': 'text/csv; charset=utf-8',
1017
+ 'content-disposition': `attachment; filename="${result.fileName}"`,
1018
+ },
1019
+ })
1020
+ }
1021
+
1022
+ const listMatch = /^([^/]+)\/entries$/.exec(action)
1023
+ if (listMatch && request.method === 'GET') {
1024
+ if (!maySeeEntries) return new Response('Forbidden', { status: 403 })
1025
+ const key = decodeURIComponent(listMatch[1] as string)
1026
+ const since = url.searchParams.get('from')
1027
+ const until = url.searchParams.get('to')
1028
+ const page = await entries.list({
1029
+ formKey: key,
1030
+ state: parseEntryState(url.searchParams.get('state')),
1031
+ from: since ? new Date(since) : undefined,
1032
+ to: until ? new Date(until) : undefined,
1033
+ search: url.searchParams.get('search') ?? undefined,
1034
+ includeSpam: url.searchParams.get('includeSpam') === 'true',
1035
+ skip: Number(url.searchParams.get('skip') ?? 0) || 0,
1036
+ take: Number(url.searchParams.get('take') ?? 100) || 100,
1037
+ })
1038
+ const form = byKey.get(key)
1039
+ return Response.json({
1040
+ total: page.total,
1041
+ items: page.items.map((entry) => ({
1042
+ ...entry,
1043
+ values: redactSensitive(entry, form, maySeeSensitive),
1044
+ })),
1045
+ })
1046
+ }
1047
+
1048
+ if (action && request.method === 'GET') {
1049
+ const form = byKey.get(decodeURIComponent(action))
1050
+ if (!form) return new Response('Not Found', { status: 404 })
1051
+ // The whole definition, which is what the designer edits.
1052
+ return Response.json({ ...formDetail(form), definition: form })
1053
+ }
1054
+
1055
+ // Designing a form is a developer's job, so it is its own verb rather
1056
+ // than something an entry reader can do.
1057
+ if (request.method === 'POST' || request.method === 'PUT') {
1058
+ if (!may(FormPermissions.Manage)) return new Response('Forbidden', { status: 403 })
1059
+ const body = await request.json().catch(() => ({}))
1060
+ const written = await saveForm(coerceForm(body), formFiles)
1061
+ if (!written.ok) {
1062
+ log.warning('{user} could not save a form: {detail}', {
1063
+ user: principal.email,
1064
+ detail: written.problems.map((problem) => problem.message).join('; '),
1065
+ })
1066
+ return Response.json(
1067
+ { status: written.status, problems: written.problems },
1068
+ { status: written.status === 'readOnly' ? 409 : 400 },
1069
+ )
1070
+ }
1071
+ // The memo would otherwise hold the old definition for a second, which
1072
+ // is exactly long enough for the designer to reload and see it.
1073
+ forms.invalidate()
1074
+ log.info('{user} saved the form {form}', {
1075
+ user: principal.email,
1076
+ form: coerceForm(body).alias,
1077
+ })
1078
+ return Response.json({ ok: true })
1079
+ }
1080
+
1081
+ if (action && request.method === 'DELETE') {
1082
+ if (!may(FormPermissions.Manage)) return new Response('Forbidden', { status: 403 })
1083
+ const form = byKey.get(decodeURIComponent(action))
1084
+ if (!form) return new Response('Not Found', { status: 404 })
1085
+ const removed = await deleteForm(form.alias, formFiles)
1086
+ if (!removed.ok)
1087
+ return Response.json(
1088
+ { status: removed.status, problems: removed.problems },
1089
+ { status: removed.status === 'readOnly' ? 409 : 404 },
1090
+ )
1091
+ forms.invalidate()
1092
+ log.info('{user} deleted the form {form}; its entries are kept', {
1093
+ user: principal.email,
1094
+ form: form.alias,
1095
+ })
1096
+ return Response.json({ ok: true })
1097
+ }
1098
+ return new Response('Not Found', { status: 404 })
1099
+ }
1100
+
1101
+ // Non-contract: what the Changes dashboard and the read-only banner read.
1102
+ //
1103
+ // Two audiences, so two answers. `health` says this node has fallen behind
1104
+ // and writes will be refused, which is the banner in the header — every
1105
+ // editor needs to see that, and gating it would leave them with failing saves
1106
+ // and no explanation. The findings name documents and the properties that are
1107
+ // unfilled: that is the Settings dashboard's material, and the dashboard's own
1108
+ // manifest is already `Umb.Condition.SectionAlias` on Settings.
1109
+ if (pathname === `${paths.pluginPath}/api/change-report`) {
1110
+ const principal = await authenticate(request)
1111
+ if (!principal) return new Response('Unauthorized', { status: 401 })
1112
+ if (!hasSection(principal, 'settings')) return Response.json({ health: health() })
1113
+ const findings = await readReport(db, { includeResolved: true })
1114
+ const counts: Record<string, number> = {}
1115
+ // Per source as well as overall, so the dashboard can separate a pending
1116
+ // upgrade from a content bundle waiting on a decision.
1117
+ const bySource: Record<string, Record<string, number>> = {}
1118
+ for (const f of findings) {
1119
+ if (f.status !== 'open') continue
1120
+ counts[f.kind] = (counts[f.kind] ?? 0) + 1
1121
+ const source = bySource[f.source] ?? {}
1122
+ source[f.kind] = (source[f.kind] ?? 0) + 1
1123
+ bySource[f.source] = source
1124
+ }
1125
+ return Response.json({ health: health(), counts, bySource, findings })
1126
+ }
1127
+
1128
+ const staticMatch = resolveStaticFile(paths, pathname, {
1129
+ extensions: extensions.list(),
1130
+ immutable: config.immutableAssets ?? !config.development,
1131
+ })
1132
+ if (staticMatch) {
1133
+ return (
1134
+ (await serveStaticFile(staticMatch, request)) ?? new Response('Not Found', { status: 404 })
1135
+ )
1136
+ }
1137
+
1138
+ const loginAsset = resolveLoginAsset(paths.backOfficePath, pathname)
1139
+ if (loginAsset) {
1140
+ const response = await serveStaticFile(
1141
+ { file: loginAsset, cacheControl: 'no-cache' },
1142
+ request,
1143
+ )
1144
+ if (response) return response
1145
+ }
1146
+
1147
+ const graphic = resolveGraphic(paths, pathname)
1148
+ if (graphic) {
1149
+ const response = await serveStaticFile({ file: graphic, cacheControl: 'no-cache' }, request)
1150
+ if (response) return response
1151
+ }
1152
+
1153
+ const authResponse = await authRoutes.handle(request, pathname)
1154
+ if (authResponse) return authResponse
1155
+
1156
+ if (pathname.startsWith(apiPrefix)) {
1157
+ if (pathname === `${apiPrefix}openapi.json`) {
1158
+ return new Response(Bun.file(CONTRACT_PATH), {
1159
+ headers: { 'content-type': 'application/json' },
1160
+ })
1161
+ }
1162
+ return api.dispatch(request)
1163
+ }
1164
+
1165
+ if (pathname === `${paths.backOfficePath}/login`) {
1166
+ const { importmap } = collectManifests(paths, extensions.list())
1167
+ return html(
1168
+ renderLoginShell(paths, importmap, {
1169
+ defaultUiLanguage: config.defaultUiLanguage,
1170
+ keepUserLoggedIn: config.keepUserLoggedIn,
1171
+ title: config.siteName,
1172
+ usernameIsEmail: true,
1173
+ allowUserInvite: false,
1174
+ // Offering "forgot password" with no way to send the link is a dead
1175
+ // end that looks like a bug; without a sender the option is not there.
1176
+ allowPasswordReset: config.allowPasswordReset && canSendUserLinks,
1177
+ disableLocalLogin: !config.allowLocalLogin,
1178
+ // Set by the authorize endpoint so sign-in resumes the OAuth flow. Only
1179
+ // a path on this site: the login page hands this to the browser once
1180
+ // the session exists, so anything else is an open redirect at the worst
1181
+ // possible moment, and `javascript:` there would run signed in.
1182
+ returnUrl: localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
1183
+ }),
1184
+ )
1185
+ }
1186
+
1187
+ if (isSpaRoute(paths.backOfficePath, pathname)) {
1188
+ const { importmap } = collectManifests(paths, extensions.list())
1189
+ return html(
1190
+ renderBackOfficeShell(paths, importmap, {
1191
+ defaultUiLanguage: config.defaultUiLanguage,
1192
+ keepUserLoggedIn: config.keepUserLoggedIn,
1193
+ title: config.siteName,
1194
+ }),
1195
+ )
1196
+ }
1197
+
1198
+ return undefined
1199
+ }
1200
+
1201
+ /** A stylesheet or script the Settings section edits, from its folder; nothing else from there. */
1202
+ async function serveSiteFile(root: string, pathname: string, extension: string) {
1203
+ const segments = decodeURIComponent(pathname).split('/').slice(2)
1204
+ const safe =
1205
+ segments.length > 0 &&
1206
+ segments.every((s) => s !== '' && s !== '.' && s !== '..' && !s.includes('\\')) &&
1207
+ segments.at(-1)?.toLowerCase().endsWith(extension)
1208
+ const file = safe ? Bun.file(join(root, ...segments)) : undefined
1209
+ if (!file || !(await file.exists())) return new Response('Not Found', { status: 404 })
1210
+ const headers = { 'cache-control': 'no-cache', 'content-type': file.type }
1211
+ // Bun.serve answers an empty file body 204, and an empty stylesheet is still one
1212
+ return new Response(file.size === 0 ? '' : file, { status: 200, headers })
1213
+ }
1214
+
1215
+ /**
1216
+ * An uploaded file from the media store; an image with a resize or crop query
1217
+ * is served as that variant, computed once and cached in the store.
1218
+ *
1219
+ * A store that serves its own bytes (a CDN, a presigned bucket URL) gets the
1220
+ * browser sent straight there. Otherwise this streams them — from the local
1221
+ * file when there is one, so a disk-backed site still gets sendfile.
1222
+ */
1223
+ async function serveMedia(pathname: string, search: URLSearchParams): Promise<Response> {
1224
+ // Media is what someone else uploaded, served from this origin, so a browser
1225
+ // must not guess a type the store did not give.
1226
+ const cacheable = {
1227
+ 'cache-control': 'public, max-age=31536000, immutable',
1228
+ 'x-content-type-options': 'nosniff',
1229
+ }
1230
+ /**
1231
+ * `sandbox` for the types that carry script, which Umbraco's upload rules
1232
+ * allow into the library as ours do. Only those: opening a PDF inline is
1233
+ * something a media library is expected to do, and sandboxing every response
1234
+ * would take that away to no purpose. An `<img src>` is unaffected either way
1235
+ * — what this stops is opening an SVG or an HTML file as a page.
1236
+ */
1237
+ const protective = (contentType: string) =>
1238
+ /^(?:image\/svg\+xml|text\/html|application\/xhtml\+xml|(?:text|application)\/xml)\b/.test(
1239
+ contentType,
1240
+ )
1241
+ ? { ...cacheable, 'content-security-policy': 'sandbox' }
1242
+ : cacheable
1243
+ // A prefix may be a path on this host as well as an absolute URL, and
1244
+ // Response.redirect insists on the latter.
1245
+ const sendTo = (location: string) =>
1246
+ new Response(null, { status: 302, headers: { location, ...cacheable } })
1247
+ const key = mediaFiles.key(pathname)
1248
+ if (!key) return new Response('Not Found', { status: 404 })
1249
+ const request = parseImagingQuery(search)
1250
+ const extension = key.split('.').pop()?.toLowerCase() ?? ''
1251
+
1252
+ if (request && DEFAULT_UPLOAD_SETTINGS.imageFileTypes.includes(extension)) {
1253
+ const source = await mediaStore.get(key)
1254
+ if (source) {
1255
+ try {
1256
+ const variant = await images.variant(source, request)
1257
+ const elsewhere = mediaStore.publicUrl?.(variant.key)
1258
+ if (elsewhere) return sendTo(elsewhere)
1259
+ return new Response(variant.bytes as Uint8Array<ArrayBuffer>, {
1260
+ headers: { ...cacheable, 'content-type': variant.contentType },
1261
+ })
1262
+ } catch (error) {
1263
+ log.error('Could not process image {path}', { path: pathname, error })
1264
+ }
1265
+ }
1266
+ }
1267
+
1268
+ const elsewhere = mediaStore.publicUrl?.(key)
1269
+ if (elsewhere) return sendTo(elsewhere)
1270
+ const stored = await mediaStore.get(key)
1271
+ if (!stored) return new Response('Not Found', { status: 404 })
1272
+ const headers = { ...protective(stored.contentType), 'content-type': stored.contentType }
1273
+ if (stored.localPath) return new Response(Bun.file(stored.localPath), { headers })
1274
+ return new Response((await stored.bytes()) as Uint8Array<ArrayBuffer>, { headers })
1275
+ }
1276
+
1277
+ /**
1278
+ * Preview: a signed-in editor with the preview cookie sees drafts. The
1279
+ * backoffice's preview app frames `/<document key>`; a routed URL renders the
1280
+ * draft of the page it resolves to.
1281
+ */
1282
+ async function renderPreview(request: Request, pathname: string, host: string) {
1283
+ if (!readCookie(request, PREVIEW_COOKIE) || !(await authenticate(request))) return undefined
1284
+ const byKey = /^\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i.exec(
1285
+ pathname,
1286
+ )
1287
+ const key = byKey?.[1] ?? (await cache.resolve(host, pathname))?.content.key
1288
+ if (!key) return undefined
1289
+ const draft = await draftModel(key, new URL(request.url).searchParams.get('culture'))
1290
+ if (!draft) return undefined
1291
+ return renderer.renderModel(draft.content, draft.culture, await pageRequest(request))
1292
+ }
1293
+
1294
+ /** What every rendered page is told about the request and the site's search defaults. */
1295
+ async function pageRequest(request: Request) {
1296
+ return {
1297
+ origin: originOf(request, config),
1298
+ url: request.url,
1299
+ seo: await seo.siteDefaults(),
1300
+ signal: request.signal,
1301
+ }
1302
+ }
1303
+
1304
+ /**
1305
+ * The front end: resolve the route against the published cache and render.
1306
+ *
1307
+ * Public access is enforced here, not in the renderer: a protected page is
1308
+ * *rewritten* to its login or error page, as Umbraco does, so the visitor
1309
+ * keeps the URL they asked for. Preview is exempt — only a signed-in editor
1310
+ * reaches it.
1311
+ */
1312
+ /**
1313
+ * A form submission from a visitor. Not the Management API: the caller is the
1314
+ * public, and there is no operation in the vendored contract for it.
1315
+ *
1316
+ * Answers JSON to a `fetch` caller and a 303 to a browser, so a form works
1317
+ * with no JavaScript — `form-submit.ts` says why the state goes in a cookie
1318
+ * rather than a body.
1319
+ */
1320
+ async function submitFormRoute(request: Request, pathname: string): Promise<Response> {
1321
+ const key = decodeURIComponent(pathname.slice(`${FORM_SUBMIT_PATH}/`.length))
1322
+ // Where to send a browser back to. The form was rendered on a page, and the
1323
+ // referer is the only thing that names it without the view passing it down.
1324
+ const back = (() => {
1325
+ const referer = request.headers.get('referer')
1326
+ if (!referer) return '/'
1327
+ try {
1328
+ const url = new URL(referer)
1329
+ return `${url.pathname}${url.search}`
1330
+ } catch {
1331
+ return '/'
1332
+ }
1333
+ })()
1334
+
1335
+ const outcome = await submitForm(
1336
+ {
1337
+ db,
1338
+ forms,
1339
+ mediaFiles,
1340
+ uploads: DEFAULT_UPLOAD_SETTINGS,
1341
+ urlOf: async (contentKey) => (await cache.byKey(contentKey))?.url,
1342
+ },
1343
+ request,
1344
+ key,
1345
+ { ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() },
1346
+ )
1347
+
1348
+ const json = wantsJson(request)
1349
+ const flash = (state: Parameters<typeof writeFlash>[1]) =>
1350
+ serializeCookie({
1351
+ name: FORM_FLASH_COOKIE,
1352
+ value: writeFlash(formSecret, state),
1353
+ secure: config.secureCookies,
1354
+ maxAgeSeconds: 60,
1355
+ })
1356
+ const plain = (body: string, status: number) =>
1357
+ new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } })
1358
+
1359
+ switch (outcome.status) {
1360
+ case 'notFound':
1361
+ return json
1362
+ ? Response.json({ ok: false, error: 'No such form.' }, { status: 404 })
1363
+ : plain('Not Found', 404)
1364
+ case 'badToken': {
1365
+ // Stale or forged. Somebody who left the page open overnight lands here,
1366
+ // so it says what to do rather than only refusing.
1367
+ const message = 'This form has expired. Reload the page and try again.'
1368
+ return json
1369
+ ? Response.json({ ok: false, error: message }, { status: 400 })
1370
+ : plain(message, 400)
1371
+ }
1372
+ case 'full': {
1373
+ const message = 'This form is no longer accepting entries.'
1374
+ return json
1375
+ ? Response.json({ ok: false, error: message }, { status: 409 })
1376
+ : plain(message, 409)
1377
+ }
1378
+ case 'invalid':
1379
+ return json
1380
+ ? Response.json({ ok: false, errors: outcome.state.errors }, { status: 422 })
1381
+ : new Response(null, {
1382
+ status: 303,
1383
+ headers: { location: back, 'set-cookie': flash(outcome.state) },
1384
+ })
1385
+ case 'ok':
1386
+ return json
1387
+ ? Response.json({ ok: true, message: outcome.state.message, entryId: outcome.entryId })
1388
+ : new Response(null, {
1389
+ status: 303,
1390
+ headers: {
1391
+ location: outcome.redirectTo ?? back,
1392
+ 'set-cookie': flash(outcome.state),
1393
+ },
1394
+ })
1395
+ }
1396
+ }
1397
+
1398
+ async function renderSite(request: Request, pathname: string, host: string): Promise<Response> {
1399
+ const preview = await renderPreview(request, pathname, host)
1400
+ // An `api` node renders for preview and nothing else. The backoffice opens a
1401
+ // site URL to preview a draft, so the renderer has to be here; serving the
1402
+ // published site as well would mean a second public copy of it on whatever
1403
+ // address the editing box answers.
1404
+ if (!preview && config.role === 'api')
1405
+ return new Response('Not Found', {
1406
+ status: 404,
1407
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1408
+ })
1409
+ const member = preview ? undefined : await members.resolve(request)
1410
+ // What a submission redirected back with, if anything. Read once and
1411
+ // cleared, so a reload shows the page rather than the thank-you again.
1412
+ const cookie = readCookie(request, FORM_FLASH_COOKIE)
1413
+ const submission = cookie ? readFlash(formSecret, cookie) : undefined
1414
+ const result =
1415
+ preview ??
1416
+ (await renderer.render(pathname, host, {
1417
+ member,
1418
+ submission,
1419
+ access: (content) => members.decide(content, member),
1420
+ ...(await pageRequest(request)),
1421
+ }))
1422
+ const cleared: Record<string, string> = cookie
1423
+ ? { 'set-cookie': expireCookie(FORM_FLASH_COOKIE, config.secureCookies) }
1424
+ : {}
1425
+ switch (result.status) {
1426
+ case 'ok':
1427
+ return new Response(result.html, {
1428
+ headers: { 'content-type': 'text/html; charset=utf-8', ...cleared },
1429
+ })
1430
+ case 'redirect':
1431
+ // Umbraco's `umbracoRedirect` answers 302: the page may stop redirecting.
1432
+ return new Response(null, {
1433
+ status: 302,
1434
+ headers: { location: withQuery(result.location, request.url), ...cleared },
1435
+ })
1436
+ case 'noTemplate':
1437
+ // Content exists but renders nothing, which Umbraco also treats as a 404.
1438
+ return new Response(`No template is assigned to '${result.content.name}'.`, {
1439
+ status: 404,
1440
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1441
+ })
1442
+ case 'error': {
1443
+ // The visitor left and the render was stopped for it: nobody reads this
1444
+ // response, and it says nothing about the template.
1445
+ if (request.signal.aborted) return new Response(null, { status: 499, headers: cleared })
1446
+ // Through `inSource`, so the file named is the one somebody can open
1447
+ // rather than the snapshot generation it was imported from.
1448
+ const detail = renderer.inSource(result.error.stack ?? result.error.message)
1449
+ renderLog.error('Template error while rendering {name} ({key}): {detail}', {
1450
+ name: result.content.name,
1451
+ key: result.content.key,
1452
+ detail,
1453
+ })
1454
+ return new Response(
1455
+ config.development
1456
+ ? `Template error while rendering '${result.content.name}':\n\n${detail}`
1457
+ : 'Server Error',
1458
+ { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } },
1459
+ )
1460
+ }
1461
+ default: {
1462
+ // Only once no page answered, so a redirect can never hide live content.
1463
+ const redirect = await cache.redirect(host, pathname)
1464
+ if (redirect)
1465
+ return new Response(null, {
1466
+ status: redirect.statusCode,
1467
+ headers: {
1468
+ location: withQuery(redirect.location, request.url),
1469
+ // Umbraco sends a 301 uncacheable on purpose: browsers cache them
1470
+ // hard, so a page renamed and then renamed back would keep
1471
+ // redirecting for visitors who saw the first answer.
1472
+ 'cache-control': 'no-store, must-revalidate',
1473
+ pragma: 'no-cache',
1474
+ expires: '0',
1475
+ },
1476
+ })
1477
+ // A site with nothing published is not a 404 anybody can act on, so it
1478
+ // explains itself instead. Still a 404: see `no-nodes.ts`.
1479
+ const snapshot = await cache.snapshot()
1480
+ if ([...snapshot.views.values()].every((view) => view.roots.length === 0))
1481
+ return new Response(
1482
+ noNodesPage({ siteName: config.siteName, backOfficePath: config.backOfficePath }),
1483
+ { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } },
1484
+ )
1485
+ const page = await renderNotFoundPage(request, pathname, host, member)
1486
+ if (page) return page
1487
+ return new Response('Not Found', {
1488
+ status: 404,
1489
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1490
+ })
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ /**
1496
+ * The page chosen to answer an address nothing else does, with status 404. A
1497
+ * page that fails to render leaves the plain answer rather than a second error.
1498
+ */
1499
+ async function renderNotFoundPage(
1500
+ request: Request,
1501
+ pathname: string,
1502
+ host: string,
1503
+ member: Awaited<ReturnType<typeof members.resolve>>,
1504
+ ): Promise<Response | undefined> {
1505
+ const found = await seo.notFound(host, pathname)
1506
+ if (!found) return undefined
1507
+ const result = await renderer.renderModel(found.content, found.culture, {
1508
+ member,
1509
+ ...(await pageRequest(request)),
1510
+ })
1511
+ if (result.status !== 'ok') {
1512
+ if (result.status === 'error' && !request.signal.aborted)
1513
+ renderLog.error('Template error while rendering the not-found page {name}: {detail}', {
1514
+ name: found.content.name,
1515
+ detail: renderer.inSource(result.error.stack ?? result.error.message),
1516
+ })
1517
+ return undefined
1518
+ }
1519
+ return new Response(result.html, {
1520
+ status: 404,
1521
+ headers: { 'content-type': 'text/html; charset=utf-8' },
1522
+ })
1523
+ }
1524
+
1525
+ return {
1526
+ config,
1527
+ fetch,
1528
+ paths,
1529
+ db,
1530
+ auth,
1531
+ cache,
1532
+ renderer,
1533
+ snapshots,
1534
+ seededAdminPassword,
1535
+ schema,
1536
+ poll: () => poller.poll(),
1537
+ notImplemented,
1538
+ health,
1539
+ events,
1540
+ jobs,
1541
+ serveOptions: {
1542
+ port: config.port,
1543
+ maxRequestBodySize: config.maxRequestBodyBytes,
1544
+ fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
1545
+ const pathname = new URL(request.url).pathname
1546
+ if (pathname === hubPath || pathname === previewHubPath) {
1547
+ const principal = await authenticate(request)
1548
+ const hub = pathname === hubPath ? events : previewHub
1549
+ return hub.upgrade(request, bunServer, principal?.id)
1550
+ }
1551
+ return fetch(request)
1552
+ }),
1553
+ websocket: combineHubs([events, previewHub]),
1554
+ },
1555
+ async close() {
1556
+ diagnostics.stop()
1557
+ jobs.stop()
1558
+ events.stop()
1559
+ previewHub.stop()
1560
+ watcher?.close()
1561
+ poller.stop()
1562
+ await db.close()
1563
+ },
1564
+ }
1565
+ }
1566
+
1567
+ function html(body: string): Response {
1568
+ return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } })
1569
+ }
1570
+
1571
+ /**
1572
+ * Carries the request's query string onto a redirect target, as Umbraco does, so
1573
+ * campaign and tracking parameters survive a rename. A target that names its own
1574
+ * query wins.
1575
+ */
1576
+ function withQuery(location: string, requestUrl: string): string {
1577
+ const query = new URL(requestUrl).search
1578
+ return query && !location.includes('?') ? `${location}${query}` : location
1579
+ }