@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.
- package/package.json +12 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/search-index.ts +31 -0
- package/src/adapters/webhooks.ts +76 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +46 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +12 -0
- package/src/jobs.ts +91 -10
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +57 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -29
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- 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 {
|
|
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
|
-
|
|
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'}.
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
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)
|
|
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({
|
|
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({
|
|
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, {
|
|
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
|
-
|
|
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)
|