@crombolo/api-management 0.10.0 → 0.12.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.
package/src/router.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * so a path that is not in the contract cannot be served, and an operation in
4
4
  * the contract without a handler answers 501 rather than 404.
5
5
  */
6
+
6
7
  import { type HttpMethod, listOperations, loadSpec, type OperationInfo } from '@crombolo/contracts'
7
8
  import {
8
9
  applyNotifications,
@@ -14,8 +15,18 @@ import {
14
15
  type StartNodes,
15
16
  unauthorized,
16
17
  } from '@crombolo/core'
18
+ import {
19
+ type AuditPlan,
20
+ type AuditRecord,
21
+ type AuditSink,
22
+ auditDrafts,
23
+ auditNamesFirst,
24
+ auditPlanFor,
25
+ auditReadsBody,
26
+ } from './audit.ts'
17
27
  import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
18
28
  import { type ServerEvent, serverEventFor } from './server-events.ts'
29
+ import { type RaisedWebhookEvent, webhookEventFor } from './webhook-events.ts'
19
30
 
20
31
  /**
21
32
  * Resolved back-office identity, assembled from a reference token plus the
@@ -75,8 +86,28 @@ export interface RouterOptions {
75
86
  nodes?: NodeLookup
76
87
  /** Whether users may sign in with a password, which opens the password-reset endpoints to everyone. */
77
88
  allowLocalLogin?: boolean
89
+ /** Where every successful change is recorded. docs/audit-log.md. */
90
+ audit?: AuditSink
91
+ /** Where a request came from, for its audit entry. */
92
+ clientAddress?: (request: Request) => ClientAddress
93
+ /** Answers an error a handler threw; undefined lets it propagate. */
94
+ onError?: (error: unknown) => Response | undefined
95
+ /** Told about every webhook event a successful operation raises. */
96
+ onWebhookEvent?: (event: RaisedWebhookEvent) => void
78
97
  }
79
98
 
99
+ export interface ClientAddress {
100
+ /** The peer that connected: a proxy, when there is one. */
101
+ ipAddress: string | null
102
+ /** What a proxy said the client was; anyone can send it, so it is kept apart. */
103
+ forwardedFor: string | null
104
+ }
105
+
106
+ const forwardedOnly = (request: Request): ClientAddress => ({
107
+ ipAddress: null,
108
+ forwardedFor: request.headers.get('x-forwarded-for'),
109
+ })
110
+
80
111
  export type OperationHandler = (ctx: RequestContext) => Response | Promise<Response>
81
112
 
