@crombolo/server 0.10.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +12 -11
  2. package/src/adapters/audit.ts +186 -0
  3. package/src/adapters/content.ts +2 -0
  4. package/src/adapters/documents.ts +28 -25
  5. package/src/adapters/elements.ts +3 -0
  6. package/src/adapters/health.ts +333 -0
  7. package/src/adapters/media.ts +3 -0
  8. package/src/adapters/members.ts +10 -2
  9. package/src/adapters/references.ts +13 -0
  10. package/src/adapters/relations.ts +48 -0
  11. package/src/adapters/schema-files.ts +41 -11
  12. package/src/adapters/schema-proposals.ts +13 -6
  13. package/src/adapters/search-index.ts +31 -0
  14. package/src/adapters/webhooks.ts +76 -0
  15. package/src/boot-failure.ts +147 -0
  16. package/src/bundle-install.ts +148 -13
  17. package/src/bundles.ts +14 -0
  18. package/src/busy.ts +13 -0
  19. package/src/config.ts +46 -2
  20. package/src/database.ts +9 -7
  21. package/src/delivery-members.ts +387 -0
  22. package/src/editor-types.ts +7 -2
  23. package/src/extensions-notice.ts +3 -2
  24. package/src/form-files.ts +38 -3
  25. package/src/form-submit.ts +62 -5
  26. package/src/form-types.ts +187 -0
  27. package/src/form-workflows.ts +66 -6
  28. package/src/forms.ts +15 -7
  29. package/src/index.ts +12 -0
  30. package/src/jobs.ts +91 -10
  31. package/src/media-files.ts +3 -1
  32. package/src/member-auth.ts +17 -1
  33. package/src/models-builder.ts +12 -8
  34. package/src/ports.ts +57 -2
  35. package/src/schema.ts +58 -11
  36. package/src/secrets.ts +36 -0
  37. package/src/server.ts +388 -29
  38. package/src/setup.ts +288 -0
  39. package/src/status.ts +5 -2
  40. package/src/transfer-dashboard.ts +219 -0
  41. package/src/upgrade-summary.ts +43 -0
  42. package/src/url-settings.ts +91 -0
  43. package/src/webhook-files.ts +138 -0
  44. package/src/webhooks.ts +544 -0
package/src/server.ts CHANGED
@@ -6,8 +6,15 @@
6
6
  * API, the SPA shells, then the front-end site.
7
7
  */
8
8
 
9
+ import { existsSync } from 'node:fs'
9
10
  import { join } from 'node:path'
