@crombolo/api-management 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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +32 -4
  4. package/src/authorization.ts +453 -0
  5. package/src/handlers/content-type.ts +784 -0
  6. package/src/handlers/data-type.ts +267 -0
  7. package/src/handlers/dictionary.ts +245 -0
  8. package/src/handlers/document-blueprint.ts +193 -0
  9. package/src/handlers/document.ts +961 -0
  10. package/src/handlers/editor-services.ts +59 -0
  11. package/src/handlers/element.ts +492 -0
  12. package/src/handlers/file-systems.ts +207 -0
  13. package/src/handlers/import.ts +46 -0
  14. package/src/handlers/localization.ts +116 -0
  15. package/src/handlers/log-viewer.ts +125 -0
  16. package/src/handlers/manifest.ts +15 -0
  17. package/src/handlers/media.ts +453 -0
  18. package/src/handlers/member-group.ts +87 -0
  19. package/src/handlers/member.ts +189 -0
  20. package/src/handlers/models-builder.ts +29 -0
  21. package/src/handlers/package.ts +189 -0
  22. package/src/handlers/public-access.ts +107 -0
  23. package/src/handlers/published-cache.ts +27 -0
  24. package/src/handlers/redirects.ts +78 -0
  25. package/src/handlers/references.ts +44 -0
  26. package/src/handlers/server.ts +25 -0
  27. package/src/handlers/settings-stubs.ts +76 -0
  28. package/src/handlers/start-node-trees.ts +84 -0
  29. package/src/handlers/template-query.ts +191 -0
  30. package/src/handlers/template.ts +150 -0
  31. package/src/handlers/temporary-file.ts +62 -0
  32. package/src/handlers/user.ts +604 -0
  33. package/src/index.ts +17 -0
  34. package/src/ports-content.ts +564 -0
  35. package/src/ports-files.ts +51 -0
  36. package/src/ports-logs.ts +38 -0
  37. package/src/ports-members.ts +111 -0
  38. package/src/ports-models-builder.ts +26 -0
  39. package/src/ports-packages.ts +58 -0
  40. package/src/ports-users.ts +230 -0
  41. package/src/ports.ts +225 -0
  42. package/src/router.ts +216 -0
  43. package/src/routes.ts +99 -0
  44. package/src/server-events.ts +71 -0
  45. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kineco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,69 @@