82
113
  interface CompiledRoute {
@@ -95,6 +126,10 @@ export class ManagementApiRouter {
95
126
  readonly #onServerEvent: RouterOptions['onServerEvent']
96
127
  readonly #nodes: NodeLookup | undefined
97
128
  readonly #allowLocalLogin: boolean
129
+ readonly #audit: AuditSink | undefined
130
+ readonly #clientAddress: (request: Request) => ClientAddress
131
+ readonly #onError: RouterOptions['onError']
132
+ readonly #onWebhookEvent: RouterOptions['onWebhookEvent']
98
133
 
99
134
  constructor(options: RouterOptions = {}) {
100
135
  const operations = options.operations ?? listOperations(loadSpec())
@@ -104,6 +139,10 @@ export class ManagementApiRouter {
104
139
  this.#onServerEvent = options.onServerEvent
105
140
  this.#nodes = options.nodes
106
141
  this.#allowLocalLogin = options.allowLocalLogin ?? true
142
+ this.#audit = options.audit
143
+ this.#clientAddress = options.clientAddress ?? forwardedOnly
144
+ this.#onError = options.onError
145
+ this.#onWebhookEvent = options.onWebhookEvent
107
146
  for (const operation of operations) {
108
147
  const segments = operation.path.split('/').filter((s) => s.length > 0)
109
148
  const paramNames = segments.map((s) =>
@@ -201,16 +240,106 @@ export class ManagementApiRouter {
201
240
 
202
241
  const handler = this.#handlers.get(operation.operationId)
203
242
  if (!handler) this.#onNotImplemented?.(operation, request)
243
+ const plan = handler && this.#audit ? auditPlanFor(operation.operationId) : undefined
244
+ const audit = plan ? await this.#beforeAudit(plan, params, request) : undefined
204
245
  const response = handler
205
- ? await handler({ request, url, params, operation, notifications, principal })
246
+ ? await this.#run(handler, { request, url, params, operation, notifications, principal })
206
247
  : problemResponse(notImplemented(operation.operationId))
248
+ if (plan && audit && response.status >= 200 && response.status < 300)
249
+ await this.#recordAudit(plan, audit, params, request, response, principal)
207
250
  if (handler && this.#onServerEvent) {
208
251
  const event = serverEventFor(operation, params, response)
209
252
  if (event) this.#onServerEvent(event)
210
253
  }
254
+ if (handler && this.#onWebhookEvent) {
255
+ const event = webhookEventFor(operation.operationId, params, response)
256
+ if (event) this.#onWebhookEvent(event)
257
+ }
211
258
  const headers = new Headers(response.headers)
212
259
  applyNotifications(headers, request.method, notifications)
213
260
  headers.set('cache-control', 'no-store')
214
261
  return new Response(response.body, { status: response.status, headers })
215
262
  }
263
+
264
+ async #run(handler: OperationHandler, ctx: RequestContext): Promise<Response> {
265
+ try {
266
+ return await handler(ctx)
267
+ } catch (error) {
268
+ const answered = this.#onError?.(error)
269
+ if (answered) return answered
270
+ throw error
271
+ }
272
+ }
273
+
274
+ /** Reads what the entry needs before the handler consumes the body or removes the item. */
275
+ async #beforeAudit(
276
+ plan: AuditPlan,
277
+ params: Readonly<Record<string, string>>,
278
+ request: Request,
279
+ ): Promise<AuditState> {
280
+ const body = auditReadsBody(plan)
281
+ ? await request
282
+ .clone()
283
+ .json()
284
+ .catch(() => undefined)
285
+ : undefined
286
+ const named = new Map<string, Subject>()
287
+ if (auditNamesFirst(plan) && this.#audit)
288
+ for (const draft of auditDrafts(plan, params, new Response(), body))
289
+ if (draft.entityKey)
290
+ named.set(draft.entityKey, await this.#audit.describe(draft.entityType, draft.entityKey))
291
+ return { body, named }
292
+ }
293
+
294
+ async #recordAudit(
295
+ plan: AuditPlan,
296
+ state: AuditState,
297
+ params: Readonly<Record<string, string>>,
298
+ request: Request,
299
+ response: Response,
300
+ principal: Principal | undefined,
301
+ ): Promise<void> {
302
+ const sink = this.#audit
303
+ if (!sink) return
304
+ const address = this.#clientAddress(request)
305
+ let detail: string | null = null
306
+ if (plan.related) {
307
+ const names: string[] = []
308
+ for (const key of plan.related.keys(state.body))
309
+ names.push((await sink.describe(plan.related.entityType, key)).entityName ?? key)
310
+ detail = `${plan.related.label}: ${names.length > 0 ? names.join(', ') : 'none'}`
311
+ }
312
+ const records: AuditRecord[] = []
313
+ for (const draft of auditDrafts(plan, params, response, state.body)) {
314
+ const key = draft.entityKey ?? (plan.entityType === 'User' ? (principal?.id ?? null) : null)
315
+ const subject = key
316
+ ? (state.named.get(key) ?? (await sink.describe(draft.entityType, key)))
317
+ : undefined
318
+ // A reset link or an accepted invite is anonymous: the user it names is who acted.
319
+ const self = !principal && draft.entityType === 'User' && subject
320
+ records.push({
321
+ ...draft,
322
+ entityType: subject?.entityType ?? draft.entityType,
323
+ entityKey: subject?.entityKey ?? key,
324
+ entityName: subject?.entityName ?? null,
325
+ detail,
326
+ userKey: principal?.id ?? (self ? subject.entityKey : null),
327
+ userName: principal?.name ?? (self ? subject.entityName : null),
328
+ ipAddress: address.ipAddress,
329
+ forwardedFor: address.forwardedFor,
330
+ })
331
+ }
332
+ await sink.record(records)
333
+ }
334
+ }
335
+
336
+ interface Subject {
337
+ entityType: string
338
+ entityKey: string
339
+ entityName: string | null
340
+ }
341
+
342
+ interface AuditState {
343
+ body: unknown
344
+ named: Map<string, Subject>
216
345
  }
package/src/routes.ts CHANGED
@@ -2,11 +2,17 @@
2
2
  * Composition root for the Management API. Handlers are registered here as they
3
3
  * are implemented; every other operation in the contract answers 501.
4
4
  */
5
+ import { registerAuditLogHandlers } from './handlers/audit-log.ts'
5
6
  import { registerContentTypeHandlers } from './handlers/content-type.ts'
6
7
  import { registerDataTypeHandlers } from './handlers/data-type.ts'
8
+ import {
9
+ registerDiagnosticsHandlers,
10
+ registerTelemetryAndProfilingHandlers,
11
+ } from './handlers/diagnostics.ts'
7
12
  import { registerDictionaryHandlers } from './handlers/dictionary.ts'
8
13
  import { registerDocumentHandlers } from './handlers/document.ts'
9
14
  import { registerDocumentBlueprintHandlers } from './handlers/document-blueprint.ts'
15
+ import { registerDynamicRootHandlers } from './handlers/dynamic-root.ts'
10
16
  import { registerEditorServiceHandlers } from './handlers/editor-services.ts'
11
17
  import { registerElementHandlers } from './handlers/element.ts'
12
18
  import { registerFileSystemHandlers } from './handlers/file-systems.ts'
@@ -22,13 +28,21 @@ import { registerPackageHandlers } from './handlers/package.ts'
22
28
  import { registerPublicAccessHandlers } from './handlers/public-access.ts'
23
29
  import { registerPublishedCacheHandlers } from './handlers/published-cache.ts'
24
30
  import { registerRedirectHandlers } from './handlers/redirects.ts'
31
+ import { registerReferenceDataHandlers } from './handlers/reference-data.ts'
25
32
  import { registerReferenceHandlers } from './handlers/references.ts'
33
+ import {
34
+ registerRecycleBinReferenceHandlers,
35
+ registerRelationHandlers,
36
+ } from './handlers/relations.ts'
37
+ import { registerSearchIndexHandlers } from './handlers/search-index.ts'
26
38
  import { registerServerHandlers } from './handlers/server.ts'
27
39
  import { registerSettingsStubHandlers } from './handlers/settings-stubs.ts'
40
+ import { createStaticFiles, registerStaticFileHandlers } from './handlers/static-files.ts'
28
41
  import { registerTemplateHandlers } from './handlers/template.ts'
29
42
  import { registerTemplateQueryHandlers } from './handlers/template-query.ts'
30
43
  import { registerTemporaryFileHandlers } from './handlers/temporary-file.ts'
31
44
  import { registerUserHandlers } from './handlers/user.ts'
45
+ import { registerWebhookHandlers } from './handlers/webhook.ts'
32
46
  import type { ManagementApiDeps } from './ports.ts'
33
47
  import { type Authenticator, ManagementApiRouter, type RouterOptions } from './router.ts'
34
48
 
@@ -39,6 +53,10 @@ export interface ManagementApiOptions {
39
53
  onServerEvent?: RouterOptions['onServerEvent']
40
54
  nodes?: RouterOptions['nodes']
41
55
  allowLocalLogin?: boolean
56
+ audit?: RouterOptions['audit']
57
+ clientAddress?: RouterOptions['clientAddress']
58
+ onError?: RouterOptions['onError']
59
+ onWebhookEvent?: RouterOptions['onWebhookEvent']
42
60
  }
43
61
 
44
62
  export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
@@ -48,6 +66,10 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
48
66
  onServerEvent: options.onServerEvent,
49
67
  nodes: options.nodes,
50
68
  allowLocalLogin: options.allowLocalLogin,
69
+ audit: options.audit,
70
+ clientAddress: options.clientAddress,
71
+ onError: options.onError,
72
+ onWebhookEvent: options.onWebhookEvent,
51
73
  })
52
74
  const { deps } = options
53
75
  if (!deps) return router
@@ -61,6 +83,10 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
61
83
  userData: deps.userData,
62
84
  })
