@crombolo/server 0.11.0 → 0.13.1

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