1
+ crombolo
2
+ Copyright (c) 2026 Kineco
3
+
4
+ This product includes third-party software. crombolo is an independent project.
5
+ It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
6
+ the Umbraco trademark.
7
+
8
+ There are three places third-party material is redistributed, rather than merely
9
+ depended on:
10
+
11
+ @crombolo/backoffice-dist the built Umbraco backoffice and the browser
12
+ packages bundled into it, produced by
13
+ `bun run vendor:backoffice`
14
+ @crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
15
+ client source tree
16
+ @crombolo/contracts the Umbraco Management API OpenAPI document,
17
+ copied unmodified
18
+
19
+ No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
20
+ favicon and the installer illustration are deliberately not carried in this
21
+ repository: MIT grants copyright permission and says nothing about trademarks, so
22
+ crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
23
+ `@crombolo/backoffice-host`.
24
+
25
+ The Umbraco backoffice
26
+ ----------------------
27
+ @umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
28
+ @umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
29
+
30
+ Static files from the Umbraco client source tree
31
+ ------------------------------------------------
32
+ css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
33
+ — copied unmodified from Umbraco-CMS release-18.2.0 — MIT
34
+ — https://github.com/umbraco/Umbraco-CMS
35
+ Shipped in @crombolo/backoffice-dist under `upstream-static/`.
36
+
37
+ `rte-content.css` itself carries Tiptap's default editor styles, copied from
38
+ tiptap v2.11.7 (MIT); the file records this in its own header.
39
+
40
+ The Umbraco Management API contract
41
+ -----------------------------------
42
+ OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
43
+ (src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
44
+ — https://github.com/umbraco/Umbraco-CMS
45
+ Shipped in @crombolo/contracts, which also ships the TypeScript types generated
46
+ from it.
47
+
48
+ Browser runtime dependencies bundled into the backoffice build
49
+ --------------------------------------------------------------
50
+ @heximal/expressions 0.1.5 — BSD-3-Clause
51
+ @microsoft/signalr 10.0.11 — MIT
52
+ @tiptap/* 3.31.3 — MIT
53
+ diff 9.0.0 — BSD-3-Clause
54
+ dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
55
+ element-internals-polyfill 3.0.2 — MIT
56
+ lit 3.3.3 — BSD-3-Clause
57
+ luxon 3.7.2 — MIT
58
+ marked 18.0.14 — MIT
59
+ monaco-editor 0.57.0 — MIT
60
+ rxjs 7.8.2 — Apache-2.0
61
+ uuid 14.0.2 — MIT
62
+
63
+ dompurify is offered under either licence; Apache-2.0 is elected here, so no
64
+ MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
65
+ Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
66
+ section 4(d) adds nothing to this file.
67
+
68
+ Each package's own licence text ships inside its npm distribution and applies
69
+ unchanged to the copies bundled here.
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@crombolo/api-management",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.1",
4
+ "description": "Umbraco-compatible Management API handlers for crombolo.",
5
+ "license": "MIT",
6
+ "author": "Crombolo",
7
+ "homepage": "https://github.com/kineco-au/crombolo-cms#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kineco-au/crombolo-cms.git",
11
+ "directory": "packages/api-management"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/kineco-au/crombolo-cms/issues"
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.3.0"
18
+ },
19
+ "type": "module",
20
+ "exports": {
21
+ ".": "./src/index.ts"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "NOTICE"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "dependencies": {
31
+ "@crombolo/core": "0.9.1",
32
+ "@crombolo/contracts": "0.9.1"
33
+ }
34
+ }
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Authorization for every Management API call, as Umbraco's policies apply it:
3
+ * first the sections an operation's controller demands (any one of a set, and
4
+ * every set), then, for documents and media, the caller's start nodes and the
5
+ * permission verbs the operation needs on the nodes it names. Users and user
6
+ * groups carry rules about the people involved, which their port enforces.
7
+ * See docs/04-backoffice-hosting.md for the table's provenance.
8
+ */
9
+ import type { OperationInfo } from '@crombolo/contracts'
10
+ import {
11
+ type AppAlias,
12
+ DocumentPermissions as D,
13
+ hasPathAccess,
14
+ permissionsForPath,
15
+ type StartNodes,
16
+ sectionKey,
17
+ } from '@crombolo/core'
18
+ import type { Principal } from './router.ts'
19
+
20
+ /**
21
+ * Operations behind Umbraco's `DenyLocalLoginIfConfigured` policy: the contract
22
+ * marks them secured, but while local login is allowed the policy lets anyone
23
+ * through, since the login page calls them before anyone has signed in.
24
+ */
25
+ export const LOCAL_LOGIN_OPERATIONS: ReadonlySet<string> = new Set([
26
+ 'GetSecurityConfiguration',
27
+ 'PostSecurityForgotPassword',
28
+ 'PostSecurityForgotPasswordReset',
29
+ ])
30
+
31
+ /**
32
+ * The sections a caller holds, normalised the one way.
33
+ *
34
+ * Aliases arrive either bare or as manifest aliases depending on where they were
35
+ * read, so every check goes through this rather than comparing what it was given.
36
+ */
37
+ export const sectionsOf = (principal: Principal): Set<string> =>
38
+ new Set(principal.allowedSections.map(sectionKey))
39
+
40
+ /**
41
+ * Whether a caller holds a section, named in either vocabulary. For the routes
42
+ * outside the contract — the plugin endpoints — which have no operation for
43
+ * `authorize` to look up but must answer to the same rule as the operations they
44
+ * stand next to.
45
+ */
46
+ export const hasSection = (principal: Principal, section: string): boolean =>
47
+ sectionsOf(principal).has(sectionKey(section))
48
+
49
+ /** A node's ancestor keys then its own, root first, and whether it is in the recycle bin. */
50
+ export interface NodeChain {
51
+ chain: string[]
52
+ trashed: boolean
53
+ }
54
+
55
+ /** Where nodes sit, for start-node and per-node permission checks. */
56
+ export interface NodeLookup {
57
+ documents(keys: readonly string[]): Promise<Map<string, NodeChain>>
58
+ media(keys: readonly string[]): Promise<Map<string, NodeChain>>
59
+ /** Every document below `key`. */
60
+ documentDescendants(key: string): Promise<NodeChain[]>
61
+ /** The document a version belongs to. */
62
+ versionDocument(versionId: string): Promise<string | undefined>
63
+ }
64
+
65
+ type Sections = readonly AppAlias[]
66
+
67
+ const CONTENT: Sections = ['content']
68
+ const MEDIA: Sections = ['media']
69
+ const SETTINGS: Sections = ['settings']
70
+ const USERS: Sections = ['users']
71
+ const MEMBERS: Sections = ['members']
72
+ const TRANSLATION: Sections = ['translation']
73
+ const LIBRARY: Sections = ['library']
74
+ const PACKAGES: Sections = ['packages']
75
+ /** SectionAccessFor{Content,Media,Element}Tree: pickers open these trees from anywhere. */
76
+ const ANY_TREE: Sections = [
77
+ 'content',
78
+ 'media',
79
+ 'users',
80
+ 'settings',
81
+ 'packages',
82
+ 'members',
83
+ 'library',
84
+ ]
85
+
86
+ const TREES: Record<string, Sections> = {
87
+ document: ANY_TREE,
88
+ media: ANY_TREE,
89
+ element: ANY_TREE,
90
+ 'document-type': SETTINGS,
91
+ 'document-blueprint': SETTINGS,
92
+ 'data-type': SETTINGS,
93
+ 'media-type': SETTINGS,
94
+ 'member-type': ['settings', 'members'],
95
+ template: ['settings', 'content'],
96
+ dictionary: ['translation', 'settings'],
97
+ 'partial-view': SETTINGS,
98
+ script: SETTINGS,
99
+ stylesheet: SETTINGS,
100
+ 'relation-type': SETTINGS,
101
+ 'static-file': ['settings', 'content', 'media', 'members'],
102
+ 'member-group': MEMBERS,
103
+ }
104
+
105
+ const SETTINGS_AREAS = new Set([
106
+ 'log-viewer',
107
+ 'health-check',
108
+ 'health-check-group',
109
+ 'indexer',
110
+ 'searcher',
111
+ 'models-builder',
112
+ 'profiling',
113
+ 'published-cache',
114
+ 'telemetry',
115
+ 'webhook',
116
+ 'partial-view',
117
+ 'script',
118
+ 'stylesheet',
119
+ 'relation-type',
120
+ ])
121
+
122
+ /** Type actions only the Settings section may take, beyond every write. */
123
+ const TYPE_ADMIN_ACTIONS = new Set([
124
+ 'configuration',
125
+ 'available-compositions',
126
+ 'composition-references',
127
+ 'export',
128
+ 'import',
129
+ 'copy',
130
+ 'move',
131
+ 'template',
132
+ 'folder',
133
+ ])
134
+
135
+ /**
136
+ * The sections an operation demands: every set must be satisfied, each by any
137
+ * one of its sections; the string `admin` requires the admin group. Operations
138
+ * absent here need only a signed-in backoffice user.
139
+ */
140
+ export function sectionRequirements(operation: OperationInfo): Array<Sections | 'admin'> {
141
+ const [area = '', second = '', third = ''] = operation.path
142
+ .replace(/^\/umbraco\/management\/api\/v1\//, '')
143
+ .split('/')
144
+ const write = operation.method !== 'get'
145
+ const action = second.startsWith('{') ? third : second
146
+ const typeAdmin = write || TYPE_ADMIN_ACTIONS.has(action)
147
+ if (SETTINGS_AREAS.has(area)) return [SETTINGS]
148
+ switch (area) {
149
+ case 'tree':
150
+ return TREES[second] ? [TREES[second]] : []
151
+ case 'recycle-bin':
152
+ return [second === 'media' ? MEDIA : second === 'element' ? LIBRARY : CONTENT]
153
+ case 'collection':
154
+ return [second === 'media' ? MEDIA : CONTENT]
155
+ case 'filter':
156
+ if (second === 'data-type') return [['content', 'settings']]
157
+ if (second === 'user' || second === 'user-group') return [USERS]
158
+ if (second === 'member') return [MEMBERS]
159
+ return []
160
+ case 'document':
161
+ case 'document-version':
162
+ case 'redirect-management':
163
+ case 'relation':
164
+ case 'oembed':
165
+ case 'dynamic-root':
166
+ return [CONTENT]
167
+ case 'document-type':
168
+ if (second === 'folder') return [SETTINGS]
169
+ if (third === 'blueprint') return [['content', 'library', 'settings'], CONTENT]
170
+ return typeAdmin
171
+ ? [['content', 'library', 'settings'], SETTINGS]
172
+ : [['content', 'library', 'settings']]
173
+ case 'document-blueprint':
174
+ if (second === 'from-document' || third === 'scaffold') return [CONTENT]
175
+ if (!write && second.startsWith('{') && third === '') return [['content', 'settings']]
176
+ return [SETTINGS]
177
+ case 'data-type':
178
+ if (second === 'folder') return [SETTINGS]
179
+ return typeAdmin
180
+ ? [['content', 'library', 'media', 'members', 'settings'], SETTINGS]
181
+ : [['content', 'library', 'media', 'members', 'settings']]
182
+ case 'media':
183
+ return [MEDIA]
184
+ case 'media-type':
185
+ if (second === 'folder') return [SETTINGS]
186
+ return typeAdmin ? [['media', 'settings'], SETTINGS] : [['media', 'settings']]
187
+ case 'member-type':
188
+ if (second === 'folder') return [SETTINGS]
189
+ return typeAdmin ? [['settings', 'members'], SETTINGS] : [['settings', 'members']]
190
+ case 'member':
191
+ case 'member-group':
192
+ return [MEMBERS]
193
+ case 'template':
194
+ return write ? [['settings', 'content'], SETTINGS] : [['settings', 'content']]
195
+ case 'language':
196
+ return write ? [SETTINGS] : []
197
+ case 'dictionary':
198
+ return [TRANSLATION]
199
+ case 'user':
200
+ return second === 'current' ? [] : [USERS]
201
+ case 'user-group':
202
+ return [USERS]
203
+ case 'element':
204
+ case 'element-version':
205
+ return [LIBRARY]
206
+ case 'package':
207
+ return [PACKAGES]
208
+ case 'upgrade':
209
+ return ['admin']
210
+ case 'server':
211
+ return second === 'upgrade-check' ? ['admin'] : []
212
+ default:
213
+ return []
214
+ }
215
+ }
216
+
217
+ /** Where a rule finds the node it checks: a path or query parameter, a body field, or a fixed place. */
218
+ type NodeRef =
219
+ | { param: string }
220
+ | { query: string; orRoot?: true }
221
+ | { body: readonly string[] }
222
+ | { version: string }
223
+ | 'root'
224
+ | 'bin'
225
+
226
+ interface DocumentRule {
227
+ verbs: readonly string[]
228
+ node: NodeRef
229
+ /** Every descendant too. */
230
+ branch?: boolean
231
+ /** Also check the cultures the body names. */
232
+ cultures?: boolean
233
+ }
234
+
235
+ const id: NodeRef = { param: 'id' }
236
+ const DOCUMENT_RULES: Record<string, readonly DocumentRule[]> = {
237
+ GetDocumentById: [{ verbs: [D.Read], node: id }],
238
+ GetDocumentByIdPublished: [{ verbs: [D.Read], node: id }],
239
+ GetDocumentByIdAuditLog: [{ verbs: [D.Read], node: id }],
240
+ GetDocumentByIdDomains: [{ verbs: [D.Read], node: id }],
241
+ GetDocumentByIdNotifications: [{ verbs: [D.Read], node: id }],
242
+ PutDocumentByIdNotifications: [{ verbs: [D.Read], node: id }],
243
+ GetDocumentByIdAvailableSegmentOptions: [{ verbs: [D.Read], node: id }],
244
+ GetDocumentByIdPreviewUrl: [{ verbs: [D.Read], node: id }],
245
+ GetDocumentVersion: [{ verbs: [D.Read], node: { query: 'documentId' } }],
246
+ // The list view: it answers with the children's own property values, so it is
247
+ // a read of the branch and not merely of the tree, and Umbraco filters it per
248
+ // item (ContentListViewService.FilterAuthorizedKeysAsync). Requiring Read on
249
+ // the parent is the same boundary expressed once: a caller who cannot reach the
250
+ // parent's path cannot reach anything below it either.
251
+ GetCollectionDocumentById: [{ verbs: [D.Read], node: id }],
252
+ PostDocument: [{ verbs: [D.Create], node: { body: ['parent', 'id'] } }],
253
+ PostDocumentValidate: [{ verbs: [D.Create], node: { body: ['parent', 'id'] } }],
254
+ PutDocumentById: [{ verbs: [D.Update], node: id }],
255
+ PutDocumentByIdValidate: [{ verbs: [D.Update], node: id }],
256
+ PostDocumentCreateAndPublish: [
257
+ { verbs: [D.Publish], node: { body: ['parent', 'id'] }, cultures: true },
258
+ ],
259
+ PutDocumentByIdUpdateAndPublish: [{ verbs: [D.Publish], node: id, cultures: true }],
260
+ PutDocumentByIdPublish: [{ verbs: [D.Publish], node: id, cultures: true }],
261
+ PutDocumentByIdPublishWithDescendants: [
262
+ { verbs: [D.Publish], node: id, branch: true, cultures: true },
263
+ ],
264
+ PutDocumentByIdUnpublish: [{ verbs: [D.Unpublish], node: id, cultures: true }],
265
+ DeleteDocumentById: [{ verbs: [D.Delete], node: id }],
266
+ PutDocumentByIdMoveToRecycleBin: [{ verbs: [D.Delete], node: id }],
267
+ PutDocumentByIdMove: [
268
+ { verbs: [D.Move], node: id },
269
+ { verbs: [D.Create], node: { body: ['target', 'id'] } },
270
+ ],
271
+ PostDocumentByIdCopy: [
272
+ { verbs: [D.Duplicate], node: id },
273
+ { verbs: [D.Create], node: { body: ['target', 'id'] } },
274
+ ],
275
+ PutDocumentSort: [
276
+ { verbs: [D.Sort], node: { body: ['parent', 'id'] } },
277
+ { verbs: [D.Sort], node: { body: ['sorting', '*', 'id'] } },
278
+ ],
279
+ PutDocumentByIdSortChildren: [{ verbs: [D.Sort], node: id }],
280
+ PutDocumentRootSortChildren: [{ verbs: [D.Sort], node: 'root' }],
281
+ GetDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
282
+ PostDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
283
+ PutDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
284
+ DeleteDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
285
+ PutDocumentByIdDomains: [{ verbs: [D.CultureAndHostnames], node: id }],
286
+ PostDocumentVersionByIdRollback: [{ verbs: [D.Rollback], node: { version: 'id' } }],
287
+ PostDocumentBlueprintFromDocument: [
288
+ { verbs: [D.CreateBlueprint], node: { body: ['document', 'id'] } },
289
+ ],
290
+ DeleteRecycleBinDocument: [{ verbs: [D.Delete], node: 'bin' }],
291
+ DeleteRecycleBinDocumentById: [{ verbs: [D.Delete], node: 'bin' }],
292
+ PutRecycleBinDocumentByIdRestore: [{ verbs: [D.Move], node: 'bin' }],
293
+ GetRecycleBinDocumentByIdOriginalParent: [{ verbs: [D.Read], node: 'bin' }],
294
+ }
295
+
296
+ /** Media carries no verbs: a rule names the nodes whose path the caller must be able to reach. */
297
+ const MEDIA_RULES: Record<string, readonly NodeRef[]> = {
298
+ GetMediaById: [id],
299
+ // As for documents above. `id` is a query parameter here and omitting it means
300
+ // the root, so the rule says so rather than leaving the absent case unchecked.
301
+ GetCollectionMedia: [{ query: 'id', orRoot: true }],
302
+ PutMediaById: [id],
303
+ DeleteMediaById: [id],
304
+ GetMediaByIdAuditLog: [id],
305
+ PutMediaByIdMove: [id, { body: ['target', 'id'] }],
306
+ PutMediaByIdMoveToRecycleBin: [id],
307
+ PutMediaByIdSortChildren: [id],
308
+ PutMediaByIdValidate: [id],
309
+ PostMedia: [{ body: ['parent', 'id'] }],
310
+ PostMediaValidate: [{ body: ['parent', 'id'] }],
311
+ PutMediaSort: [{ body: ['parent', 'id'] }, { body: ['sorting', '*', 'id'] }],
312
+ PutMediaRootSortChildren: ['root'],
313
+ DeleteRecycleBinMedia: ['bin'],
314
+ DeleteRecycleBinMediaById: ['bin'],
315
+ PutRecycleBinMediaByIdRestore: ['bin'],
316
+ GetRecycleBinMediaByIdOriginalParent: ['bin'],
317
+ }
318
+
319
+ /** Values at `path` in `body`; `*` walks every element of an array. A missing or null step means the root. */
320
+ function pick(body: unknown, path: readonly string[]): Array<string | null> {
321
+ let current: unknown[] = [body]
322
+ for (const step of path) {
323
+ const next: unknown[] = []
324
+ for (const value of current) {
325
+ if (value === null || value === undefined) {
326
+ next.push(null)
327
+ continue
328
+ }
329
+ if (step === '*') {
330
+ if (Array.isArray(value)) next.push(...value)
331
+ } else next.push((value as Record<string, unknown>)[step] ?? null)
332
+ }
333
+ current = next
334
+ }
335
+ return current.map((v) => (typeof v === 'string' ? v : null))
336
+ }
337
+
338
+ function culturesIn(body: unknown): string[] {
339
+ const b = (body ?? {}) as Record<string, unknown>
340
+ const out: string[] = []
341
+ for (const field of ['cultures', 'culturesToPublish'])
342
+ if (Array.isArray(b[field]))
343
+ out.push(...((b[field] as unknown[]).filter((c) => typeof c === 'string') as string[]))
344
+ if (Array.isArray(b.publishSchedules))
345
+ for (const schedule of b.publishSchedules as Array<Record<string, unknown>>)
346
+ if (typeof schedule?.culture === 'string') out.push(schedule.culture)
347
+ return out
348
+ }
349
+
350
+ /** Whether the user may work in every culture named. */
351
+ export function hasCultureAccess(principal: Principal, cultures: readonly string[]): boolean {
352
+ if (principal.hasAccessToAllLanguages) return true
353
+ const allowed = new Set(principal.languages.map((l) => l.toLowerCase()))
354
+ return cultures.every((c) => allowed.has(c.toLowerCase()))
355
+ }
356
+
357
+ /** Root and the recycle bin are reached only with root access, and hold the groups' default verbs. */
358
+ const fixedPlaceAllows = (start: StartNodes, principal: Principal, verbs: readonly string[]) => {
359
+ if (!start.root) return false
360
+ const granted = permissionsForPath(principal.groups, [])
361
+ return verbs.every((v) => granted.has(v))
362
+ }
363
+
364
+ async function resolveKeys(
365
+ ref: NodeRef,
366
+ params: Readonly<Record<string, string>>,
367
+ url: URL,
368
+ body: () => Promise<unknown>,
369
+ nodes: NodeLookup,
370
+ ): Promise<Array<string | 'root' | 'bin'>> {
371
+ if (ref === 'root' || ref === 'bin') return [ref]
372
+ if ('param' in ref) return params[ref.param] ? [params[ref.param] as string] : []
373
+ if ('query' in ref) {
374
+ const value = url.searchParams.get(ref.query)
375
+ if (value) return [value]
376
+ // An absent parameter means the root for an operation that says so, rather
377
+ // than nothing to check — otherwise omitting it is how the check is skipped.
378
+ return ref.orRoot ? ['root'] : []
379
+ }
380
+ if ('version' in ref) {
381
+ const document = params[ref.version]
382
+ ? await nodes.versionDocument(params[ref.version] as string)
383
+ : undefined
384
+ return document ? [document] : []
385
+ }
386
+ return pick(await body(), ref.body).map((key) => key ?? 'root')
387
+ }
388
+
389
+ /** Evaluates the operation's section requirements, then its node rules. */
390
+ export async function authorize(
391
+ operation: OperationInfo,
392
+ params: Readonly<Record<string, string>>,
393
+ request: Request,
394
+ principal: Principal,
395
+ nodes: NodeLookup | undefined,
396
+ ): Promise<boolean> {
397
+ const sections = sectionsOf(principal)
398
+ for (const requirement of sectionRequirements(operation)) {
399
+ if (requirement === 'admin') {
400
+ if (!principal.isAdmin) return false
401
+ } else if (!requirement.some((s) => sections.has(s))) return false
402
+ }
403
+ // Administrators are not exempt: their group holds every verb and the root, as in Umbraco
404
+ if (!nodes) return true
405
+
406
+ const url = new URL(request.url)
407
+ let parsed: Promise<unknown> | undefined
408
+ const body = () => {
409
+ parsed ??= request
410
+ .clone()
411
+ .json()
412
+ .catch(() => undefined)
413
+ return parsed
414
+ }
415
+
416
+ for (const rule of DOCUMENT_RULES[operation.operationId] ?? []) {
417
+ const start = principal.startNodes.document
418
+ const keys = await resolveKeys(rule.node, params, url, body, nodes)
419
+ const real = keys.filter((k): k is string => k !== 'root' && k !== 'bin')
420
+ const found = await nodes.documents(real)
421
+ for (const key of keys) {
422
+ if (key === 'root' || key === 'bin') {
423
+ if (!fixedPlaceAllows(start, principal, rule.verbs)) return false
424
+ continue
425
+ }
426
+ const node = found.get(key.toLowerCase())
427
+ // A missing node is not denied: the handler answers 404
428
+ if (!node) continue
429
+ const targets = rule.branch ? [node, ...(await nodes.documentDescendants(key))] : [node]
430
+ for (const target of targets) {
431
+ if (!hasPathAccess(start, target.chain, target.trashed)) return false
432
+ const granted = permissionsForPath(principal.groups, target.chain)
433
+ if (!rule.verbs.every((v) => granted.has(v))) return false
434
+ }
435
+ }
436
+ if (rule.cultures && !hasCultureAccess(principal, culturesIn(await body()))) return false
437
+ }
438
+
439
+ for (const ref of MEDIA_RULES[operation.operationId] ?? []) {
440
+ const start = principal.startNodes.media
441
+ const keys = await resolveKeys(ref, params, url, body, nodes)
442
+ const found = await nodes.media(keys.filter((k): k is string => k !== 'root' && k !== 'bin'))
443
+ for (const key of keys) {
444
+ if (key === 'root' || key === 'bin') {
445
+ if (!start.root) return false
446
+ continue
447
+ }
448
+ const node = found.get(key.toLowerCase())
449
+ if (node && !hasPathAccess(start, node.chain, node.trashed)) return false
450
+ }
451
+ }
452
+ return true
453
+ }