63
85
  registerSettingsStubHandlers(router)
86
+ registerTelemetryAndProfilingHandlers(router)
87
+ registerReferenceDataHandlers(router)
88
+ if (deps.dynamicRoots) registerDynamicRootHandlers(router, deps.dynamicRoots)
89
+ if (deps.diagnostics) registerDiagnosticsHandlers(router, deps.diagnostics)
64
90
  if (deps.contentTypes)
65
91
  registerContentTypeHandlers(router, deps.contentTypes, 'document', deps.temporaryFiles)
66
92
  if (deps.mediaTypes)
@@ -81,19 +107,39 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
81
107
  registerFileSystemHandlers(router, 'PartialView', deps.components, deps.componentSnippets)
82
108
  if (deps.stylesheets) registerFileSystemHandlers(router, 'Stylesheet', deps.stylesheets)
83
109
  if (deps.scripts) registerFileSystemHandlers(router, 'Script', deps.scripts)
110
+ if (deps.stylesheets && deps.scripts)
111
+ registerStaticFileHandlers(
112
+ router,
113
+ createStaticFiles({ css: deps.stylesheets, scripts: deps.scripts }),
114
+ )
84
115
  if (deps.logViewer) registerLogViewerHandlers(router, deps.logViewer)
85
116
  if (deps.modelsBuilder) registerModelsBuilderHandlers(router, deps.modelsBuilder)