10
- import { createManagementApiRouter, hasSection, type Principal } from '@crombolo/api-management'
11
+ import { createDeliveryApi, resolveDeliveryApiSettings } from '@crombolo/api-delivery'
12
+ import {
13
+ type ClientAddress,
14
+ createManagementApiRouter,
15
+ hasSection,
16
+ type Principal,
17
+ } from '@crombolo/api-management'
11
18
  import {
12
19
  AuthService,
13
20
  AuthStore,
@@ -31,7 +38,10 @@ import {
31
38
  serveStaticFile,
32
39
  } from '@crombolo/backoffice-host'
33
40
  import {
41
+ CHOICE_SOURCE_KINDS,
42
+ type ChoiceSourceKind,
34
43
  DEFAULT_UPLOAD_SETTINGS,
44
+ DELIVERY_API_PREFIX,
35
45
  FormPermissions,
36
46
  hasAccessToSensitiveData,
37
47
  localReturnUrl,
@@ -45,8 +55,11 @@ import {
45
55
  type Db,
46
56
  FormEntryRepository,
47
57
  FormWorkflowRepository,
58
+ PublicAccessRepository,
48
59
  RedirectRepository,
60
+ readLedger,
49
61
  readReport,
62
+ readUrlOptions,
50
63
  TransferRunRepository,
51
64
  type WritesPaused,
52
65
  } from '@crombolo/data'
@@ -59,20 +72,25 @@ import {
59
72
  PublishedCache,
60
73
  Renderer,
61
74
  type SnapshotStatus,
75
+ setFormFieldTypes,
62
76
  setFormTokenSigner,
63
77
  ViewSnapshots,
64
78
  } from '@crombolo/render'
65
79
  import type { SyncReport } from '@crombolo/schema'
80
+ import { choicesFor } from '@crombolo/transfer'
66
81
  import type { Server } from 'bun'
67
82
  import { createNodeLookup, loadAccess } from './access.ts'
83
+ import { createAuditHost, createAuditSink, pluginAuditPlan, recordAudit } from './adapters/audit.ts'
68
84
  import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
69
85
  import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
70
86
  import { createSchemaProposals } from './adapters/schema-proposals.ts'
71
- import { installBundleContent } from './bundle-install.ts'
87
+ import { CommandError, installBundleContent } from './bundle-install.ts'
72
88
  import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
73
89
  import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
90
+ import { busyResponse } from './busy.ts'
74
91
  import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
75
92
  import { attachDatabase, bootstrapDatabase } from './database.ts'
93
+ import { createDeliveryMemberSecurity } from './delivery-members.ts'
76
94
  import { createDiagnostics } from './diagnostics.ts'
77
95
  import { syncDomainsFile } from './domains.ts'
78
96
  import { editorTypeLibs, siteJsxImportSource } from './editor-types.ts'
@@ -90,6 +108,7 @@ import {
90
108
  wantsJson,
91
109
  writeFlash,
92
110
  } from './form-submit.ts'
111
+ import { describeFormTypes, formTypesFor, formTypesOf } from './form-types.ts'
93
112
  import { createWorkflowRunner } from './form-workflows.ts'
94
113
  import {
95
114
  entriesCsv,
@@ -108,7 +127,18 @@ import { mediaStoreFromEnvironment } from './media-store.ts'
108
127
  import { createMemberAuth, MEMBER_ROUTE_PREFIX } from './member-auth.ts'
109
128
  import { noNodesPage } from './no-nodes.ts'
110
129
  import { NotImplementedLog } from './not-implemented.ts'
111
- import { createDeps, resolveUserLinkSender } from './ports.ts'
130
+ import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
131
+ import { createRedirectTracker } from './redirects.ts'
132
+ import { handleTransferAction, pendingTransfers } from './transfer-dashboard.ts'
133
+ import {
134
+ changeUrlOptions,
135
+ redirectAfterUrlChange,
136
+ type UrlChangeContext,
137
+ urlSettingEditable,
138
+ } from './url-settings.ts'
139
+
140
+ export { PREVIEW_COOKIE }
141
+
112
142
  import { syncConfiguredRedirects } from './redirects.ts'
113
143
  import {
114
144
  bootSchema,
@@ -125,7 +155,10 @@ import {
125
155
  type HubSocketData,
126
156
  type ServerEventHub,
127
157
  } from './server-events.ts'
158
+ import { createSetupRoutes } from './setup.ts'
159
+ import { summariseUpgrades } from './upgrade-summary.ts'
128
160
  import { assertViewRuntime } from './view-runtime.ts'
161
+ import { bundleEventProblems, createWebhookService, webhookStoreView } from './webhooks.ts'
129
162
 
130
163
  const CONTRACT_PATH = Bun.resolveSync('@crombolo/contracts/OpenApi.json', import.meta.dir)
131
164
 
@@ -177,9 +210,6 @@ export interface ServerHandle {
177
210
  close(): Promise<void>
178
211
  }
179
212
 
180
- /** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
181
- export const PREVIEW_COOKIE = 'UMB_PREVIEW'
182
-
183
213
  export async function createServer(config: CromboloConfig = loadConfig()): Promise<ServerHandle> {
184
214
  configureLogging({
185
215
  logsDir: config.logsDir,
@@ -203,6 +233,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
203
233
  }
204
234
  }
205
235
  const domainLog = logger('domains')
236
+ const apiLog = logger('management-api')
206
237
  // Extensions are the site's own npm dependencies (`docs/bundles.md`).
207
238
  // Uncached in development so an author editing one sees a reload pick it up.
208
239
  const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
@@ -217,7 +248,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
217
248
  (await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
218
249
  applyContentOnInstall: config.applyBundleContentOnInstall,
219
250
  applyContent: async (packageName) => {
220
- const result = await installBundleContent(db, config, { bundle: packageName })
251
+ // A package from the marketplace is for anyone, so nothing in it can be
252
+ // sealed; one that is is refused rather than half applied.
253
+ const result = await installBundleContent(db, config, { bundle: packageName }).catch(
254
+ (error: unknown) => {
255
+ if (error instanceof CommandError) return error
256
+ throw error
257
+ },
258
+ )
259
+ if (result instanceof CommandError)
260
+ return { applied: false, message: `Its content was not applied: ${result.message}` }
221
261
  if (!result.runId) {
222
262
  const reasons = [
223
263
  ...result.problems,
@@ -225,7 +265,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
225
265
  ]
226
266
  return {
227
267
  applied: false,
228
- message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Resolve that, then run \`crombolo bundle install ${packageName}\`.`,
268
+ message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Answer what it needs in Settings, under Changes, and import it from there.`,
229
269
  }
230
270
  }
231
271
  // The content landed under a cache this process had already constructed.
@@ -241,12 +281,20 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
241
281
  const owns = config.role !== 'web'
242
282
  // Bundles are checked before the database is touched, because a bundle that
243
283
  // 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)
284
+ // On every role: a bundle's form types are needed wherever forms render and
285
+ // submit. Its routes and tables stay with the nodes that serve the backoffice.
286
+ const bundlePackages = extensions.bundles().bundles
287
+ const formTypes = formTypesOf(config)
288
+ {
289
+ const problems = [
290
+ ...validateServerBundles(config.bundles, bundlePackages, VERSION),
291
+ ...formTypes.problems,
292
+ ...bundleEventProblems(config.bundles),
293
+ ]
247
294
  if (problems.length > 0)
248
295
  throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
249
296
  }
297
+ setFormFieldTypes(formTypes.types, formTypes.renderers)
250
298
  const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
251
299
  const { db, seededAdminPassword } = owns
252
300
  ? await bootstrapDatabase(config, chains)
@@ -270,10 +318,40 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
270
318
  const auth = new AuthService(new AuthStore(db))
271
319
  const cookies = { siteName: config.siteName, securePrefix: config.secureCookies }
272
320
  const names = cookieNames(cookies)
321
+ // The socket's peer for each request in flight, which only `Bun.serve` knows;
322
+ // a request that came some other way (a test, the assistant) has none.
323
+ const peers = new WeakMap<Request, string>()
324
+ const clientAddress = (request: Request): ClientAddress => ({
325
+ ipAddress: peers.get(request) ?? null,
326
+ forwardedFor: request.headers.get('x-forwarded-for'),
327
+ })
328
+ // The global cap behind the per-caller one is what holds when a forwarded
329
+ // address is spoofed to dodge it.
330
+ const setup = createSetupRoutes({
331
+ db,
332
+ setupCode: config.setupCode,
333
+ siteName: config.siteName,
334
+ backOfficePath: paths.backOfficePath,
335
+ clientKey: (request) =>
336
+ peers.get(request) ?? request.headers.get('x-forwarded-for') ?? 'unknown',
337
+ })
273
338
  const authRoutes = createAuthRoutes({
274
339
  backOfficePath: paths.backOfficePath,
275
340
  service: auth,
276
341
  cookies,
342
+ onAudit: (event, request) =>
343
+ recordAudit(db, {
344
+ category: 'sign-in',
345
+ action: event.action,
346
+ source: 'request',
347
+ userKey: event.user?.key ?? null,
348
+ userName: event.user?.userName ?? event.login ?? null,
349
+ entityType: event.user ? 'User' : null,
350
+ entityKey: event.user?.key ?? null,
351
+ entityName: event.user?.userName ?? null,
352
+ detail: event.detail ?? null,
353
+ ...clientAddress(request),
354
+ }),
277
355
  })
278
356
 
279
357
  /**
@@ -307,15 +385,39 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
307
385
 
308
386
  // Form definitions are files, so they are re-read rather than cached with the
309
387
  // content: nothing about a publish tells this node that a form changed.
310
- const forms = createFormRegistry(config.schemaDir)
388
+ const forms = createFormRegistry(config.schemaDir, { formTypes: formTypes.types })
311
389
  // Every form this node renders carries a token signed with the site's key, so
312
390
  // the timing guard cannot be bypassed by posting a made-up render time.
313
391
  const formSecret = await formSigningSecret(db)
314
392
  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
- )
393
+ const publishedSource = createPublishedContentSource(db, {
394
+ nodeState: schema.nodeState,
395
+ nodeId: schema.nodeId,
396
+ })
397
+ const cacheOptions = { form: (key: string) => forms.byKey(key) }
398
+ const cache = new PublishedCache(publishedSource, cacheOptions)
399
+ // A sync or a Settings change that alters how URLs are built moves every URL:
400
+ // the old ones redirect, as a rename's do.
401
+ const urlChange: UrlChangeContext = {
402
+ db,
403
+ config,
404
+ source: publishedSource,
405
+ cacheOptions,
406
+ cache,
407
+ tracker: createRedirectTracker({
408
+ cache,
409
+ redirects: new RedirectRepository(db),
410
+ enabled: config.trackRedirects,
411
+ }),
412
+ }
413
+ const afterSync = async (report: SyncReport | undefined) => {
414
+ if (!report?.urls) return
415
+ await redirectAfterUrlChange(urlChange, report.urls.before)
416
+ log.info('Page addresses now {mode} the top-level page; old addresses redirect', {
417
+ mode: report.urls.after.hideTopLevel ? 'leave out' : 'include',
418
+ })
419
+ }
420
+ await afterSync(schema.report)
319
421
  // A snapshot of the views, so a template edited anywhere — the backoffice, a
320
422
  // deploy, a bucket synced underneath — is picked up without restarting this
321
423
  // node. `assertViewRuntime` first, because a cache directory the JSX runtime
@@ -436,6 +538,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
436
538
  { nodeId: schema.nodeId, revision: config.schemaRevision },
437
539
  (report: SyncReport) => {
438
540
  cache.invalidate()
541
+ void afterSync(report).catch((error: unknown) =>
542
+ schemaLog.error('Recording redirects after an address change failed: {error}', {
543
+ error: String(error),
544
+ }),
545
+ )
439
546
  if (report.action === 'refused')
440
547
  schemaLog.error('Schema change refused: {reason}', {
441
548
  reason:
@@ -460,6 +567,26 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
460
567
  nodeState: schema.nodeState,
461
568
  nodeId: schema.nodeId,
462
569
  })
570
+ const deliverySettings = resolveDeliveryApiSettings(config.deliveryApi)
571
+ const deliveryPrefix = DELIVERY_API_PREFIX
572
+ const deliveryMembers = createDeliveryMemberSecurity({
573
+ db,
574
+ settings: deliverySettings,
575
+ members,
576
+ })
577
+ for (const problem of deliveryMembers.problems)
578
+ log.warning('Delivery API: {problem}', { problem })
579
+ const deliveryApi =
580
+ config.role === 'api'
581
+ ? undefined
582
+ : createDeliveryApi({
583
+ settings: deliverySettings,
584
+ cache,
585
+ prefix: deliveryPrefix,
586
+ protection: () => new PublicAccessRepository(db).allRules(),
587
+ member: (token) => deliveryMembers.member(token),
588
+ security: (request, action) => deliveryMembers.handle(request, action),
589
+ })
463
590
 
464
591
  /** A document's latest saved version, as preview shows it, in the published tree's context. */
465
592
  async function draftModel(key: string, requested: string | null) {
@@ -505,12 +632,6 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
505
632
  ...entry,
506
633
  })
507
634
  })
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
635
  // The site's config may name a store; without one the environment decides,
515
636
  // and that falls back to the media directory on this disk.
516
637
  const mediaStore = config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir))
