@crombolo/server 0.0.0-stage → 0.10.0

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