@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/package.json +3 -3
- package/src/audit.ts +430 -0
- package/src/divergences.ts +22 -0
- package/src/handlers/audit-log.ts +131 -0
- package/src/handlers/data-type.ts +25 -0
- package/src/handlers/diagnostics.ts +89 -0
- package/src/handlers/document-blueprint.ts +1 -3
- package/src/handlers/document.ts +75 -49
- package/src/handlers/dynamic-root.ts +63 -0
- package/src/handlers/element.ts +1 -2
- package/src/handlers/media.ts +13 -9
- package/src/handlers/reference-data.ts +35 -0
- package/src/handlers/relations.ts +60 -0
- package/src/handlers/search-index.ts +96 -0
- package/src/handlers/settings-stubs.ts +1 -10
- package/src/handlers/start-node-trees.ts +26 -0
- package/src/handlers/static-files.ts +98 -0
- package/src/handlers/template.ts +2 -0
- package/src/handlers/webhook.ts +146 -0
- package/src/index.ts +5 -0
- package/src/ports-content.ts +29 -0
- package/src/ports-diagnostics.ts +17 -0
- package/src/ports-webhooks.ts +32 -0
- package/src/ports.ts +68 -0
- package/src/router.ts +130 -1
- package/src/routes.ts +47 -1
- package/src/webhook-events.ts +74 -0
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
|
|
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
|
+
}
|