@crombolo/server 0.0.0-stage → 0.9.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
package/src/bundles.ts ADDED
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Server-side bundle extensions: a bundle that has to answer a request, not just
3
+ * render a screen.
4
+ *
5
+ * `docs/17-bundles.md` argued that server-side extensions should not exist here,
6
+ * and the objection was specific — loading third-party JS into a running node
7
+ * has no isolation story, and a `role: web` node would never see a runtime
8
+ * install. What answers it is wiring rather than sandboxing: **nothing in this
9
+ * file is discovered**. A bundle installed from the marketplace writes
10
+ * `package.json` and nothing more; its server half exists only because someone
11
+ * imported the factory into `crombolo.config.ts` and deployed that, which is a
12
+ * reviewable diff that reaches every node. The path from "someone clicked
13
+ * install" to "third-party code is in the request path" is not merely guarded,
14
+ * it is absent.
15
+ *
16
+ * What a wired bundle then gets is deliberately small:
17
+ *
18
+ * - Routes mount under `<backoffice>/crombolo/api/bundle/<id>/`. The id and every
19
+ * route path are validated, so a bundle can neither shadow a core route nor
20
+ * reach another bundle's.
21
+ * - The host authenticates and checks the declared section **before** dispatch. A
22
+ * handler never sees an anonymous request, and a bundle cannot declare a public
23
+ * route or widen its own authorisation.
24
+ * - It receives only the capabilities it declared, frozen. No `Db`, no config, no
25
+ * filesystem. A capability is a narrow interface this file owns and implements,
26
+ * so the blast radius is what core chose to expose rather than what the bundle
27
+ * can reach.
28
+ * - A handler that throws becomes a 500 and a log line naming the bundle, never a
29
+ * broken request path.
30
+ *
31
+ * Deliberately not offered: migrations, raw SQL, and anything that writes the
32
+ * site's files. A bundle that needs a table of its own needs a release of this
33
+ * CMS, because a migration that arrives with a dependency is a schema no
34
+ * `migration_history` can account for.
35
+ */
36
+ import { hasCultureAccess, hasSection, type Principal } from '@crombolo/api-management'
37
+ import {
38
+ type AppAlias,
39
+ type ExtensionSectionAlias,
40
+ isExtensionSectionAlias,
41
+ SECTION_ALIASES,
42
+ } from '@crombolo/core'
43
+ import {
44
+ type Db,
45
+ type RedirectMatchKind,
46
+ RedirectRepository,
47
+ type RedirectRow,
48
+ type RedirectTargetKind,
49
+ } from '@crombolo/data'
50
+ import { normaliseRedirectPath, type PublishedCache } from '@crombolo/render'
51
+
52
+ /** The segment every bundle's routes hang below, inside the plugin API. */
53
+ export const BUNDLE_API_SEGMENT = 'bundle'
54
+
55
+ /**
56
+ * A bundle's id, which is also its URL segment: lowercase, no dots or slashes, so
57
+ * it cannot climb out of its namespace however it is spelled.
58
+ */
59
+ const BUNDLE_ID = /^[a-z][a-z0-9-]{1,38}$/
60
+
61
+ /**
62
+ * A route path below the bundle's namespace. Literal segments, optionally one
63
+ * `:name` parameter each, and no leading or trailing slash — so a path cannot
64
+ * traverse, and `''` means the namespace root.
65
+ */
66
+ const ROUTE_PATH =
67
+ /^(?:[a-z0-9][a-z0-9-]*|:[a-z][a-z0-9]*)(?:\/(?:[a-z0-9][a-z0-9-]*|:[a-z][a-z0-9]*))*$/
68
+
69
+ export type BundleMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
70
+
71
+ /** What the host writes to the site's log on the bundle's behalf. */
72
+ export interface BundleLog {
73
+ info(message: string, properties?: Record<string, unknown>): void
74
+ warn(message: string, properties?: Record<string, unknown>): void
75
+ error(message: string, properties?: Record<string, unknown>): void
76
+ }
77
+
78
+ /** A redirect rule as the capability reports it. */
79
+ export interface BundleRedirectRule extends RedirectRow {
80
+ /** Whether this bundle may change or delete it: only a `manual` rule. */
81
+ editable: boolean
82
+ }
83
+
84
+ /** A rule to store. `source` is not a field: the capability sets it to `manual`. */
85
+ export interface BundleRedirectInput {
86
+ matchKind: RedirectMatchKind
87
+ pattern: string
88
+ rootKey?: string | null
89
+ culture?: string | null
90
+ targetKind: RedirectTargetKind
91
+ target: string
92
+ statusCode?: number
93
+ sortOrder?: number
94
+ }
95
+
96
+ export type BundleRedirectSaved =
97
+ | { ok: true; rule: BundleRedirectRule }
98
+ | { ok: false; message: string; reason?: 'notFound' | 'notEditable' }
99
+
100
+ /**
101
+ * Redirect rules an administrator manages.
102
+ *
103
+ * `list` reports every rule in force, because a screen that hid the configured
104
+ * and tracked ones would be lying about what the site does. Writing is confined
105
+ * to `manual`: the capability sets the source itself and refuses a key belonging
106
+ * to any other, so a bundle cannot forge a configured rule, delete one the
107
+ * config file owns, or interfere with the URL tracker.
108
+ */
109
+ export interface BundleRedirects {
110
+ list(options?: { filter?: string; skip?: number; take?: number }): Promise<{
111
+ total: number
112
+ items: BundleRedirectRule[]
113
+ }>
114
+ byKey(key: string): Promise<BundleRedirectRule | undefined>
115
+ save(input: BundleRedirectInput): Promise<BundleRedirectSaved>
116
+ /**
117
+ * Changes an existing rule, pattern included. A rule is identified by what it
118
+ * matches, so editing the pattern is a different row — the host does the
119
+ * delete and the insert rather than leaving a bundle to discover that and
120
+ * leave the original behind.
121
+ */
122
+ replace(key: string, input: BundleRedirectInput): Promise<BundleRedirectSaved>
123
+ remove(key: string): Promise<'deleted' | 'notFound' | 'notEditable'>
124
+ }
125
+
126
+ /** Reading where a document lives now, for a screen that shows a rule's target. */
127
+ export interface BundleDocuments {
128
+ url(key: string, culture?: string | null): Promise<string | undefined>
129
+ }
130
+
131
+ /** Everything core is willing to hand a bundle. A capability is a key of this. */
132
+ export interface BundleHost {
133
+ redirects: BundleRedirects
134
+ documents: BundleDocuments
135
+ log: BundleLog
136
+ }
137
+
138
+ export type BundleCapability = keyof BundleHost
139
+
140
+ /**
141
+ * The section a bundle gates its routes on: a core section by its application
142
+ * alias, or a section the bundle's own manifests register by its manifest alias.
143
+ * An extension's section needs nothing from the server beyond this — the
144
+ * backoffice shows it to whoever's group was granted it, and the grant is stored
145
+ * as the alias itself.
146
+ */
147
+ export type BundleSection = AppAlias | ExtensionSectionAlias
148
+
149
+ /**
150
+ * Re-exported so a bundle needs one dependency rather than three. `principal`
151
+ * arrives on every `BundleRequest`, and a bundle that lifts a check out into a
152
+ * helper has to be able to name its type; `BundleSection` is what `section` takes.
153
+ */
154
+ export type { AppAlias, ExtensionSectionAlias, Principal }
155
+
156
+ /**
157
+ * The two checks a bundle is likely to want beyond the section the host has
158
+ * already enforced. `hasSection` normalises the alias, which matters because
159
+ * `allowedSections` arrives either bare or as a manifest alias depending on where
160
+ * it was read — comparing the strings by hand is the mistake this avoids.
161
+ *
162
+ * Anything finer — a permission verb, a start node, a per-node check — is read
163
+ * off `principal` directly: `isAdmin`, `permissions`, `groupKeys`, `groups`,
164
+ * `startNodes`, `languages`.
165
+ */
166
+ export { hasCultureAccess, hasSection }
167
+
168
+ /** Every capability name, for validating what a bundle asked for. */
169
+ export const BUNDLE_CAPABILITIES: readonly BundleCapability[] = ['redirects', 'documents', 'log']
170
+
171
+ export interface BundleRequest<C extends BundleCapability = BundleCapability> {
172
+ request: Request
173
+ url: URL
174
+ /** Already authenticated, and already holding the bundle's declared section. */
175
+ principal: Principal
176
+ /** The `:name` segments of the matched route. */
177
+ params: Record<string, string>
178
+ /** Only what the bundle declared, frozen. */
179
+ host: Pick<BundleHost, C>
180
+ }
181
+
182
+ export interface ServerBundleRoute<C extends BundleCapability = BundleCapability> {
183
+ method: BundleMethod
184
+ /** Below the bundle's namespace; `''` is the namespace root. */
185
+ path: string
186
+ handler(context: BundleRequest<C>): Response | Promise<Response>
187
+ }
188
+
189
+ export interface ServerBundle<C extends BundleCapability = BundleCapability> {
190
+ /** URL segment and log name; unique across the site's bundles. */
191
+ id: string
192
+ /** What the boot log and an error message call it. */
193
+ name: string
194
+ /**
195
+ * The section a caller must have access to. Every route is gated on it; there
196
+ * is no per-route override and no public route.
197
+ */
198
+ section: BundleSection
199
+ capabilities: readonly C[]
200
+ routes: readonly ServerBundleRoute<C>[]
201
+ }
202
+
203
+ /** A bundle as mounted, with the prefix its routes answer below. */
204
+ export interface MountedBundle {
205
+ id: string
206
+ name: string
207
+ section: BundleSection
208
+ capabilities: readonly BundleCapability[]
209
+ /** `<backoffice>/crombolo/api/bundle/<id>`. */
210
+ prefix: string
211
+ routes: readonly { method: BundleMethod; path: string }[]
212
+ }
213
+
214
+ /**
215
+ * Every reason this set of bundles cannot be mounted. Returned rather than
216
+ * thrown so the boot can report all of them at once; `createServerBundles`
217
+ * turns a non-empty list into a failed boot, because a bundle that is half
218
+ * mounted is worse than one that is absent.
219
+ */
220
+ export function validateServerBundles(bundles: readonly ServerBundle[]): string[] {
221
+ const problems: string[] = []
222
+ const seen = new Set<string>()
223
+ for (const bundle of bundles) {
224
+ const label = bundle.id || bundle.name || '<unnamed>'
225
+ if (!BUNDLE_ID.test(bundle.id))
226
+ problems.push(
227
+ `Bundle "${label}" has the id "${bundle.id}", which is not 2–39 characters of lowercase letters, digits and hyphens starting with a letter.`,
228
+ )
229
+ else if (seen.has(bundle.id)) problems.push(`Two bundles claim the id "${bundle.id}".`)
230
+ else seen.add(bundle.id)
231
+ if (!bundle.name) problems.push(`Bundle "${label}" has no name.`)
232
+ if (!(bundle.section in SECTION_ALIASES) && !isExtensionSectionAlias(bundle.section))
233
+ problems.push(
234
+ `Bundle "${label}" is gated on the section "${bundle.section}", which is neither a core section nor a manifest alias of the form Vendor.Section.Name.`,
235
+ )
236
+ for (const capability of bundle.capabilities)
237
+ if (!BUNDLE_CAPABILITIES.includes(capability))
238
+ problems.push(
239
+ `Bundle "${label}" asked for the capability "${capability}", which no host offers.`,
240
+ )
241
+ const routes = new Set<string>()
242
+ for (const route of bundle.routes) {
243
+ if (route.path !== '' && !ROUTE_PATH.test(route.path))
244
+ problems.push(
245
+ `Bundle "${label}" declares the route path "${route.path}", which is not a plain path of segments.`,
246
+ )
247
+ const signature = `${route.method} ${route.path}`
248
+ if (routes.has(signature)) problems.push(`Bundle "${label}" declares "${signature}" twice.`)
249
+ routes.add(signature)
250
+ }
251
+ }
252
+ return problems
253
+ }
254
+
255
+ /** `rules/:key` against `rules/abc` — the params, or undefined when it does not match. */
256
+ function matchRoute(path: string, requested: string): Record<string, string> | undefined {
257
+ if (path === '' && requested === '') return {}
258
+ const declared = path.split('/')
259
+ const actual = requested.split('/')
260
+ if (declared.length !== actual.length) return undefined
261
+ const params: Record<string, string> = {}
262
+ for (const [index, segment] of declared.entries()) {
263
+ const value = actual[index] as string
264
+ if (segment.startsWith(':')) {
265
+ if (value === '') return undefined
266
+ params[segment.slice(1)] = decodeURIComponent(value)
267
+ } else if (segment !== value) return undefined
268
+ }
269
+ return params
270
+ }
271
+
272
+ const STATUS_CODES = new Set([301, 302, 307, 308])
273
+ const MATCH_KINDS = new Set<RedirectMatchKind>(['exact', 'prefix', 'regex'])
274
+ const TARGET_KINDS = new Set<RedirectTargetKind>(['document', 'path', 'url'])
275
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
276
+ const ABSOLUTE_URL = /^https?:\/\/\S+$/i
277
+
278
+ /**
279
+ * Checks a rule before it is stored, so one bad row cannot sit in the table
280
+ * breaking route resolution for every request. A regex is compiled here rather
281
+ * than at match time for exactly that reason: the matcher runs on the request
282
+ * path, and a pattern that throws there would 500 the site rather than the
283
+ * screen that saved it.
284
+ */
285
+ export function validateRedirectInput(
286
+ input: BundleRedirectInput,
287
+ ): { ok: true; value: Required<BundleRedirectInput> } | { ok: false; message: string } {
288
+ if (!MATCH_KINDS.has(input.matchKind))
289
+ return { ok: false, message: 'Choose how the URL is matched.' }
290
+ if (!TARGET_KINDS.has(input.targetKind))
291
+ return { ok: false, message: 'Choose what the redirect points at.' }
292
+ const statusCode = input.statusCode ?? 301
293
+ if (!STATUS_CODES.has(statusCode))
294
+ return { ok: false, message: 'The status code must be 301, 302, 307 or 308.' }
295
+
296
+ const pattern = input.pattern?.trim() ?? ''
297
+ if (pattern === '') return { ok: false, message: 'Enter the URL to redirect from.' }
298
+ if (input.matchKind === 'regex') {
299
+ try {
300
+ new RegExp(pattern, 'i')
301
+ } catch {
302
+ return { ok: false, message: 'That is not a valid regular expression.' }
303
+ }
304
+ }
305
+
306
+ const target = input.target?.trim() ?? ''
307
+ if (target === '') return { ok: false, message: 'Enter where the redirect should go.' }
308
+ if (input.targetKind === 'document' && !UUID.test(target))
309
+ return { ok: false, message: 'Pick the page to redirect to.' }
310
+ if (input.targetKind === 'url' && !ABSOLUTE_URL.test(target))
311
+ return { ok: false, message: 'An external target must be a full http:// or https:// URL.' }
312
+
313
+ const normalised = input.matchKind === 'regex' ? pattern : normaliseRedirectPath(pattern)
314
+ const rootKey = input.rootKey?.trim() ? input.rootKey.trim().toLowerCase() : null
315
+ if (rootKey !== null && !UUID.test(rootKey))
316
+ return { ok: false, message: 'The site a rule is scoped to must be a document key.' }
317
+
318
+ // A rule pointing a route at itself would loop the browser; the exact case is
319
+ // cheap to catch and is what a typo produces.
320
+ if (input.targetKind === 'path' && normaliseRedirectPath(target) === normalised)
321
+ return { ok: false, message: 'That rule points the URL at itself.' }
322
+
323
+ return {
324
+ ok: true,
325
+ value: {
326
+ matchKind: input.matchKind,
327
+ pattern: normalised,
328
+ rootKey,
329
+ culture: input.culture?.trim() ? input.culture.trim().toLowerCase() : null,
330
+ targetKind: input.targetKind,
331
+ target: input.targetKind === 'document' ? target.toLowerCase() : target,
332
+ statusCode,
333
+ sortOrder: Number.isFinite(input.sortOrder) ? Number(input.sortOrder) : 0,
334
+ },
335
+ }
336
+ }
337
+
338
+ /** The manual-redirect capability over the repository core already owns. */
339
+ export function createBundleRedirects(options: {
340
+ db: Db
341
+ cache?: PublishedCache
342
+ }): BundleRedirects {
343
+ const redirects = new RedirectRepository(options.db)
344
+ const report = (row: RedirectRow): BundleRedirectRule => ({
345
+ ...row,
346
+ editable: row.source === 'manual',
347
+ })
348
+ // Rules are part of the published cache's snapshot, so a write that does not
349
+ // drop it does not take effect until something else does. The capability does
350
+ // this rather than the bundle, which cannot be relied on to remember.
351
+ const invalidate = () => options.cache?.invalidate()
352
+
353
+ return {
354
+ async list(paging = {}) {
355
+ const result = await redirects.list(paging)
356
+ return { total: result.total, items: result.items.map(report) }
357
+ },
358
+ async byKey(key) {
359
+ const found = await redirects.byKey(key)
360
+ return found ? report(found) : undefined
361
+ },
362
+ async save(input) {
363
+ const checked = validateRedirectInput(input)
364
+ if (!checked.ok) return { ok: false, message: checked.message }
365
+ const stored = await redirects.save({ ...checked.value, source: 'manual' })
366
+ invalidate()
367
+ return { ok: true, rule: report(stored) }
368
+ },
369
+ async replace(key, input) {
370
+ // Validated before anything is deleted, so the ordinary failure — a
371
+ // malformed rule — cannot cost the rule being edited.
372
+ const checked = validateRedirectInput(input)
373
+ if (!checked.ok) return { ok: false, message: checked.message }
374
+ const found = await redirects.byKey(key)
375
+ if (!found)
376
+ return { ok: false, message: 'That redirect no longer exists.', reason: 'notFound' }
377
+ if (found.source !== 'manual')
378
+ return {
379
+ ok: false,
380
+ message:
381
+ found.source === 'config'
382
+ ? "That redirect is declared in the site's configuration, so the file owns it."
383
+ : 'That redirect was recorded by a rename, so it cannot be edited here.',
384
+ reason: 'notEditable',
385
+ }
386
+ await redirects.delete(key)
387
+ const stored = await redirects.save({ ...checked.value, source: 'manual' })
388
+ invalidate()
389
+ return { ok: true, rule: report(stored) }
390
+ },
391
+ async remove(key) {
392
+ const found = await redirects.byKey(key)
393
+ if (!found) return 'notFound'
394
+ // The file owns a configured rule and the tracker owns a tracked one; a
395
+ // bundle deleting either would be undone at the next boot or publish
396
+ // anyway, so it is refused rather than silently reverted.
397
+ if (found.source !== 'manual') return 'notEditable'
398
+ await redirects.delete(key)
399
+ invalidate()
400
+ return 'deleted'
401
+ },
402
+ }
403
+ }
404
+
405
+ export interface ServerBundlesOptions {
406
+ bundles: readonly ServerBundle[]
407
+ /** `<backoffice>/crombolo/api`, from `createBackOfficePaths`. */
408
+ pluginApiPath: string
409
+ host: BundleHost
410
+ /** Named per bundle, so a log line says which one wrote it. */
411
+ log(bundleId: string): BundleLog
412
+ }
413
+
414
+ export interface ServerBundles {
415
+ readonly mounted: readonly MountedBundle[]
416
+ /** The prefix every bundle route sits below, including the trailing slash. */
417
+ readonly basePath: string
418
+ /**
419
+ * Answers a request in the bundle namespace. `undefined` means the path belongs
420
+ * to no bundle, so the caller falls through to its own 404.
421
+ */
422
+ handle(request: Request, url: URL, principal: Principal): Promise<Response | undefined>
423
+ }
424
+
425
+ /**
426
+ * Mounts the site's bundles, or fails the boot saying why.
427
+ *
428
+ * Validation is not advisory: a bundle with a bad id would otherwise answer on a
429
+ * path nobody intended, and one asking for a capability the host does not have
430
+ * would fail on its first request rather than at boot.
431
+ */
432
+ export function createServerBundles(options: ServerBundlesOptions): ServerBundles {
433
+ const problems = validateServerBundles(options.bundles)
434
+ if (problems.length > 0)
435
+ throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
436
+
437
+ const basePath = `${options.pluginApiPath}/${BUNDLE_API_SEGMENT}/`
438
+ const byId = new Map(options.bundles.map((bundle) => [bundle.id, bundle]))
439
+ const logs = new Map(options.bundles.map((bundle) => [bundle.id, options.log(bundle.id)]))
440
+
441
+ const mounted: MountedBundle[] = options.bundles.map((bundle) => ({
442
+ id: bundle.id,
443
+ name: bundle.name,
444
+ section: bundle.section,
445
+ capabilities: bundle.capabilities,
446
+ prefix: `${basePath}${bundle.id}`,
447
+ routes: bundle.routes.map((route) => ({ method: route.method, path: route.path })),
448
+ }))
449
+
450
+ /** Only the declared capabilities, frozen, so a handler cannot reach further. */
451
+ const contextFor = (bundle: ServerBundle): Pick<BundleHost, BundleCapability> => {
452
+ const host: Record<string, unknown> = {}
453
+ for (const capability of bundle.capabilities)
454
+ host[capability] =
455
+ capability === 'log' ? (logs.get(bundle.id) as BundleLog) : options.host[capability]
456
+ return Object.freeze(host) as Pick<BundleHost, BundleCapability>
457
+ }
458
+
459
+ return {
460
+ mounted,
461
+ basePath,
462
+ async handle(request, url, principal) {
463
+ const pathname = url.pathname
464
+ if (!pathname.startsWith(basePath)) return undefined
465
+ const rest = pathname.slice(basePath.length)
466
+ const slash = rest.indexOf('/')
467
+ const id = slash === -1 ? rest : rest.slice(0, slash)
468
+ const bundle = byId.get(id)
469
+ if (!bundle) return undefined
470
+
471
+ // Authorisation is the host's, not the bundle's: every route is gated on
472
+ // the declared section, and a bundle has no way to opt a route out.
473
+ if (!hasSection(principal, bundle.section)) return new Response('Forbidden', { status: 403 })
474
+
475
+ const requested = (slash === -1 ? '' : rest.slice(slash + 1)).replace(/\/$/, '')
476
+ const method = request.method.toUpperCase()
477
+ let pathExists = false
478
+ for (const route of bundle.routes) {
479
+ const params = matchRoute(route.path, requested)
480
+ if (!params) continue
481
+ pathExists = true
482
+ if (route.method !== method) continue
483
+ try {
484
+ return await route.handler({
485
+ request,
486
+ url,
487
+ principal,
488
+ params,
489
+ host: contextFor(bundle),
490
+ })
491
+ } catch (error) {
492
+ // A bundle's mistake is its own 500, named, rather than an unhandled
493
+ // rejection in the request path.
494
+ logs.get(bundle.id)?.error('The bundle {bundle} failed to answer {method} {path}', {
495
+ bundle: bundle.name,
496
+ method,
497
+ path: pathname,
498
+ error,
499
+ })
500
+ return new Response('Internal Server Error', { status: 500 })
501
+ }
502
+ }
503
+ return new Response(pathExists ? 'Method Not Allowed' : 'Not Found', {
504
+ status: pathExists ? 405 : 404,
505
+ })
506
+ },
507
+ }
508
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The components the backoffice offers to start from — Umbraco's snippets, with
3
+ * its ids, written as TSX. One is rendered the way any component is:
4
+ * `<Breadcrumb model={model} nav={nav} />`. `Empty` is also the skeleton a new
5
+ * component starts with.
6
+ */
7
+ import type { Snippet } from '@crombolo/api-management'
8
+
9
+ const IMPORTS = "import type { PageProps } from '@crombolo/crombolo'\n\n"
10
+
11
+ export const COMPONENT_SNIPPETS: readonly Snippet[] = [
12
+ {
13
+ id: 'Empty',
14
+ name: 'Empty',
15
+ content: `${IMPORTS}export default function Partial({ model }: Pick<PageProps, 'model'>) {
16
+ return <div>{model.name}</div>
17
+ }
18
+ `,
19
+ },
20
+ {
21
+ id: 'Breadcrumb',
22
+ name: 'Breadcrumb',
23
+ content: `${IMPORTS}export default function Breadcrumb({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
24
+ const trail = [...nav.ancestors(model)].reverse()
25
+ return (
26
+ <ul class="breadcrumb">
27
+ {trail.map((page) => (
28
+ <li>
29
+ <a href={page.url}>{page.name}</a>
30
+ </li>
31
+ ))}
32
+ <li class="active">{model.name}</li>
33
+ </ul>
34
+ )
35
+ }
36
+ `,
37
+ },
38
+ {
39
+ id: 'ListAncestorsFromCurrentPage',
40
+ name: 'List Ancestors From Current Page',
41
+ content: `${IMPORTS}export default function Ancestors({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
42
+ return (
43
+ <ul>
44
+ {nav.ancestors(model).map((page) => (
45
+ <li>
46
+ <a href={page.url}>{page.name}</a>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )
51
+ }
52
+ `,
53
+ },
54
+ {
55
+ id: 'ListChildPagesFromCurrentPage',
56
+ name: 'List Child Pages From Current Page',
57
+ content: `${IMPORTS}export default function ChildPages({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
58
+ return (
59
+ <ul>
60
+ {nav.children(model).map((page) => (
61
+ <li>
62
+ <a href={page.url}>{page.name}</a>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ )
67
+ }
68
+ `,
69
+ },
70
+ {
71
+ id: 'ListChildPagesOrderedByDate',
72
+ name: 'List Child Pages Ordered By Date',
73
+ content: `${IMPORTS}export default function ChildPagesByDate({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
74
+ const pages = [...nav.children(model)].sort(
75
+ (a, b) => b.createDate.getTime() - a.createDate.getTime(),
76
+ )
77
+ return (
78
+ <ul>
79
+ {pages.map((page) => (
80
+ <li>
81
+ <a href={page.url}>{page.name}</a>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ )
86
+ }
87
+ `,
88
+ },
89
+ {
90
+ id: 'ListChildPagesOrderedByName',
91
+ name: 'List Child Pages Ordered By Name',
92
+ content: `${IMPORTS}export default function ChildPagesByName({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
93
+ const pages = [...nav.children(model)].sort((a, b) => a.name.localeCompare(b.name))
94
+ return (
95
+ <ul>
96
+ {pages.map((page) => (
97
+ <li>
98
+ <a href={page.url}>{page.name}</a>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+ )
103
+ }
104
+ `,
105
+ },
106
+ {
107
+ id: 'ListDescendantsFromCurrentPage',
108
+ name: 'List Descendants From Current Page',
109
+ content: `import type { PageProps, PublishedContent } from '@crombolo/crombolo'
110
+
111
+ export default function Descendants({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
112
+ const branch = (page: PublishedContent) => (
113
+ <ul>
114
+ {nav.children(page).map((child) => (
115
+ <li>
116
+ <a href={child.url}>{child.name}</a>
117
+ {nav.children(child).length > 0 ? branch(child) : null}
118
+ </li>
119
+ ))}
120
+ </ul>
121
+ )
122
+ return branch(model)
123
+ }
124
+ `,
125
+ },
126
+ {
127
+ id: 'Navigation',
128
+ name: 'Navigation',
129
+ content: `${IMPORTS}export default function Navigation({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
130
+ const site = nav.ancestors(model).at(-1) ?? model
131
+ return (
132
+ <ul class="nav">
133
+ {nav.children(site).map((page) => (
134
+ <li class={page.id === model.id ? 'current' : undefined}>
135
+ <a href={page.url}>{page.name}</a>
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ )
140
+ }
141
+ `,
142
+ },
143
+ {
144
+ id: 'SiteMap',
145
+ name: 'Site Map',
146
+ content: `import type { PageProps, PublishedContent } from '@crombolo/crombolo'
147
+
148
+ export default function SiteMap({ nav }: Pick<PageProps, 'nav'>) {
149
+ const branch = (pages: PublishedContent[]) => (
150
+ <ul>
151
+ {pages.map((page) => (
152
+ <li>
153
+ <a href={page.url}>{page.name}</a>
154
+ {nav.children(page).length > 0 ? branch(nav.children(page)) : null}
155
+ </li>
156
+ ))}
157
+ </ul>
158
+ )
159
+ return <div class="sitemap">{branch(nav.root())}</div>
160
+ }
161
+ `,
162
+ },
163
+ ]