@crombolo/server 0.0.0-stage → 0.10.0

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 (85) 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 +507 -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/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
package/src/bundles.ts ADDED
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Server-side bundle extensions: a bundle that has to answer a request, not just
3
+ * render a screen.
4
+ *
5
+ * `docs/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
+ * - Every wired bundle belongs to an npm package the site declares, whose
32
+ * `crombolo` manifest names the same id and declares a server half. Its name,
33
+ * version and peer range come from there, and a peer range that excludes this
34
+ * release refuses the boot.
35
+ *
36
+ * - A bundle that declares `tables` owns them: its own ledgered chain, written
37
+ * with the declarative builder in `@crombolo/data`, and a `tables`
38
+ * capability confined to them (`docs/bundles.md`, Part 4).
39
+ *
40
+ * Deliberately not offered: raw SQL, a `Db`, and anything that writes the
41
+ * site's files.
42
+ */
43
+ import { hasCultureAccess, hasSection, type Principal } from '@crombolo/api-management'
44
+ import { BUNDLE_ID, type BundlePackage, peerRangeProblem } from '@crombolo/backoffice-host'
45
+ import {
46
+ type AppAlias,
47
+ type ExtensionSectionAlias,
48
+ isExtensionSectionAlias,
49
+ SECTION_ALIASES,
50
+ } from '@crombolo/core'
51
+ import {
52
+ type BundleTables,
53
+ type BundleTablesCapability,
54
+ bundleTablesCompatibility,
55
+ compileBundleTables,
56
+ type Db,
57
+ type RedirectMatchKind,
58
+ RedirectRepository,
59
+ type RedirectRow,
60
+ type RedirectTargetKind,
61
+ } from '@crombolo/data'
62
+ import { normaliseRedirectPath, type PublishedCache } from '@crombolo/render'
63
+
64
+ /** The segment every bundle's routes hang below, inside the plugin API. */
65
+ export const BUNDLE_API_SEGMENT = 'bundle'
66
+
67
+ /**
68
+ * A route path below the bundle's namespace. Literal segments, optionally one
69
+ * `:name` parameter each, and no leading or trailing slash — so a path cannot
70
+ * traverse, and `''` means the namespace root.
71
+ */
72
+ const ROUTE_PATH =
73
+ /^(?:[a-z0-9][a-z0-9-]*|:[a-z][a-z0-9]*)(?:\/(?:[a-z0-9][a-z0-9-]*|:[a-z][a-z0-9]*))*$/
74
+
75
+ export type BundleMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
76
+
77
+ /** What the host writes to the site's log on the bundle's behalf. */
78
+ export interface BundleLog {
79
+ info(message: string, properties?: Record<string, unknown>): void
80
+ warn(message: string, properties?: Record<string, unknown>): void
81
+ error(message: string, properties?: Record<string, unknown>): void
82
+ }
83
+
84
+ /** A redirect rule as the capability reports it. */
85
+ export interface BundleRedirectRule extends RedirectRow {
86
+ /** Whether this bundle may change or delete it: only a `manual` rule. */
87
+ editable: boolean
88
+ }
89
+
90
+ /** A rule to store. `source` is not a field: the capability sets it to `manual`. */
91
+ export interface BundleRedirectInput {
92
+ matchKind: RedirectMatchKind
93
+ pattern: string
94
+ rootKey?: string | null
95
+ culture?: string | null
96
+ targetKind: RedirectTargetKind
97
+ target: string
98
+ statusCode?: number
99
+ sortOrder?: number
100
+ }
101
+
102
+ export type BundleRedirectSaved =
103
+ | { ok: true; rule: BundleRedirectRule }
104
+ | { ok: false; message: string; reason?: 'notFound' | 'notEditable' }
105
+
106
+ /**
107
+ * Redirect rules an administrator manages.
108
+ *
109
+ * `list` reports every rule in force, because a screen that hid the configured
110
+ * and tracked ones would be lying about what the site does. Writing is confined
111
+ * to `manual`: the capability sets the source itself and refuses a key belonging
112
+ * to any other, so a bundle cannot forge a configured rule, delete one the
113
+ * config file owns, or interfere with the URL tracker.
114
+ */
115
+ export interface BundleRedirects {
116
+ list(options?: { filter?: string; skip?: number; take?: number }): Promise<{
117
+ total: number
118
+ items: BundleRedirectRule[]
119
+ }>
120
+ byKey(key: string): Promise<BundleRedirectRule | undefined>
121
+ save(input: BundleRedirectInput): Promise<BundleRedirectSaved>
122
+ /**
123
+ * Changes an existing rule, pattern included. A rule is identified by what it
124
+ * matches, so editing the pattern is a different row — the host does the
125
+ * delete and the insert rather than leaving a bundle to discover that and
126
+ * leave the original behind.
127
+ */
128
+ replace(key: string, input: BundleRedirectInput): Promise<BundleRedirectSaved>
129
+ remove(key: string): Promise<'deleted' | 'notFound' | 'notEditable'>
130
+ }
131
+
132
+ /** Reading where a document lives now, for a screen that shows a rule's target. */
133
+ export interface BundleDocuments {
134
+ url(key: string, culture?: string | null): Promise<string | undefined>
135
+ }
136
+
137
+ /** Everything core is willing to hand a bundle. A capability is a key of this. */
138
+ export interface BundleHost {
139
+ redirects: BundleRedirects
140
+ documents: BundleDocuments
141
+ log: BundleLog
142
+ /** The bundle's own tables, and only for a bundle that declares some. */
143
+ tables: BundleTablesCapability
144
+ }
145
+
146
+ export type BundleCapability = keyof BundleHost
147
+
148
+ /**
149
+ * The section a bundle gates its routes on: a core section by its application
150
+ * alias, or a section the bundle's own manifests register by its manifest alias.
151
+ * An extension's section needs nothing from the server beyond this — the
152
+ * backoffice shows it to whoever's group was granted it, and the grant is stored
153
+ * as the alias itself.
154
+ */
155
+ export type BundleSection = AppAlias | ExtensionSectionAlias
156
+
157
+ /**
158
+ * Re-exported so a bundle needs one dependency rather than three. `principal`
159
+ * arrives on every `BundleRequest`, and a bundle that lifts a check out into a
160
+ * helper has to be able to name its type; `BundleSection` is what `section` takes.
161
+ */
162
+ export type { AppAlias, ExtensionSectionAlias, Principal }
163
+
164
+ /**
165
+ * The two checks a bundle is likely to want beyond the section the host has
166
+ * already enforced. `hasSection` normalises the alias, which matters because
167
+ * `allowedSections` arrives either bare or as a manifest alias depending on where
168
+ * it was read — comparing the strings by hand is the mistake this avoids.
169
+ *
170
+ * Anything finer — a permission verb, a start node, a per-node check — is read
171
+ * off `principal` directly: `isAdmin`, `permissions`, `groupKeys`, `groups`,
172
+ * `startNodes`, `languages`.
173
+ */
174
+ export { hasCultureAccess, hasSection }
175
+
176
+ /** Every capability name, for validating what a bundle asked for. */
177
+ export const BUNDLE_CAPABILITIES: readonly BundleCapability[] = [
178
+ 'redirects',
179
+ 'documents',
180
+ 'log',
181
+ 'tables',
182
+ ]
183
+
184
+ /** What a handler is handed: the declared capabilities, with `tables` typed from the declaration. */
185
+ export type HostFor<C extends BundleCapability, T extends BundleTables> = Pick<
186
+ BundleHost,
187
+ Exclude<C, 'tables'>
188
+ > &
189
+ ('tables' extends C
190
+ ? { tables: T extends BundleTables<infer S> ? BundleTablesCapability<S> : never }
191
+ : unknown)
192
+
193
+ export interface BundleRequest<
194
+ C extends BundleCapability = BundleCapability,
195
+ T extends BundleTables = BundleTables,
196
+ > {
197
+ request: Request
198
+ url: URL
199
+ /** Already authenticated, and already holding the bundle's declared section. */
200
+ principal: Principal
201
+ /** The `:name` segments of the matched route. */
202
+ params: Record<string, string>
203
+ /** Only what the bundle declared, frozen. */
204
+ host: HostFor<C, T>
205
+ }
206
+
207
+ export interface ServerBundleRoute<
208
+ C extends BundleCapability = BundleCapability,
209
+ T extends BundleTables = BundleTables,
210
+ > {
211
+ method: BundleMethod
212
+ /** Below the bundle's namespace; `''` is the namespace root. */
213
+ path: string
214
+ handler(context: BundleRequest<C, T>): Response | Promise<Response>
215
+ }
216
+
217
+ export interface ServerBundle<
218
+ C extends BundleCapability = BundleCapability,
219
+ T extends BundleTables = BundleTables,
220
+ > {
221
+ /**
222
+ * URL segment and log name: the `crombolo.id` of the package that exports
223
+ * this bundle, which is where its name and version are read from.
224
+ */
225
+ id: string
226
+ /**
227
+ * The section a caller must have access to. Every route is gated on it; there
228
+ * is no per-route override and no public route.
229
+ */
230
+ section: BundleSection
231
+ capabilities: readonly C[]
232
+ routes: readonly ServerBundleRoute<C, T>[]
233
+ /** Tables the bundle owns, from `defineBundleTables`, under its own id. */
234
+ tables?: T
235
+ }
236
+
237
+ /** A bundle as mounted, with the prefix its routes answer below. */
238
+ export interface MountedBundle {
239
+ id: string
240
+ /** From its package's manifest. */
241
+ name: string
242
+ package: { name: string; version: string; peerRange: string }
243
+ section: BundleSection
244
+ capabilities: readonly BundleCapability[]
245
+ /** `<backoffice>/crombolo/api/bundle/<id>`. */
246
+ prefix: string
247
+ routes: readonly { method: BundleMethod; path: string }[]
248
+ }
249
+
250
+ /**
251
+ * Every reason this set of bundles cannot be mounted. Returned rather than
252
+ * thrown so the boot can report all of them at once; `createServerBundles`
253
+ * turns a non-empty list into a failed boot, because a bundle that is half
254
+ * mounted is worse than one that is absent.
255
+ *
256
+ * `packages` are the bundles the site declares as dependencies. A wired bundle
257
+ * must be one of them, so its version and peer range are always known.
258
+ */
259
+ export function validateServerBundles(
260
+ bundles: readonly ServerBundle[],
261
+ packages: readonly BundlePackage[],
262
+ coreVersion: string,
263
+ ): string[] {
264
+ const problems: string[] = []
265
+ const seen = new Set<string>()
266
+ for (const bundle of bundles) {
267
+ const label = bundle.id || '<unnamed>'
268
+ if (!BUNDLE_ID.test(bundle.id))
269
+ problems.push(
270
+ `Bundle "${label}" has the id "${bundle.id}", which is not 2–39 characters of lowercase letters, digits and hyphens starting with a letter.`,
271
+ )
272
+ else if (seen.has(bundle.id)) problems.push(`Two bundles claim the id "${bundle.id}".`)
273
+ else {
274
+ seen.add(bundle.id)
275
+ const owner = packages.find((pkg) => pkg.id === bundle.id)
276
+ if (!owner)
277
+ problems.push(
278
+ `Bundle "${label}" is wired in crombolo.config.ts, but no package the site depends on declares the bundle id "${bundle.id}". Add the package to package.json.`,
279
+ )
280
+ else if (!owner.carries.server)
281
+ problems.push(
282
+ `Bundle "${label}" is wired in crombolo.config.ts, but ${owner.packageName} declares no "server" in its crombolo manifest.`,
283
+ )
284
+ else {
285
+ const peer = peerRangeProblem(owner, coreVersion)
286
+ if (peer) problems.push(`Bundle "${label}" cannot run here: ${peer}.`)
287
+ }
288
+ }
289
+ if (!(bundle.section in SECTION_ALIASES) && !isExtensionSectionAlias(bundle.section))
290
+ problems.push(
291
+ `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.`,
292
+ )
293
+ for (const capability of bundle.capabilities)
294
+ if (!BUNDLE_CAPABILITIES.includes(capability))
295
+ problems.push(
296
+ `Bundle "${label}" asked for the capability "${capability}", which no host offers.`,
297
+ )
298
+ if (bundle.capabilities.includes('tables') && !bundle.tables)
299
+ problems.push(
300
+ `Bundle "${label}" asked for the capability "tables", but declares no tables of its own.`,
301
+ )
302
+ if (bundle.tables) {
303
+ if (bundle.tables.id !== bundle.id)
304
+ problems.push(
305
+ `Bundle "${label}" declares its tables under the id "${bundle.tables.id}"; a bundle's tables are under its own id.`,
306
+ )
307
+ const owner = packages.find((pkg) => pkg.id === bundle.id)
308
+ for (const problem of compileBundleTables(bundle.tables, owner?.version).problems)
309
+ problems.push(`Bundle "${label}" tables: ${problem}.`)
310
+ // Read by an operator's tooling before anything is installed, so it must
311
+ // say what the plan does — derived, not asserted, as core's is.
312
+ if (owner) {
313
+ const derived = bundleTablesCompatibility(bundle.tables, owner.version)
314
+ if (owner.field.migrations !== derived)
315
+ problems.push(
316
+ `Bundle "${label}"'s package.json declares "crombolo.migrations": ${JSON.stringify(owner.field.migrations ?? null)}, but its tables make ${owner.version} "${derived}".`,
317
+ )
318
+ }
319
+ }
320
+ const routes = new Set<string>()
321
+ for (const route of bundle.routes) {
322
+ if (route.path !== '' && !ROUTE_PATH.test(route.path))
323
+ problems.push(
324
+ `Bundle "${label}" declares the route path "${route.path}", which is not a plain path of segments.`,
325
+ )
326
+ const signature = `${route.method} ${route.path}`
327
+ if (routes.has(signature)) problems.push(`Bundle "${label}" declares "${signature}" twice.`)
328
+ routes.add(signature)
329
+ }
330
+ }
331
+ return problems
332
+ }
333
+
334
+ /** `rules/:key` against `rules/abc` — the params, or undefined when it does not match. */
335
+ function matchRoute(path: string, requested: string): Record<string, string> | undefined {
336
+ if (path === '' && requested === '') return {}
337
+ const declared = path.split('/')
338
+ const actual = requested.split('/')
339
+ if (declared.length !== actual.length) return undefined
340
+ const params: Record<string, string> = {}
341
+ for (const [index, segment] of declared.entries()) {
342
+ const value = actual[index] as string
343
+ if (segment.startsWith(':')) {
344
+ if (value === '') return undefined
345
+ params[segment.slice(1)] = decodeURIComponent(value)
346
+ } else if (segment !== value) return undefined
347
+ }
348
+ return params
349
+ }
350
+
351
+ const STATUS_CODES = new Set([301, 302, 307, 308])
352
+ const MATCH_KINDS = new Set<RedirectMatchKind>(['exact', 'prefix', 'regex'])
353
+ const TARGET_KINDS = new Set<RedirectTargetKind>(['document', 'path', 'url'])
354
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
355
+ const ABSOLUTE_URL = /^https?:\/\/\S+$/i
356
+
357
+ /**
358
+ * Checks a rule before it is stored, so one bad row cannot sit in the table
359
+ * breaking route resolution for every request. A regex is compiled here rather
360
+ * than at match time for exactly that reason: the matcher runs on the request
361
+ * path, and a pattern that throws there would 500 the site rather than the
362
+ * screen that saved it.
363
+ */
364
+ export function validateRedirectInput(
365
+ input: BundleRedirectInput,
366
+ ): { ok: true; value: Required<BundleRedirectInput> } | { ok: false; message: string } {
367
+ if (!MATCH_KINDS.has(input.matchKind))
368
+ return { ok: false, message: 'Choose how the URL is matched.' }
369
+ if (!TARGET_KINDS.has(input.targetKind))
370
+ return { ok: false, message: 'Choose what the redirect points at.' }
371
+ const statusCode = input.statusCode ?? 301
372
+ if (!STATUS_CODES.has(statusCode))
373
+ return { ok: false, message: 'The status code must be 301, 302, 307 or 308.' }
374
+
375
+ const pattern = input.pattern?.trim() ?? ''
376
+ if (pattern === '') return { ok: false, message: 'Enter the URL to redirect from.' }
377
+ if (input.matchKind === 'regex') {
378
+ try {
379
+ new RegExp(pattern, 'i')
380
+ } catch {
381
+ return { ok: false, message: 'That is not a valid regular expression.' }
382
+ }
383
+ }
384
+
385
+ const target = input.target?.trim() ?? ''
386
+ if (target === '') return { ok: false, message: 'Enter where the redirect should go.' }
387
+ if (input.targetKind === 'document' && !UUID.test(target))
388
+ return { ok: false, message: 'Pick the page to redirect to.' }
389
+ if (input.targetKind === 'url' && !ABSOLUTE_URL.test(target))
390
+ return { ok: false, message: 'An external target must be a full http:// or https:// URL.' }
391
+
392
+ const normalised = input.matchKind === 'regex' ? pattern : normaliseRedirectPath(pattern)
393
+ const rootKey = input.rootKey?.trim() ? input.rootKey.trim().toLowerCase() : null
394
+ if (rootKey !== null && !UUID.test(rootKey))
395
+ return { ok: false, message: 'The site a rule is scoped to must be a document key.' }
396
+
397
+ // A rule pointing a route at itself would loop the browser; the exact case is
398
+ // cheap to catch and is what a typo produces.
399
+ if (input.targetKind === 'path' && normaliseRedirectPath(target) === normalised)
400
+ return { ok: false, message: 'That rule points the URL at itself.' }
401
+
402
+ return {
403
+ ok: true,
404
+ value: {
405
+ matchKind: input.matchKind,
406
+ pattern: normalised,
407
+ rootKey,
408
+ culture: input.culture?.trim() ? input.culture.trim().toLowerCase() : null,
409
+ targetKind: input.targetKind,
410
+ target: input.targetKind === 'document' ? target.toLowerCase() : target,
411
+ statusCode,
412
+ sortOrder: Number.isFinite(input.sortOrder) ? Number(input.sortOrder) : 0,
413
+ },
414
+ }
415
+ }
416
+
417
+ /** The manual-redirect capability over the repository core already owns. */
418
+ export function createBundleRedirects(options: {
419
+ db: Db
420
+ cache?: PublishedCache
421
+ }): BundleRedirects {
422
+ const redirects = new RedirectRepository(options.db)
423
+ const report = (row: RedirectRow): BundleRedirectRule => ({
424
+ ...row,
425
+ editable: row.source === 'manual',
426
+ })
427
+ // Rules are part of the published cache's snapshot, so a write that does not
428
+ // drop it does not take effect until something else does. The capability does
429
+ // this rather than the bundle, which cannot be relied on to remember.
430
+ const invalidate = () => options.cache?.invalidate()
431
+
432
+ return {
433
+ async list(paging = {}) {
434
+ const result = await redirects.list(paging)
435
+ return { total: result.total, items: result.items.map(report) }
436
+ },
437
+ async byKey(key) {
438
+ const found = await redirects.byKey(key)
439
+ return found ? report(found) : undefined
440
+ },
441
+ async save(input) {
442
+ const checked = validateRedirectInput(input)
443
+ if (!checked.ok) return { ok: false, message: checked.message }
444
+ const stored = await redirects.save({ ...checked.value, source: 'manual' })
445
+ invalidate()
446
+ return { ok: true, rule: report(stored) }
447
+ },
448
+ async replace(key, input) {
449
+ // Validated before anything is deleted, so the ordinary failure — a
450
+ // malformed rule — cannot cost the rule being edited.
451
+ const checked = validateRedirectInput(input)
452
+ if (!checked.ok) return { ok: false, message: checked.message }
453
+ const found = await redirects.byKey(key)
454
+ if (!found)
455
+ return { ok: false, message: 'That redirect no longer exists.', reason: 'notFound' }
456
+ if (found.source !== 'manual')
457
+ return {
458
+ ok: false,
459
+ message:
460
+ found.source === 'config'
461
+ ? "That redirect is declared in the site's configuration, so the file owns it."
462
+ : 'That redirect was recorded by a rename, so it cannot be edited here.',
463
+ reason: 'notEditable',
464
+ }
465
+ await redirects.delete(key)
466
+ const stored = await redirects.save({ ...checked.value, source: 'manual' })
467
+ invalidate()
468
+ return { ok: true, rule: report(stored) }
469
+ },
470
+ async remove(key) {
471
+ const found = await redirects.byKey(key)
472
+ if (!found) return 'notFound'
473
+ // The file owns a configured rule and the tracker owns a tracked one; a
474
+ // bundle deleting either would be undone at the next boot or publish
475
+ // anyway, so it is refused rather than silently reverted.
476
+ if (found.source !== 'manual') return 'notEditable'
477
+ await redirects.delete(key)
478
+ invalidate()
479
+ return 'deleted'
480
+ },
481
+ }
482
+ }
483
+
484
+ export interface ServerBundlesOptions {
485
+ bundles: readonly ServerBundle[]
486
+ /** The bundle packages the site depends on, from discovery. */
487
+ packages: readonly BundlePackage[]
488
+ /** The running release, which every bundle's peer range must admit. */
489
+ coreVersion: string
490
+ /** `<backoffice>/crombolo/api`, from `createBackOfficePaths`. */
491
+ pluginApiPath: string
492
+ host: Omit<BundleHost, 'tables'>
493
+ /** Named per bundle, so a log line says which one wrote it. */
494
+ log(bundleId: string): BundleLog
495
+ /** A bundle's own tables, resolved against its schema alone; absent where none are offered. */
496
+ tables?(bundle: ServerBundle): BundleTablesCapability | undefined
497
+ }
498
+
499
+ export interface ServerBundles {
500
+ readonly mounted: readonly MountedBundle[]
501
+ /** The prefix every bundle route sits below, including the trailing slash. */
502
+ readonly basePath: string
503
+ /**
504
+ * Answers a request in the bundle namespace. `undefined` means the path belongs
505
+ * to no bundle, so the caller falls through to its own 404.
506
+ */
507
+ handle(request: Request, url: URL, principal: Principal): Promise<Response | undefined>
508
+ }
509
+
510
+ /**
511
+ * Mounts the site's bundles, or fails the boot saying why.
512
+ *
513
+ * Validation is not advisory: a bundle with a bad id would otherwise answer on a
514
+ * path nobody intended, and one asking for a capability the host does not have
515
+ * would fail on its first request rather than at boot.
516
+ */
517
+ export function createServerBundles(options: ServerBundlesOptions): ServerBundles {
518
+ const problems = validateServerBundles(options.bundles, options.packages, options.coreVersion)
519
+ if (problems.length > 0)
520
+ throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
521
+
522
+ const basePath = `${options.pluginApiPath}/${BUNDLE_API_SEGMENT}/`
523
+ const byId = new Map(options.bundles.map((bundle) => [bundle.id, bundle]))
524
+ const logs = new Map(options.bundles.map((bundle) => [bundle.id, options.log(bundle.id)]))
525
+
526
+ const owners = new Map(options.packages.map((pkg) => [pkg.id, pkg]))
527
+ const mounted: MountedBundle[] = options.bundles.map((bundle) => {
528
+ const owner = owners.get(bundle.id) as BundlePackage
529
+ return {
530
+ id: bundle.id,
531
+ name: owner.name,
532
+ package: { name: owner.packageName, version: owner.version, peerRange: owner.peerRange },
533
+ section: bundle.section,
534
+ capabilities: bundle.capabilities,
535
+ prefix: `${basePath}${bundle.id}`,
536
+ routes: bundle.routes.map((route) => ({ method: route.method, path: route.path })),
537
+ }
538
+ })
539
+ const names = new Map(mounted.map((bundle) => [bundle.id, bundle.name]))
540
+
541
+ /** Only the declared capabilities, frozen, so a handler cannot reach further. */
542
+ // Built once per bundle, each over that bundle's schema alone.
543
+ const tables = new Map(
544
+ options.bundles
545
+ .filter((bundle) => bundle.capabilities.includes('tables'))
546
+ .map((bundle) => [bundle.id, options.tables?.(bundle)]),
547
+ )
548
+ const contextFor = (bundle: ServerBundle): Pick<BundleHost, BundleCapability> => {
549
+ const host: Record<string, unknown> = {}
550
+ for (const capability of bundle.capabilities)
551
+ host[capability] =
552
+ capability === 'log'
553
+ ? (logs.get(bundle.id) as BundleLog)
554
+ : capability === 'tables'
555
+ ? tables.get(bundle.id)
556
+ : options.host[capability]
557
+ return Object.freeze(host) as Pick<BundleHost, BundleCapability>
558
+ }
559
+
560
+ return {
561
+ mounted,
562
+ basePath,
563
+ async handle(request, url, principal) {
564
+ const pathname = url.pathname
565
+ if (!pathname.startsWith(basePath)) return undefined
566
+ const rest = pathname.slice(basePath.length)
567
+ const slash = rest.indexOf('/')
568
+ const id = slash === -1 ? rest : rest.slice(0, slash)
569
+ const bundle = byId.get(id)
570
+ if (!bundle) return undefined
571
+
572
+ // Authorisation is the host's, not the bundle's: every route is gated on
573
+ // the declared section, and a bundle has no way to opt a route out.
574
+ if (!hasSection(principal, bundle.section)) return new Response('Forbidden', { status: 403 })
575
+
576
+ const requested = (slash === -1 ? '' : rest.slice(slash + 1)).replace(/\/$/, '')
577
+ const method = request.method.toUpperCase()
578
+ let pathExists = false
579
+ for (const route of bundle.routes) {
580
+ const params = matchRoute(route.path, requested)
581
+ if (!params) continue
582
+ pathExists = true
583
+ if (route.method !== method) continue
584
+ try {
585
+ return await route.handler({
586
+ request,
587
+ url,
588
+ principal,
589
+ params,
590
+ host: contextFor(bundle),
591
+ })
592
+ } catch (error) {
593
+ // A bundle's mistake is its own 500, named, rather than an unhandled
594
+ // rejection in the request path.
595
+ logs.get(bundle.id)?.error('The bundle {bundle} failed to answer {method} {path}', {
596
+ bundle: names.get(bundle.id),
597
+ method,
598
+ path: pathname,
599
+ error,
600
+ })
601
+ return new Response('Internal Server Error', { status: 500 })
602
+ }
603
+ }
604
+ return new Response(pathExists ? 'Method Not Allowed' : 'Not Found', {
605
+ status: pathExists ? 405 : 404,
606
+ })
607
+ },
608
+ }
609
+ }