@crombolo/server 0.11.0 → 0.12.1

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