86
117
  if (deps.publishedCache) registerPublishedCacheHandlers(router, deps.publishedCache)
118
+ if (deps.searchIndex) registerSearchIndexHandlers(router, deps.searchIndex)
87
119
  if (deps.temporaryFiles) registerImportHandlers(router, deps.temporaryFiles)
88
120
  if (deps.memberGroups) registerMemberGroupHandlers(router, deps.memberGroups)
89
121
  if (deps.members) registerMemberHandlers(router, deps.members)
90
122
  if (deps.publicAccess) registerPublicAccessHandlers(router, deps.publicAccess)
91
123
  if (deps.elements) registerElementHandlers(router, deps.elements, deps.references)
124
+ registerAuditLogHandlers(
125
+ router,
126
+ {
127
+ documents: deps.documents,
128
+ media: deps.media,
129
+ elements: deps.elements,
130
+ blueprints: deps.blueprints,
131
+ },
132
+ deps.auditHistory,
133
+ )
92
134
  if (deps.redirects) registerRedirectHandlers(router, deps.redirects)
93
135
  if (deps.packages) registerPackageHandlers(router, deps.packages)
94
- if (deps.references)
136
+ if (deps.references) {
95
137
  for (const area of ['Document', 'Media', 'Member', 'Element'] as const)
96
138
  registerReferenceHandlers(router, area, deps.references)
139
+ registerRecycleBinReferenceHandlers(router, deps.references)
140
+ }
141
+ if (deps.relations) registerRelationHandlers(router, deps.relations)
142
+ if (deps.webhooks) registerWebhookHandlers(router, deps.webhooks)
97
143
  registerEditorServiceHandlers(router, { tags: deps.tags, oembed: deps.oembed })
98
144
  return router