@@ -524,6 +645,18 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
524
645
  const canSendUserLinks = Boolean(resolveUserLinkSender(config, email))
525
646
  const mediaFiles = new MediaFileStore(mediaStore)
526
647
  const images = new ImageProcessor(mediaStore)
648
+ const webhooks = createWebhookService({
649
+ db,
650
+ schemaDir: config.schemaDir,
651
+ filesWritable: config.schemaWritable,
652
+ secretKey: config.secretKey,
653
+ bundles: config.bundles,
654
+ onFilesChanged: config.schemaStore
655
+ ? async () => {
656
+ await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
657
+ }
658
+ : undefined,
659
+ })
527
660
  const api = createManagementApiRouter({
528
661
  deps: {
529
662
  ...createDeps({
@@ -531,10 +664,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
531
664
  paths,
532
665
  extensions,
533
666
  db,
667
+ webhooks,
534
668
  cache,
535
669
  schema,
536
670
  mediaFiles,
537
671
  email,
672
+ onSearchQueued: () => jobs.kickSearchIndexing(),
538
673
  // This node looks again at once; the others are told to. The write has
539
674
  // already landed wherever `componentsDir` points, so there is nothing to
540
675
  // distribute — only the fact that it happened.
@@ -552,16 +687,28 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
552
687
  })
553
688
  },
554
689
  }),
