@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/src/router.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Contract-first router: every route comes from the vendored OpenAPI document,
3
+ * so a path that is not in the contract cannot be served, and an operation in
4
+ * the contract without a handler answers 501 rather than 404.
5
+ */
6
+ import { type HttpMethod, listOperations, loadSpec, type OperationInfo } from '@crombolo/contracts'
7
+ import {
8
+ applyNotifications,
9
+ type EventMessage,
10
+ type GroupGrant,
11
+ notFound,
12
+ notImplemented,
13
+ problemResponse,
14
+ type StartNodes,
15
+ unauthorized,
16
+ } from '@crombolo/core'
17
+ import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
18
+ import { type ServerEvent, serverEventFor } from './server-events.ts'
19
+
20
+ /**
21
+ * Resolved back-office identity, assembled from a reference token plus the
22
+ * user's group membership. Everything needed to authorise a request is here, so
23
+ * a handler never queries the identity tables itself.
24
+ */
25
+ export interface Principal {
26
+ /** The user's key (a uuid), which is what the API exposes as its id. */
27
+ id: string
28
+ userName: string
29
+ name: string
30
+ email: string
31
+ isAdmin: boolean
32
+ languageIsoCode: string | undefined
33
+ avatarUrls: string[]
34
+ /** Section manifest aliases, e.g. `Umb.Section.Content`. */
35
+ allowedSections: string[]
36
+ /** Permission verbs granted globally by the user's groups. */
37
+ permissions: string[]
38
+ groupKeys: string[]
39
+ hasAccessToAllLanguages: boolean
40
+ /** Content languages the user may work in; everything when `hasAccessToAllLanguages`. */
41
+ languages: string[]
42
+ /** Where the user may work in each tree, groups' and own start nodes combined. */
43
+ startNodes: { document: StartNodes; media: StartNodes; element: StartNodes }
44
+ /** Each group's verbs, for per-node permission checks. */
45
+ groups: GroupGrant[]
46
+ }
47
+
48
+ export interface RequestContext {
49
+ request: Request
50
+ url: URL
51
+ /** Path parameters, keyed by the name used in the contract. */
52
+ params: Readonly<Record<string, string>>
53
+ operation: OperationInfo
54
+ /** Messages surfaced to the editor through the Umb-Notifications header. */
55
+ notifications: EventMessage[]
56
+ /**
57
+ * Present only when the request authenticated. The router already rejected
58
+ * unauthenticated requests to secured operations, so a handler for a secured
59
+ * operation can rely on this being set.
60
+ */
61
+ principal: Principal | undefined
62
+ }
63
+
64
+ export type Authenticator = (request: Request) => Promise<Principal | undefined>
65
+
66
+ export interface RouterOptions {
67
+ operations?: readonly OperationInfo[]
68
+ /** Resolves the caller's identity. Omitted means every request is anonymous. */
69
+ authenticate?: Authenticator
70
+ /** Told about every request that reached an operation with no handler. */
71
+ onNotImplemented?: (operation: OperationInfo, request: Request) => void
72
+ /** Told about every change a handler made, before its response is sent. */
73
+ onServerEvent?: (event: ServerEvent) => void
74
+ /** Node positions for per-node permission checks; without it only section access is enforced. */
75
+ nodes?: NodeLookup
76
+ /** Whether users may sign in with a password, which opens the password-reset endpoints to everyone. */
77
+ allowLocalLogin?: boolean
78
+ }
79
+
80
+ export type OperationHandler = (ctx: RequestContext) => Response | Promise<Response>
81
+
82
+ interface CompiledRoute {
83
+ operation: OperationInfo
84
+ segments: string[]
85
+ paramNames: (string | undefined)[]
86
+ staticCount: number
87
+ }
88
+
89
+ export class ManagementApiRouter {
90
+ readonly operations: readonly OperationInfo[]
91
+ readonly #handlers = new Map<string, OperationHandler>()
92
+ readonly #byMethodAndLength = new Map<string, CompiledRoute[]>()
93
+ readonly #authenticate: Authenticator | undefined
94
+ readonly #onNotImplemented: RouterOptions['onNotImplemented']
95
+ readonly #onServerEvent: RouterOptions['onServerEvent']
96
+ readonly #nodes: NodeLookup | undefined
97
+ readonly #allowLocalLogin: boolean
98
+
99
+ constructor(options: RouterOptions = {}) {
100
+ const operations = options.operations ?? listOperations(loadSpec())
101
+ this.operations = operations
102
+ this.#authenticate = options.authenticate
103
+ this.#onNotImplemented = options.onNotImplemented
104
+ this.#onServerEvent = options.onServerEvent
105
+ this.#nodes = options.nodes
106
+ this.#allowLocalLogin = options.allowLocalLogin ?? true
107
+ for (const operation of operations) {
108
+ const segments = operation.path.split('/').filter((s) => s.length > 0)
109
+ const paramNames = segments.map((s) =>
110
+ s.startsWith('{') && s.endsWith('}') ? s.slice(1, -1) : undefined,
111
+ )
112
+ const route: CompiledRoute = {
113
+ operation,
114
+ segments,
115
+ paramNames,
116
+ staticCount: paramNames.filter((p) => p === undefined).length,
117
+ }
118
+ const key = `${operation.method}:${segments.length}`
119
+ const bucket = this.#byMethodAndLength.get(key)
120
+ if (bucket) bucket.push(route)
121
+ else this.#byMethodAndLength.set(key, [route])
122
+ }
123
+ // Prefer the most literal route when several shapes match.
124
+ for (const bucket of this.#byMethodAndLength.values()) {
125
+ bucket.sort((a, b) => b.staticCount - a.staticCount)
126
+ }
127
+ }
128
+
129
+ /** Register the implementation of a contract operation. */
130
+ handle(operationId: string, handler: OperationHandler): this {
131
+ if (!this.operations.some((o) => o.operationId === operationId)) {
132
+ throw new Error(`Unknown operationId '${operationId}' — it is not in the contract.`)
133
+ }
134
+ this.#handlers.set(operationId, handler)
135
+ return this
136
+ }
137
+
138
+ has(operationId: string): boolean {
139
+ return this.#handlers.has(operationId)
140
+ }
141
+
142
+ get implementedCount(): number {
143
+ return this.#handlers.size
144
+ }
145
+
146
+ match(
147
+ method: string,
148
+ pathname: string,
149
+ ): { operation: OperationInfo; params: Record<string, string> } | undefined {
150
+ const segments = pathname.split('/').filter((s) => s.length > 0)
151
+ const bucket = this.#byMethodAndLength.get(
152
+ `${method.toLowerCase() as HttpMethod}:${segments.length}`,
153
+ )
154
+ if (!bucket) return undefined
155
+ for (const route of bucket) {
156
+ const params: Record<string, string> = {}
157
+ let matched = true
158
+ for (let i = 0; i < segments.length; i++) {
159
+ const name = route.paramNames[i]
160
+ const segment = segments[i] as string
161
+ if (name === undefined) {
162
+ if (route.segments[i] !== segment) {
163
+ matched = false
164
+ break
165
+ }
166
+ } else {
167
+ params[name] = decodeURIComponent(segment)
168
+ }
169
+ }
170
+ if (matched) return { operation: route.operation, params }
171
+ }
172
+ return undefined
173
+ }
174
+
175
+ async dispatch(request: Request): Promise<Response> {
176
+ const url = new URL(request.url)
177
+ const matched = this.match(request.method, url.pathname)
178
+ if (!matched) {
179
+ return problemResponse(
180
+ notFound(
181
+ 'Not Found',
182
+ `No operation in the contract matches ${request.method} ${url.pathname}`,
183
+ ),
184
+ )
185
+ }
186
+ const { operation, params } = matched
187
+ const notifications: EventMessage[] = []
188
+
189
+ // Authorisation is driven by the contract: the document requires a
190
+ // back-office user globally, and 10 operations opt out with `security: []`.
191
+ const principal = await this.#authenticate?.(request)
192
+ const anonymous =
193
+ operation.anonymous ||
194
+ (this.#allowLocalLogin && LOCAL_LOGIN_OPERATIONS.has(operation.operationId))
195
+ if (!anonymous && !principal) {
196
+ return problemResponse(unauthorized())
197
+ }
198
+ // Umbraco answers a failed authorization policy with a bare 403.
199
+ if (principal && !(await authorize(operation, params, request, principal, this.#nodes)))
200
+ return new Response(null, { status: 403, headers: { 'cache-control': 'no-store' } })
201
+
202
+ const handler = this.#handlers.get(operation.operationId)
203
+ if (!handler) this.#onNotImplemented?.(operation, request)
204
+ const response = handler
205
+ ? await handler({ request, url, params, operation, notifications, principal })
206
+ : problemResponse(notImplemented(operation.operationId))
207
+ if (handler && this.#onServerEvent) {
208
+ const event = serverEventFor(operation, params, response)
209
+ if (event) this.#onServerEvent(event)
210
+ }
211
+ const headers = new Headers(response.headers)
212
+ applyNotifications(headers, request.method, notifications)
213
+ headers.set('cache-control', 'no-store')
214
+ return new Response(response.body, { status: response.status, headers })
215
+ }
216
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Composition root for the Management API. Handlers are registered here as they
3
+ * are implemented; every other operation in the contract answers 501.
4
+ */
5
+ import { registerContentTypeHandlers } from './handlers/content-type.ts'
6
+ import { registerDataTypeHandlers } from './handlers/data-type.ts'
7
+ import { registerDictionaryHandlers } from './handlers/dictionary.ts'
8
+ import { registerDocumentHandlers } from './handlers/document.ts'
9
+ import { registerDocumentBlueprintHandlers } from './handlers/document-blueprint.ts'
10
+ import { registerEditorServiceHandlers } from './handlers/editor-services.ts'
11
+ import { registerElementHandlers } from './handlers/element.ts'
12
+ import { registerFileSystemHandlers } from './handlers/file-systems.ts'
13
+ import { registerImportHandlers } from './handlers/import.ts'
14
+ import { registerLocalizationHandlers } from './handlers/localization.ts'
15
+ import { registerLogViewerHandlers } from './handlers/log-viewer.ts'
16
+ import { registerManifestHandlers } from './handlers/manifest.ts'
17
+ import { registerMediaHandlers } from './handlers/media.ts'
18
+ import { registerMemberHandlers } from './handlers/member.ts'
19
+ import { registerMemberGroupHandlers } from './handlers/member-group.ts'
20
+ import { registerModelsBuilderHandlers } from './handlers/models-builder.ts'
21
+ import { registerPackageHandlers } from './handlers/package.ts'
22
+ import { registerPublicAccessHandlers } from './handlers/public-access.ts'
23
+ import { registerPublishedCacheHandlers } from './handlers/published-cache.ts'
24
+ import { registerRedirectHandlers } from './handlers/redirects.ts'
25
+ import { registerReferenceHandlers } from './handlers/references.ts'
26
+ import { registerServerHandlers } from './handlers/server.ts'
27
+ import { registerSettingsStubHandlers } from './handlers/settings-stubs.ts'
28
+ import { registerTemplateHandlers } from './handlers/template.ts'
29
+ import { registerTemplateQueryHandlers } from './handlers/template-query.ts'
30
+ import { registerTemporaryFileHandlers } from './handlers/temporary-file.ts'
31
+ import { registerUserHandlers } from './handlers/user.ts'
32
+ import type { ManagementApiDeps } from './ports.ts'
33
+ import { type Authenticator, ManagementApiRouter, type RouterOptions } from './router.ts'
34
+
35
+ export interface ManagementApiOptions {
36
+ deps?: ManagementApiDeps
37
+ authenticate?: Authenticator
38
+ onNotImplemented?: RouterOptions['onNotImplemented']
39
+ onServerEvent?: RouterOptions['onServerEvent']
40
+ nodes?: RouterOptions['nodes']
41
+ allowLocalLogin?: boolean
42
+ }
43
+
44
+ export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
45
+ const router = new ManagementApiRouter({
46
+ authenticate: options.authenticate,
47
+ onNotImplemented: options.onNotImplemented,
48
+ onServerEvent: options.onServerEvent,
49
+ nodes: options.nodes,
50
+ allowLocalLogin: options.allowLocalLogin,
51
+ })
52
+ const { deps } = options
53
+ if (!deps) return router
54
+
55
+ registerServerHandlers(router, deps.server)
56
+ registerManifestHandlers(router, deps.manifests)
57
+ registerLocalizationHandlers(router, deps.localization)
58
+ registerUserHandlers(router, deps.currentUser, deps.users, {
59
+ users: deps.userManagement,
60
+ groups: deps.userGroups,
61
+ userData: deps.userData,
62
+ })
63
+ registerSettingsStubHandlers(router)
64
+ if (deps.contentTypes)
65
+ registerContentTypeHandlers(router, deps.contentTypes, 'document', deps.temporaryFiles)
66
+ if (deps.mediaTypes)
67
+ registerContentTypeHandlers(router, deps.mediaTypes, 'media', deps.temporaryFiles)
68
+ if (deps.memberTypes)
69
+ registerContentTypeHandlers(router, deps.memberTypes, 'member', deps.temporaryFiles)
70
+ if (deps.dataTypes) registerDataTypeHandlers(router, deps.dataTypes)
71
+ if (deps.templates) registerTemplateHandlers(router, deps.templates)
72
+ if (deps.templateQuery) registerTemplateQueryHandlers(router, deps.templateQuery)
73
+ if (deps.documents) registerDocumentHandlers(router, deps.documents, deps.preview)
74
+ if (deps.blueprints) registerDocumentBlueprintHandlers(router, deps.blueprints)
75
+ if (deps.temporaryFiles) registerTemporaryFileHandlers(router, deps.temporaryFiles)
76
+ if (deps.media) registerMediaHandlers(router, deps.media)
77
+ if (deps.dictionary) registerDictionaryHandlers(router, deps.dictionary)
78
+ // The components tree is served through the PartialView file API: it is the
79
+ // path-addressed one, so it is the only one that can express a folder.
80
+ if (deps.components)
81
+ registerFileSystemHandlers(router, 'PartialView', deps.components, deps.componentSnippets)
82
+ if (deps.stylesheets) registerFileSystemHandlers(router, 'Stylesheet', deps.stylesheets)
83
+ if (deps.scripts) registerFileSystemHandlers(router, 'Script', deps.scripts)
84
+ if (deps.logViewer) registerLogViewerHandlers(router, deps.logViewer)
85
+ if (deps.modelsBuilder) registerModelsBuilderHandlers(router, deps.modelsBuilder)
86
+ if (deps.publishedCache) registerPublishedCacheHandlers(router, deps.publishedCache)
87
+ if (deps.temporaryFiles) registerImportHandlers(router, deps.temporaryFiles)
88
+ if (deps.memberGroups) registerMemberGroupHandlers(router, deps.memberGroups)
89
+ if (deps.members) registerMemberHandlers(router, deps.members)
90
+ if (deps.publicAccess) registerPublicAccessHandlers(router, deps.publicAccess)
91
+ if (deps.elements) registerElementHandlers(router, deps.elements, deps.references)
92
+ if (deps.redirects) registerRedirectHandlers(router, deps.redirects)
93
+ if (deps.packages) registerPackageHandlers(router, deps.packages)
94
+ if (deps.references)
95
+ for (const area of ['Document', 'Media', 'Member', 'Element'] as const)
96
+ registerReferenceHandlers(router, area, deps.references)
97
+ registerEditorServiceHandlers(router, { tags: deps.tags, oembed: deps.oembed })
98
+ return router
99
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The server events a successful change announces, as Umbraco's notification
3
+ * handlers do. The backoffice caches entity details while connected to the
4
+ * event hub and drops a cached entity only when an event names it, so a change
5
+ * that announces nothing leaves every open editor, including the one that made
6
+ * it, showing the old copy.
7
+ */
8
+ import type { OperationInfo } from '@crombolo/contracts'
9
+
10
+ export type ServerEventType = 'Created' | 'Updated' | 'Deleted' | 'Trashed'
11
+
12
+ export interface ServerEvent {
13
+ eventSource: string
14
+ eventType: ServerEventType
15
+ key: string
16
+ }
17
+
18
+ /** Operation-name entity prefixes, longest first so `DocumentType` wins over `Document`. */
19
+ const ENTITIES = [
20
+ 'DocumentBlueprint',
21
+ 'DocumentType',
22
+ 'Document',
23
+ 'MediaType',
24
+ 'Media',
25
+ 'MemberGroup',
26
+ 'MemberType',
27
+ 'Member',
28
+ 'DataType',
29
+ 'Language',
30
+ 'Script',
31
+ 'Stylesheet',
32
+ 'Template',
33
+ 'DictionaryItem',
34
+ 'PartialView',
35
+ 'UserGroup',
36
+ 'User',
37
+ 'Webhook',
38
+ ] as const
39
+
40
+ /** Operations on an entity that change nothing about it, or are about something else. */
41
+ const NOT_A_CHANGE = /^(Folder|Validate|ByIdValidate|Item|Tree|Filter|Search|Current|Configuration)/
42
+
43
+ export function serverEventFor(
44
+ operation: OperationInfo,
45
+ params: Readonly<Record<string, string>>,
46
+ response: Response,
47
+ ): ServerEvent | undefined {
48
+ if (response.status < 200 || response.status >= 300) return undefined
49
+ const verb = /^(Post|Put|Delete)(.+)$/.exec(operation.operationId)
50
+ if (!verb) return undefined
51
+ const [, method, verbRest] = verb as unknown as [string, string, string]
52
+ // `DeleteRecycleBinMediaById` deletes a media item; `…ByIdRestore` updates one.
53
+ const rest = verbRest.startsWith('RecycleBin') ? verbRest.slice('RecycleBin'.length) : verbRest
54
+ const entity = ENTITIES.find((name) => rest.startsWith(name))
55
+ if (!entity) return undefined
56
+ const after = rest.slice(entity.length)
57
+ if (NOT_A_CHANGE.test(after)) return undefined
58
+
59
+ const generated = response.headers.get('umb-generated-resource')
60
+ const key = generated ?? params.id ?? params.isoCode ?? params.path
61
+ if (!key) return undefined
62
+ const eventType: ServerEventType =
63
+ method === 'Delete'
64
+ ? 'Deleted'
65
+ : after.endsWith('MoveToRecycleBin')
66
+ ? 'Trashed'
67
+ : generated
68
+ ? 'Created'
69
+ : 'Updated'
70
+ return { eventSource: `Umbraco:CMS:${entity}`, eventType, key }
71
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.