99
145
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The webhook events this site raises, and which Management API operation
3
+ * raises each. Aliases are Umbraco's, so the backoffice's event picker and an
4
+ * imported site's choices mean the same thing. docs/webhooks.md.
5
+ */
6
+
7
+ export type WebhookEventType = 'Content' | 'Media'
8
+
9
+ export interface WebhookEventInfo {
10
+ alias: string
11
+ eventName: string
12
+ eventType: WebhookEventType
13
+ }
14
+
15
+ export const WEBHOOK_EVENTS: readonly WebhookEventInfo[] = [
16
+ { alias: 'Umbraco.ContentSaved', eventName: 'Content Saved', eventType: 'Content' },
17
+ { alias: 'Umbraco.ContentPublish', eventName: 'Content Published', eventType: 'Content' },
18
+ { alias: 'Umbraco.ContentUnpublish', eventName: 'Content Unpublished', eventType: 'Content' },
19
+ { alias: 'Umbraco.ContentDelete', eventName: 'Content Deleted', eventType: 'Content' },
20
+ {
21
+ alias: 'Umbraco.ContentMovedToRecycleBin',
22
+ eventName: 'Content Moved to Recycle Bin',
23
+ eventType: 'Content',
24
+ },
25
+ { alias: 'Umbraco.ContentMoved', eventName: 'Content Moved', eventType: 'Content' },
26
+ { alias: 'Umbraco.ContentCopied', eventName: 'Content Copied', eventType: 'Content' },
27
+ { alias: 'Umbraco.MediaSave', eventName: 'Media Saved', eventType: 'Media' },
28
+ { alias: 'Umbraco.MediaDelete', eventName: 'Media Deleted', eventType: 'Media' },
29
+ ]
30
+
31
+ export const WEBHOOK_EVENT_ALIASES: ReadonlySet<string> = new Set(
32
+ WEBHOOK_EVENTS.map((event) => event.alias),
33
+ )
34
+
35
+ /** Something happened to one item: what, and to which. */
36
+ export interface RaisedWebhookEvent {
37
+ alias: string
38
+ key: string
39
+ }
40
+
41
+ const BY_OPERATION: Record<string, string> = {
42
+ PostDocument: 'Umbraco.ContentSaved',
43
+ PutDocumentById: 'Umbraco.ContentSaved',
44
+ PutDocumentByIdPublish: 'Umbraco.ContentPublish',
45
+ PutDocumentByIdPublishWithDescendants: 'Umbraco.ContentPublish',
46
+ PostDocumentCreateAndPublish: 'Umbraco.ContentPublish',
47
+ PutDocumentByIdUpdateAndPublish: 'Umbraco.ContentPublish',
48
+ PutDocumentByIdUnpublish: 'Umbraco.ContentUnpublish',
49
+ DeleteDocumentById: 'Umbraco.ContentDelete',
50
+ DeleteRecycleBinDocumentById: 'Umbraco.ContentDelete',
51
+ PutDocumentByIdMoveToRecycleBin: 'Umbraco.ContentMovedToRecycleBin',
52
+ PutDocumentByIdMove: 'Umbraco.ContentMoved',
53
+ PostDocumentByIdCopy: 'Umbraco.ContentCopied',
54
+ PostMedia: 'Umbraco.MediaSave',
55
+ PutMediaById: 'Umbraco.MediaSave',
56
+ DeleteMediaById: 'Umbraco.MediaDelete',
57
+ DeleteRecycleBinMediaById: 'Umbraco.MediaDelete',
58
+ }
59
+
60
+ /**
61
+ * The event a successful operation raises, if any. Pure. The item is the one
62
+ * the operation created (its generated-resource header) or the one it names.
63
+ */
64
+ export function webhookEventFor(
65
+ operationId: string,
66
+ params: Readonly<Record<string, string>>,
67
+ response: Pick<Response, 'status' | 'headers'>,
68
+ ): RaisedWebhookEvent | undefined {
69
+ if (response.status < 200 || response.status >= 300) return undefined
70
+ const alias = BY_OPERATION[operationId]
71
+ if (!alias) return undefined
72
+ const key = response.headers.get('umb-generated-resource') ?? params.id
73
+ return key ? { alias, key: key.toLowerCase() } : undefined
74
+ }