555
- preview: previewPort,
556
690
  },
557
691
  authenticate,
558
692
  nodes: createNodeLookup(db),
559
693
  allowLocalLogin: config.allowLocalLogin,
694
+ audit: createAuditSink(db),
695
+ clientAddress,
696
+ onError: (error) => {
697
+ const answered = busyResponse(error)
698
+ if (answered) apiLog.warn('A write gave up waiting for another: {error}', { error })
699
+ return answered
700
+ },
560
701
  onNotImplemented: config.development
561
702
  ? (operation, request) => notImplemented.record(operation, request)
562
703
  : undefined,
704
+ // Queued after the response, so a slow receiver never holds an editor up.
705
+ onWebhookEvent: (event) => {
706
+ void webhooks.raise(event)
707
+ },
563
708
  onServerEvent: (event) => {
564
709
  events.broadcast('notify', [event])
710
+ // Saves, deletes and moves all announce themselves; whatever they queued gets indexed now.
711
+ jobs.kickSearchIndexing()
565
712
  // An open preview of the document reloads when it changes.
566
713
  if (event.eventSource === 'Umbraco:CMS:Document' && event.eventType !== 'Deleted')
567
714
  previewHub.broadcast('refreshed', [event.key])
@@ -606,9 +753,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
606
753
  nodeState: schema.nodeState,
607
754
  nodeId: schema.nodeId,
608
755
  versionCleanup: config.versionCleanup,
756
+ auditRetentionDays: config.auditRetentionDays,
609
757
  mediaFiles,
610
758
  // The workflow queue drains here rather than in the request: a submission is
611
759
  // stored first, and an e-mail that cannot be sent yet must not cost it.
760
+ webhooks,
612
761
  formWorkflows: createWorkflowRunner({
613
762
  db,
614
763
  forms,
@@ -617,6 +766,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
617
766
  nodeState: schema.nodeState,
618
767
  nodeId: schema.nodeId,
619
768
  onContentChanged: () => cache.invalidate(),
769
+ bundleWorkflows: formTypes.workflows,
620
770
  }),
621
771
  onDocumentChanged: (key) =>
622
772
  events.broadcast('notify', [
@@ -635,6 +785,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
635
785
  // App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
636
786
  // extensions silently stopped loading after an upgrade; remove at 1.0.
637
787
  warnAboutAppPlugins(config.siteDir, log)
788
+ // Named, never shown: the value is the one thing this must not print.
789
+ if (config.setupCode && (await setup.pending()) === false)
790
+ log.warning('CROMBOLO_SETUP_CODE is still set, but the site is set up. Remove the setting.')
638
791
  noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
639
792
  const hubPath = SERVER_EVENT_HUB_PATH
640
793
  const previewHubPath = PREVIEW_HUB_PATH
@@ -648,7 +801,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
648
801
  module.createAssistantHost({
649
802
  config: config.assistant as NonNullable<typeof config.assistant>,
650
803
  db,
651
- schema: createSchemaProposals({ db, config, boot: schema }),
804
+ schema: createSchemaProposals({ db, config, boot: schema, afterSync }),
652
805
  siteName: config.siteName,
653
806
  version: VERSION,
654
807
  dispatch: (inner) => api.dispatch(inner),
@@ -680,6 +833,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
680
833
 
681
834
  // Imported only when configured, like the assistant: an unconfigured site has no
682
835
  // git route and no credentials anywhere near it.
836
+ const auditPath = `${paths.pluginPath}/api/audit`
837
+ const auditHost = createAuditHost(db, config.auditRetentionDays)
838
+
683
839
  const gitPath = `${paths.pluginPath}/api/git`
684
840
  const git =
685
841
  config.git && servesBackOffice
@@ -712,9 +868,19 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
712
868
  // `web` node falls straight through to the public site, so the backoffice,
713
869
  // the Management API and the sign-in routes are not merely unauthorised
714
870
  // there — they are not routes at all, and 404 like any other unknown path.
871
+ // Umbraco's Delivery API, below the backoffice path but served wherever the
872
+ // site is, from the published cache: it scales with rendering.
873
+ if (deliveryApi && pathname.startsWith(`${deliveryPrefix}/`)) {
874
+ const response = await deliveryApi.handle(request)
875
+ if (response) return response
876
+ }
877
+
715
878
  if (servesBackOffice) {
716
879
  const response = await serveBackOffice(request, url)
717
- if (response) return response
880
+ if (response) {
881
+ if (response.ok) await auditPluginWrite(request, pathname)
882
+ return response
883
+ }
718
884
  }
719
885
 
720
886
  if (pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) {
@@ -749,6 +915,27 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
749
915
  return renderSite(request, pathname, url.host)
750
916
  }
751
917
 
918
+ /**
919
+ * The audit entry for a successful write to one of Crombolo's own endpoints,
920
+ * which the contract router does not see. The assistant's are left out: what it
921
+ * changes goes through the router, and is recorded there. docs/audit-log.md.
922
+ */
923
+ async function auditPluginWrite(request: Request, pathname: string): Promise<void> {
924
+ const prefix = `${paths.pluginPath}/api/`
925
+ if (request.method === 'GET' || request.method === 'HEAD') return
926
+ if (!pathname.startsWith(prefix) || pathname.startsWith(`${assistantPath}/`)) return
927
+ const principal = await authenticate(request)
928
+ if (!principal) return
929
+ const plan = pluginAuditPlan(request.method, pathname.slice(prefix.length))
930
+ await recordAudit(db, {
931
+ ...plan,
932
+ source: 'request',
933
+ userKey: principal.id,
934
+ userName: principal.name,
935
+ ...clientAddress(request),
936
+ })
937
+ }
938
+
752
939
  /**
753
940
  * The backoffice, the Management API and everything that serves an editor.
754
941
  *
@@ -760,6 +947,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
760
947
  async function serveBackOffice(request: Request, url: URL): Promise<Response | undefined> {
761
948
  const { pathname } = url
762
949
 
950
+ if (pathname === setup.path) return setup.handle(request)
951
+ // Until the first administrator exists there is nobody to sign in as.
952
+ if (
953
+ request.method === 'GET' &&
954
+ (pathname === `${paths.backOfficePath}/login` ||
955
+ isSpaRoute(paths.backOfficePath, pathname)) &&
956
+ (await setup.pending())
957
+ )
958
+ return new Response(null, { status: 303, headers: { location: setup.path } })
959
+
763
960
  // Non-contract, signed-in: the assistant's chat, review and MCP endpoints.
764
961
  // Absent configuration there is no `assistant`, so these paths 404 like any
765
962
  // other unknown route.
@@ -809,6 +1006,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
809
1006
  }
810
1007
  const dryRun = request.method === 'GET'
811
1008
  const result = await importSchema(db, config, schema, { dryRun })
1009
+ if (!dryRun) await afterSync(result.report)
812
1010
  if (!dryRun) {
813
1011
  log.info('{user} imported the schema: {action}, {classification}', {
814
1012
  user: principal.userName,
@@ -840,7 +1038,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
840
1038
  if (!principal) return new Response('Unauthorized', { status: 401 })
841
1039
  if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
842
1040
  const body = JSON.stringify({
843
- libs: await editorTypeLibs({ schemaDir: config.schemaDir }),
1041
+ libs: await editorTypeLibs({
1042
+ schemaDir: config.schemaDir,
1043
+ formTypes: formTypesFor(config),
1044
+ }),
844
1045
  jsxImportSource: siteJsxImportSource(config.siteDir),
845
1046
  })
846
1047
  // Revalidated on every open rather than kept: the generated types follow
@@ -967,6 +1168,33 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
967
1168
  * files are the truth, the backoffice may have just rewritten one, and a
968
1169
  * picker that disagreed with what a render uses would be worse than slow.
969
1170
  */
1171
+ // Non-contract, Settings: the "Where it's kept" panel on a webhook's screen.
1172
+ const kept = new RegExp(`^${paths.pluginPath}/api/webhooks/([0-9a-fA-F-]{36})/store$`).exec(
1173
+ pathname,
1174
+ )
1175
+ if (kept) {
1176
+ const principal = await authenticate(request)
1177
+ if (!principal) return new Response('Unauthorized', { status: 401 })
1178
+ if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
1179
+ const key = (kept[1] as string).toLowerCase()
1180
+ if (request.method === 'PUT') {
1181
+ const body = (await request.json().catch(() => ({}))) as { store?: unknown }
1182
+ if (body.store !== 'files' && body.store !== 'database')
1183
+ return Response.json({ reason: 'Choose files or database.' }, { status: 400 })
1184
+ const moved = await webhooks.move(key, body.store)
1185
+ return moved.ok
1186
+ ? Response.json({ ok: true })
1187
+ : Response.json(
1188
+ { reason: moved.reason, problems: moved.problems ?? [] },
1189
+ { status: moved.status },
1190
+ )
1191
+ }
1192
+ if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
1193
+ const webhook = await webhooks.get(key)
1194
+ if (!webhook) return new Response('Not Found', { status: 404 })
1195
+ return Response.json(webhookStoreView(webhook.store, webhooks.stores()))
1196
+ }
1197
+
970
1198
  if (pathname.startsWith(`${paths.pluginPath}/api/forms`)) {
971
1199
  const principal = await authenticate(request)
972
1200
  if (!principal) return new Response('Unauthorized', { status: 401 })
@@ -978,7 +1206,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
978
1206
  // A definition is structure, like a content type: anyone who may open the
979
1207
  // section may read it, and the picker needs it in the Content section too.
980
1208
  if (action === '' && request.method === 'GET')
981
- return Response.json({ items: definitions.map(formSummary) })
1209
+ return Response.json({
1210
+ items: definitions.map((form) => formSummary(form, formTypes.types)),
1211
+ })
1212
+ // Hyphenated, so no form alias (letters and digits) can be mistaken for it.
1213
+ if (action === 'available-types' && request.method === 'GET')
1214
+ return Response.json(describeFormTypes(formTypes.types))
982
1215
 
983
1216
  const entries = new FormEntryRepository(db)
984
1217
  const byKey = formsByKey(definitions)
@@ -989,6 +1222,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
989
1222
  const formFiles = {
990
1223
  schemaDir: config.schemaDir,
991
1224
  writable: config.schemaWritable,
1225
+ formTypes: formTypes.types,
992
1226
  onChanged: config.schemaStore
993
1227
  ? async () => {
994
1228
  await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
@@ -1065,7 +1299,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1065
1299
  const form = byKey.get(key)
1066
1300
  if (!form) return new Response('Not Found', { status: 404 })
1067
1301
  const page = await entries.list({ formKey: key, take: 10_000 })
1068
- const result = entriesCsv(form, page.items, { includeSensitive: maySeeSensitive })
1302
+ const result = entriesCsv(form, page.items, {
1303
+ includeSensitive: maySeeSensitive,
1304
+ types: formTypes.types,
1305
+ })
1069
1306
  if (!result.ok)
1070
1307
  return new Response(result.reason, {
1071
1308
  status: 403,
@@ -1112,6 +1349,25 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1112
1349
  return Response.json({ ...formDetail(form), definition: form })
1113
1350
  }
1114
1351
 
1352
+ // What a choice source offers now, in the default language, for the
1353
+ // designer to show beside the source it is editing.
1354
+ if (action === 'choices' && request.method === 'POST') {
1355
+ if (!may(FormPermissions.Manage)) return new Response('Forbidden', { status: 403 })
1356
+ const body = (await request.json().catch(() => ({}))) as { source?: unknown }
1357
+ const source = body.source as { kind?: unknown; parent?: unknown } | undefined
1358
+ if (
1359
+ !source ||
1360
+ !CHOICE_SOURCE_KINDS.includes(source.kind as ChoiceSourceKind) ||
1361
+ typeof source.parent !== 'string'
1362
+ )
1363
+ return new Response('Bad Request', { status: 400 })
1364
+ const options = await cache.choiceOptions({
1365
+ kind: source.kind as ChoiceSourceKind,
1366
+ parent: source.parent,
1367
+ })
1368
+ return Response.json({ options })
1369
+ }
1370
+
1115
1371
  // Designing a form is a developer's job, so it is its own verb rather
1116
1372
  // than something an entry reader can do.
1117
1373
  if (request.method === 'POST' || request.method === 'PUT') {
@@ -1166,6 +1422,70 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1166
1422
  // and no explanation. The findings name documents and the properties that are
1167
1423
  // unfilled: that is the Settings dashboard's material, and the dashboard's own
1168
1424
  // manifest is already `Umb.Condition.SectionAlias` on Settings.
1425
+ // Non-contract, Settings: the audit log dashboard's reads. Settings, as the
1426
+ // dashboard is: it names who signed in from where, and every change made.
1427
+ if (pathname === auditPath || pathname.startsWith(`${auditPath}/`)) {
1428
+ const principal = await authenticate(request)
1429
+ if (!principal) return new Response('Unauthorized', { status: 401 })
1430
+ if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
1431
+ const response = await auditHost.handle(request, pathname.slice(auditPath.length))
1432
+ if (response) return response
1433
+ }
1434
+
1435
+ // Non-contract, Settings: how page addresses are built (story 110).
1436
+ if (pathname === `${paths.pluginPath}/api/url-options`) {
1437
+ const principal = await authenticate(request)
1438
+ if (!principal) return new Response('Unauthorized', { status: 401 })
1439
+ if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
1440
+ if (request.method === 'GET')
1441
+ return Response.json({
1442
+ ...(await readUrlOptions(db)),
1443
+ editable: urlSettingEditable(config, existsSync(config.schemaDir)),
1444
+ })
1445
+ if (request.method !== 'PUT') return new Response('Method Not Allowed', { status: 405 })
1446
+ const body = (await request.json().catch(() => ({}))) as { hideTopLevel?: unknown }
1447
+ if (typeof body.hideTopLevel !== 'boolean')
1448
+ return Response.json(
1449
+ { ok: false, message: 'Say whether to hide the top-level page.' },
1450
+ { status: 400 },
1451
+ )
1452
+ const outcome = await changeUrlOptions(urlChange, { hideTopLevel: body.hideTopLevel })
1453
+ log.info('{user} set page addresses to {mode} the top-level page: {outcome}', {
1454
+ user: principal.email,
1455
+ mode: body.hideTopLevel ? 'leave out' : 'include',
1456
+ outcome: outcome.ok ? 'ok' : outcome.message,
1457
+ })
1458
+ return outcome.ok
1459
+ ? Response.json({ ok: true, ...outcome.options })
1460
+ : Response.json({ ok: false, message: outcome.message }, { status: outcome.status })
1461
+ }
1462
+
1463
+ // Non-contract, Packages: answering a content import's conflicts and running
1464
+ // it, from the Changes dashboard. Packages, because installing is.
1465
+ if (pathname.startsWith(`${paths.pluginPath}/api/transfers/`)) {
1466
+ const principal = await authenticate(request)
1467
+ if (!principal) return new Response('Unauthorized', { status: 401 })
1468
+ if (!hasSection(principal, 'packages')) return new Response('Forbidden', { status: 403 })
1469
+ const action = pathname.slice(`${paths.pluginPath}/api/transfers/`.length)
1470
+ const response = await handleTransferAction(request, action, {
1471
+ db,
1472
+ config,
1473
+ user: principal.email,
1474
+ onApplied: () => cache.invalidate(),
1475
+ })
1476
+ if (response) {
1477
+ if (request.method === 'POST')
1478
+ log.info('{user} {verb} for the content bundle {bundle}: {status}', {
1479
+ user: principal.email,
1480
+ verb: action.split('/').pop(),
1481
+ bundle: action.split('/')[0],
1482
+ status: response.status,
1483
+ })
1484
+ return response
1485
+ }
1486
+ return new Response('Not Found', { status: 404 })
1487
+ }
1488
+
1169
1489
  if (pathname === `${paths.pluginPath}/api/change-report`) {
1170
1490
  const principal = await authenticate(request)
1171
1491
  if (!principal) return new Response('Unauthorized', { status: 401 })
@@ -1185,7 +1505,24 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1185
1505
  // A bundle's tables are part of what an upgrade changes, so the dashboard
1186
1506
  // that shows an upgrade's findings shows their steps too.
1187
1507
  const bundles = await bundleTablesReport(db, chains)
1188
- return Response.json({ health: health(), counts, bySource, findings, bundles })
1508
+ const upgrades = summariseUpgrades(await readLedger(db), VERSION)
1509
+ // The answers a finding can be given, offered only to those who may import.
1510
+ const mayImport = hasSection(principal, 'packages')
1511
+ const offered = findings.map((f) =>
1512
+ f.source === 'transfer' && f.status === 'open' && mayImport
1513
+ ? { ...f, choices: choicesFor(f) }
1514
+ : f,
1515
+ )
1516
+ return Response.json({
1517
+ health: health(),
1518
+ counts,
1519
+ bySource,
1520
+ findings: offered,
1521
+ bundles,
1522
+ upgrades,
1523
+ transfers: await pendingTransfers(db),
1524
+ mayImport,
1525
+ })
1189
1526
  }
1190
1527
 
1191
1528
  const staticMatch = resolveStaticFile(paths, pathname, {
@@ -1402,6 +1739,21 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1402
1739
  mediaFiles,
1403
1740
  uploads: DEFAULT_UPLOAD_SETTINGS,
1404
1741
  urlOf: async (contentKey) => (await cache.byKey(contentKey))?.url,
1742
+ // The page it was sent from names the culture; the keys a source offers
1743
+ // are the same in every culture, so only the stored labels depend on it.
1744
+ resolveForm: async (form) => {
1745
+ const referer = request.headers.get('referer')
1746
+ let culture: string | null = null
1747
+ try {
1748
+ if (referer) {
1749
+ const url = new URL(referer)
1750
+ culture = await cache.cultureFor(url.host, url.pathname)
1751
+ }
1752
+ } catch {
1753
+ culture = null
1754
+ }
1755
+ return cache.resolveForm(form, culture)
1756
+ },
1405
1757
  },
1406
1758
  request,
1407
1759
  key,
@@ -1438,6 +1790,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1438
1790
  ? Response.json({ ok: false, error: message }, { status: 409 })
1439
1791
  : plain(message, 409)
1440
1792
  }
1793
+ // Only the script checks a page, so this always answers in JSON.
1794
+ case 'checked':
1795
+ return outcome.errors.length > 0
1796
+ ? Response.json({ ok: false, errors: outcome.errors }, { status: 422 })
1797
+ : Response.json({ ok: true })
1441
1798
  case 'invalid':
1442
1799
  return json
1443
1800
  ? Response.json({ ok: false, errors: outcome.state.errors }, { status: 422 })
@@ -1605,6 +1962,8 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1605
1962
  port: config.port,
1606
1963
  maxRequestBodySize: config.maxRequestBodyBytes,
1607
1964
  fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
1965
+ const peer = bunServer.requestIP(request)?.address
1966
+ if (peer) peers.set(request, peer)
1608
1967
  const pathname = new URL(request.url).pathname
1609
1968
  if (pathname === hubPath || pathname === previewHubPath) {
1610
1969
  const principal = await authenticate(request)