@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- 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
|
+
}
|