@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/api-management",
3
- "version": "0.10.0",
3
+ "version": "0.12.1",
4
4
  "description": "Umbraco-compatible Management API handlers for crombolo.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.10.0",
32
- "@crombolo/contracts": "0.10.0"
31
+ "@crombolo/core": "0.12.1",
32
+ "@crombolo/contracts": "0.12.1"
33
33
  }
34
34
  }
package/src/audit.ts ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * What each backoffice change writes to the audit log. Derived from the
3
+ * operation's name, as server events are, with the exceptions listed; every
4
+ * contract operation that changes something is either mapped here or listed as
5
+ * not a change, and a test holds that to the whole contract. docs/audit-log.md.
6
+ */
7
+ export type AuditCategory =
8
+ | 'content'
9
+ | 'media'
10
+ | 'member'
11
+ | 'schema'
12
+ | 'settings'
13
+ | 'user'
14
+ | 'permission'
15
+ | 'sign-in'
16
+ | 'system'
17
+
18
+ export interface AuditPlan {
19
+ category: AuditCategory
20
+ entityType: string
21
+ action: string
22
+ /** Where the item's key is: a path parameter, the created resource, or the request body. */
23
+ key: 'param' | 'generated' | 'none' | ((body: unknown) => string[])
24
+ /** Other items the change names in its body, listed in the entry's detail: a group's users. */
25
+ related?: { entityType: string; label: string; keys: (body: unknown) => string[] }
26
+ }
27
+
28
+ /** One entry the router is about to write, before the item is named. */
29
+ export interface AuditDraft {
30
+ category: AuditCategory
31
+ action: string
32
+ entityType: string
33
+ entityKey: string | null
34
+ detail?: string | null
35
+ }
36
+
37
+ export interface AuditRecord extends AuditDraft {
38
+ entityName: string | null
39
+ userKey: string | null
40
+ userName: string | null
41
+ ipAddress: string | null
42
+ forwardedFor: string | null
43
+ }
44
+
45
+ /** Where the router writes. The server adapts the data layer's `AuditLog` to this. */
46
+ export interface AuditSink {
47
+ /** Names an item, re-keying it when it belongs to another (a version to its page). */
48
+ describe(
49
+ entityType: string,
50
+ key: string,
51
+ ): Promise<{ entityType: string; entityKey: string; entityName: string | null }>
52
+ record(entries: AuditRecord[]): Promise<void>
53
+ }
54
+
55
+ /** Writes that change nothing anyone would answer for: checks, previews, a user's own UI state. */
56
+ export const NOT_AUDITED = new Set([
57
+ 'DeletePreview',
58
+ 'DeleteTemporaryFileById',
59
+ 'DeleteUserDataById',
60
+ 'PostDocumentTypeAvailableCompositions',
61
+ 'PostDynamicRootQuery',
62
+ 'PostHealthCheckGroupByNameCheck',
63
+ 'PostInstallValidateDatabase',
64
+ 'PostMediaTypeAvailableCompositions',
65
+ 'PostMemberTypeAvailableCompositions',
66
+ 'PostSecurityForgotPassword',
67
+ 'PostSecurityForgotPasswordVerify',
68
+ 'PostTemplateQueryExecute',
69
+ 'PostTemporaryFile',
70
+ 'PostUpgradeAuthorize',
71
+ 'PostUserData',
72
+ 'PostUserInviteVerify',
73
+ 'PutUserData',
74
+ ])
75
+
76
+ const ids = (field: string) => (body: unknown) => {
77
+ const list = (body as Record<string, unknown> | null)?.[field]
78
+ return Array.isArray(list)
79
+ ? list.map((item) => (item as { id?: unknown })?.id).filter((id) => typeof id === 'string')
80
+ : []
81
+ }
82
+
83
+ const listed = (body: unknown) =>
84
+ Array.isArray(body)
85
+ ? body.map((item) => (item as { id?: unknown })?.id).filter((id) => typeof id === 'string')
86
+ : []
87
+
88
+ const GROUPS = { entityType: 'UserGroup', label: 'Groups', keys: ids('userGroupIds') }
89
+ const USERS = { entityType: 'User', label: 'Users', keys: listed }
90
+
91
+ const reference = (field: string) => (body: unknown) => {
92
+ const id = ((body as Record<string, unknown> | null)?.[field] as { id?: unknown } | null)?.id
93
+ return typeof id === 'string' ? [id] : []
94
+ }
95
+
96
+ const EXCEPTIONS: Record<string, AuditPlan> = {
97
+ DeleteUser: { category: 'user', entityType: 'User', action: 'Delete', key: ids('userIds') },
98
+ PostUserDisable: { category: 'user', entityType: 'User', action: 'Disable', key: ids('userIds') },
99
+ PostUserEnable: { category: 'user', entityType: 'User', action: 'Enable', key: ids('userIds') },
100
+ PostUserUnlock: { category: 'user', entityType: 'User', action: 'Unlock', key: ids('userIds') },
101
+ PostUserInvite: { category: 'user', entityType: 'User', action: 'Invite', key: 'generated' },
102
+ PostUserInviteResend: {
103
+ category: 'user',
104
+ entityType: 'User',
105
+ action: 'ResendInvite',
106
+ key: reference('user'),
107
+ },
108
+ PostUserInviteCreatePassword: {
109
+ category: 'user',
110
+ entityType: 'User',
111
+ action: 'AcceptInvite',
112
+ key: reference('user'),
113
+ },
114
+ PostSecurityForgotPasswordReset: {
115
+ category: 'user',
116
+ entityType: 'User',
117
+ action: 'ResetPasswordByLink',
118
+ key: reference('user'),
119
+ },
120
+ PostUserSetUserGroups: {
121
+ category: 'permission',
122
+ entityType: 'User',
123
+ action: 'SetUserGroups',
124
+ key: ids('userIds'),
125
+ related: GROUPS,
126
+ },
127
+ PostUser: {
128
+ category: 'user',
129
+ entityType: 'User',
130
+ action: 'Create',
131
+ key: 'generated',
132
+ related: GROUPS,
133
+ },
134
+ PutUserById: {
135
+ category: 'user',
136
+ entityType: 'User',
137
+ action: 'Save',
138
+ key: 'param',
139
+ related: GROUPS,
140
+ },
141
+ PostUserGroupByIdUsers: {
142
+ category: 'permission',
143
+ entityType: 'UserGroup',
144
+ action: 'AddUsers',
145
+ key: 'param',
146
+ related: USERS,
147
+ },
148
+ DeleteUserGroupByIdUsers: {
149
+ category: 'permission',
150
+ entityType: 'UserGroup',
151
+ action: 'RemoveUsers',
152
+ key: 'param',
153
+ related: USERS,
154
+ },
155
+ DeleteUserGroup: {
156
+ category: 'permission',
157
+ entityType: 'UserGroup',
158
+ action: 'Delete',
159
+ key: ids('userGroupIds'),
160
+ },
161
+ PutDocumentSort: {
162
+ category: 'content',
163
+ entityType: 'Document',
164
+ action: 'Sort',
165
+ key: reference('parent'),
166
+ },
167
+ PutMediaSort: {
168
+ category: 'media',
169
+ entityType: 'Media',
170
+ action: 'Sort',
171
+ key: reference('parent'),
172
+ },
173
+ PutDocumentRootSortChildren: {
174
+ category: 'content',
175
+ entityType: 'Document',
176
+ action: 'Sort',
177
+ key: 'none',
178
+ },
179
+ PutMediaRootSortChildren: { category: 'media', entityType: 'Media', action: 'Sort', key: 'none' },
180
+ PutUserCurrentProfile: {
181
+ category: 'user',
182
+ entityType: 'User',
183
+ action: 'SaveProfile',
184
+ key: 'none',
185
+ },
186
+ PostUserCurrentChangePassword: {
187
+ category: 'user',
188
+ entityType: 'User',
189
+ action: 'ChangePassword',
190
+ key: 'none',
191
+ },
192
+ PostUserCurrentAvatar: { category: 'user', entityType: 'User', action: 'SetAvatar', key: 'none' },
193
+ DeleteUserCurrentAvatar: {
194
+ category: 'user',
195
+ entityType: 'User',
196
+ action: 'RemoveAvatar',
197
+ key: 'none',
198
+ },
199
+ PostUserCurrent2faByProviderName: {
200
+ category: 'user',
201
+ entityType: 'User',
202
+ action: 'EnableTwoFactor',
203
+ key: 'none',
204
+ },
205
+ DeleteUserCurrent2faByProviderName: {
206
+ category: 'user',
207
+ entityType: 'User',
208
+ action: 'DisableTwoFactor',
209
+ key: 'none',
210
+ },
211
+ PostInstallSetup: { category: 'system', entityType: 'Install', action: 'Install', key: 'none' },
212
+ PostPublishedCacheRebuild: {
213
+ category: 'system',
214
+ entityType: 'PublishedCache',
215
+ action: 'Rebuild',
216
+ key: 'none',
217
+ },
218
+ PostPublishedCacheReload: {
219
+ category: 'system',
220
+ entityType: 'PublishedCache',
221
+ action: 'Reload',
222
+ key: 'none',
223
+ },
224
+ PostModelsBuilderBuild: {
225
+ category: 'system',
226
+ entityType: 'ModelsBuilder',
227
+ action: 'Build',
228
+ key: 'none',
229
+ },
230
+ PostTelemetryLevel: {
231
+ category: 'settings',
232
+ entityType: 'Telemetry',
233
+ action: 'Save',
234
+ key: 'none',
235
+ },
236
+ PutProfilingStatus: {
237
+ category: 'settings',
238
+ entityType: 'Profiling',
239
+ action: 'Save',
240
+ key: 'none',
241
+ },
242
+ PostRedirectManagementStatus: {
243
+ category: 'settings',
244
+ entityType: 'RedirectManagement',
245
+ action: 'SetStatus',
246
+ key: 'none',
247
+ },
248
+ PostHealthCheckExecuteAction: {
249
+ category: 'system',
250
+ entityType: 'HealthCheck',
251
+ action: 'Execute',
252
+ key: 'none',
253
+ },
254
+ PostIndexerByIndexNameRebuild: {
255
+ category: 'system',
256
+ entityType: 'Index',
257
+ action: 'Rebuild',
258
+ key: 'param',
259
+ },
260
+ PostDocumentCreateAndPublish: {
261
+ category: 'content',
262
+ entityType: 'Document',
263
+ action: 'CreateAndPublish',
264
+ key: 'generated',
265
+ },
266
+ PostElementCreateAndPublish: {
267
+ category: 'content',
268
+ entityType: 'Element',
269
+ action: 'CreateAndPublish',
270
+ key: 'generated',
271
+ },
272
+ PostDocumentBlueprintFromDocument: {
273
+ category: 'content',
274
+ entityType: 'DocumentBlueprint',
275
+ action: 'Create',
276
+ key: 'generated',
277
+ },
278
+ PostDictionaryImport: {
279
+ category: 'settings',
280
+ entityType: 'DictionaryItem',
281
+ action: 'Import',
282
+ key: 'generated',
283
+ },
284
+ }
285
+
286
+ /** Operation-name entity prefixes, longest first so `DocumentType` wins over `Document`. */
287
+ const ENTITIES: Array<[string, AuditCategory]> = [
288
+ ['DocumentBlueprint', 'content'],
289
+ ['DocumentVersion', 'content'],
290
+ ['DocumentType', 'schema'],
291
+ ['Document', 'content'],
292
+ ['ElementVersion', 'content'],
293
+ ['Element', 'content'],
294
+ ['MediaType', 'schema'],
295
+ ['Media', 'media'],
296
+ ['MemberGroup', 'member'],
297
+ ['MemberType', 'schema'],
298
+ ['Member', 'member'],
299
+ ['DataType', 'schema'],
300
+ ['Template', 'schema'],
301
+ ['PartialView', 'schema'],
302
+ ['Script', 'schema'],
303
+ ['Stylesheet', 'schema'],
304
+ ['Language', 'settings'],
305
+ ['Dictionary', 'settings'],
306
+ ['LogViewerSavedSearch', 'settings'],
307
+ ['RedirectManagement', 'settings'],
308
+ ['PackageCreated', 'settings'],
309
+ ['Package', 'system'],
310
+ ['Webhook', 'settings'],
311
+ ['UserGroup', 'permission'],
312
+ ['User', 'user'],
313
+ ]
314
+
315
+ const ENTITY_TYPE: Record<string, string> = {
316
+ Dictionary: 'DictionaryItem',
317
+ PackageCreated: 'Package',
318
+ RedirectManagement: 'Redirect',
319
+ }
320
+
321
+ /** The part after the item's key, to the action it names; anything else is named as it reads. */
322
+ const SUFFIX_ACTION: Record<string, string> = {
323
+ Move: 'Move',
324
+ MoveToRecycleBin: 'Trash',
325
+ Copy: 'Copy',
326
+ Publish: 'Publish',
327
+ Unpublish: 'Unpublish',
328
+ UpdateAndPublish: 'SaveAndPublish',
329
+ PublishWithDescendants: 'PublishWithDescendants',
330
+ SortChildren: 'Sort',
331
+ Domains: 'AssignDomains',
332
+ Notifications: 'SetNotifications',
333
+ Restore: 'Restore',
334
+ Rollback: 'Rollback',
335
+ PreventCleanup: 'PreventCleanup',
336
+ Import: 'Import',
337
+ Rename: 'Rename',
338
+ Template: 'CreateTemplate',
339
+ ChangePassword: 'ChangePassword',
340
+ ResetPassword: 'ResetPassword',
341
+ RunMigration: 'RunMigration',
342
+ Patch: 'Save',
343
+ }
344
+
345
+ const KEY_SEGMENT = /^By(Id|Path|IsoCode|Name|IndexName)/
346
+
347
+ const plans = new Map<string, AuditPlan | null>()
348
+
349
+ /** The audit entry an operation writes when it succeeds, or undefined for one that changes nothing. */
350
+ export function auditPlanFor(operationId: string): AuditPlan | undefined {
351
+ if (plans.has(operationId)) return plans.get(operationId) ?? undefined
352
+ const plan = derive(operationId)
353
+ plans.set(operationId, plan ?? null)
354
+ return plan
355
+ }
356
+
357
+ function derive(operationId: string): AuditPlan | undefined {
358
+ if (NOT_AUDITED.has(operationId) || /Validate$/.test(operationId)) return undefined
359
+ const exception = EXCEPTIONS[operationId]
360
+ if (exception) return exception
361
+ const verb = /^(Post|Put|Delete|Patch)(.+)$/.exec(operationId)
362
+ if (!verb) return undefined
363
+ const method = verb[1] as string
364
+ let rest = verb[2] as string
365
+ const recycleBin = rest.startsWith('RecycleBin')
366
+ if (recycleBin) rest = rest.slice('RecycleBin'.length)
367
+ const found = ENTITIES.find(([name]) => rest.startsWith(name))
368
+ if (!found) return undefined
369
+ const [entity, category] = found
370
+ let after = rest.slice(entity.length)
371
+ let entityType = ENTITY_TYPE[entity] ?? entity
372
+ if (after.startsWith('Folder')) {
373
+ entityType = `${entityType}Folder`
374
+ after = after.slice('Folder'.length)
375
+ }
376
+ const keyed = KEY_SEGMENT.exec(after)
377
+ if (keyed) after = after.slice(keyed[0].length)
378
+
379
+ if (recycleBin && method === 'Delete' && !keyed)
380
+ return { category, entityType, action: 'EmptyRecycleBin', key: 'none' }
381
+ if (after === '') {
382
+ const action = method === 'Post' ? 'Create' : method === 'Delete' ? 'Delete' : 'Save'
383
+ return { category, entityType, action, key: keyed ? 'param' : 'generated' }
384
+ }
385
+ return {
386
+ category: after === 'PublicAccess' ? 'permission' : category,
387
+ entityType,
388
+ action: actionFor(method, after),
389
+ key: keyed ? 'param' : 'none',
390
+ }
391
+ }
392
+
393
+ function actionFor(method: string, after: string): string {
394
+ const mapped = SUFFIX_ACTION[after]
395
+ if (mapped) return mapped
396
+ const add = method === 'Delete' ? 'Remove' : method === 'Post' ? 'Add' : 'Set'
397
+ if (after === 'PublicAccess') return `${add}PublicAccess`
398
+ if (after === 'Avatar') return `${add}Avatar`
399
+ if (after.startsWith('ClientCredentials')) return `${add}ClientCredentials`
400
+ if (after.startsWith('2fa')) return 'DisableTwoFactor'
401
+ return `${method}${after}`
402
+ }
403
+
404
+ /** Whether the router must read the request body for this operation's keys before the handler does. */
405
+ export function auditReadsBody(plan: AuditPlan): boolean {
406
+ return typeof plan.key === 'function' || plan.related !== undefined
407
+ }
408
+
409
+ /** Whether the item must be named before the handler runs, because it will be gone after. */
410
+ export function auditNamesFirst(plan: AuditPlan): boolean {
411
+ return plan.action === 'Delete' || plan.action === 'Trash'
412
+ }
413
+
414
+ /** The entries for a successful operation, keyed but not yet named. */
415
+ export function auditDrafts(
416
+ plan: AuditPlan,
417
+ params: Readonly<Record<string, string>>,
418
+ response: Response,
419
+ body: unknown,
420
+ ): AuditDraft[] {
421
+ const base = { category: plan.category, action: plan.action, entityType: plan.entityType }
422
+ if (typeof plan.key === 'function')
423
+ return plan.key(body).map((key) => ({ ...base, entityKey: key }))
424
+ if (plan.key === 'none') return [{ ...base, entityKey: null }]
425
+ const key =
426
+ plan.key === 'generated'
427
+ ? response.headers.get('umb-generated-resource')
428
+ : (params.id ?? params.path ?? params.isoCode ?? params.name ?? params.indexName)
429
+ return [{ ...base, entityKey: key ?? null }]
430
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Operations that answer 501 on purpose, with the reason; `coverage:api` lists
3
+ * them apart from what is still to build. docs/roadmap.md, "Deliberate divergence".
4
+ */
5
+ const INSTALL =
6
+ 'There is no browser install wizard: `crombolo init` and schema-as-code stand a site up.'
7
+ const UPGRADE =
8
+ 'Upgrades run from the command line with `crombolo upgrade`, not from the backoffice.'
9
+
10
+ export const DELIBERATE_DIVERGENCES: Readonly<Record<string, string>> = {
11
+ GetInstallSettings: INSTALL,
12
+ PostInstallSetup: INSTALL,
13
+ PostInstallValidateDatabase: INSTALL,
14
+ GetUpgradeSettings: UPGRADE,
15
+ PostUpgradeAuthorize: UPGRADE,
16
+ }
17
+
18
+ /** Areas still to build, by the story that builds them. */
19
+ export const PLANNED_AREAS: Readonly<Record<string, string>> = {}
20
+
21
+ /** Operations outside those areas that a story builds. */
22
+ export const PLANNED_OPERATIONS: Readonly<Record<string, string>> = {}
@@ -0,0 +1,131 @@
1
+ /**
2
+ * An item's audit log, for the Info tab of pages, media, elements and
3
+ * blueprints. It answers from the audit log; for the time before the log
4
+ * began, the item's versions stand in, as they did before there was one.
5
+ * docs/audit-log.md.
6
+ */
7
+ import type { DocumentVersionSummary } from '@crombolo/core'
8
+ import { invalidSkipTake, problemResponse } from '@crombolo/core'
9
+ import type { AuditHistoryPort } from '../ports.ts'
10
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
11
+ import { paging } from './content-type.ts'
12
+
13
+ interface VersionSource {
14
+ versions(key: string): Promise<DocumentVersionSummary[]>
15
+ systemUserKey(): Promise<string | null>
16
+ }
17
+
18
+ const VERSION_TYPE = {
19
+ save: 'Save',
20
+ publish: 'Publish',
21
+ rollback: 'RollBack',
22
+ migrate: 'System',
23
+ } as const
24
+
25
+ /** The Info tab's names for what an entry records; anything else is `Custom`, with the action as its comment. */
26
+ const ACTION_TYPE: Record<string, string> = {
27
+ Create: 'New',
28
+ CreateAndPublish: 'Publish',
29
+ Save: 'Save',
30
+ Publish: 'Publish',
31
+ SaveAndPublish: 'Publish',
32
+ PublishWithDescendants: 'Publish',
33
+ Unpublish: 'Unpublish',
34
+ Move: 'Move',
35
+ Trash: 'Move',
36
+ Restore: 'Move',
37
+ Copy: 'Copy',
38
+ Sort: 'Sort',
39
+ Delete: 'Delete',
40
+ AssignDomains: 'AssignDomain',
41
+ SetPublicAccess: 'PublicAccess',
42
+ AddPublicAccess: 'PublicAccess',
43
+ RemovePublicAccess: 'PublicAccess',
44
+ SetNotifications: 'Notify',
45
+ Rollback: 'RollBack',
46
+ PreventCleanup: 'ContentVersionPreventCleanup',
47
+ }
48
+
49
+ const COMMENT: Record<string, string> = {
50
+ Trash: 'Moved to the recycle bin',
51
+ Restore: 'Restored from the recycle bin',
52
+ PublishWithDescendants: 'Published with its descendants',
53
+ }
54
+
55
+ interface Line {
56
+ user: string | null
57
+ timestamp: Date
58
+ logType: string
59
+ comment: string | null
60
+ }
61
+
62
+ /** The log for one item, paged and filtered as the contract asks. */
63
+ export async function auditLogResponse(
64
+ ctx: RequestContext,
65
+ source: VersionSource,
66
+ history: AuditHistoryPort | undefined,
67
+ ): Promise<Response> {
68
+ const page = paging(ctx)
69
+ if (!page) return problemResponse(invalidSkipTake())
70
+ const key = ctx.params.id as string
71
+ const since = ctx.url.searchParams.get('sinceDate')
72
+ const sinceTime = since ? Date.parse(since) : Number.NaN
73
+ const fallbackUser = await source.systemUserKey()
74
+
75
+ const started = await history?.startedAt()
76
+ const retained = history?.retainedSince()
77
+ const lines: Line[] = (await source.versions(key))
78
+ .filter((v) => !started || v.versionDate < started)
79
+ .filter((v) => !retained || v.versionDate >= retained)
80
+ .map((v) => ({
81
+ user: v.userKey,
82
+ timestamp: v.versionDate,
83
+ logType: VERSION_TYPE[v.kind ?? 'save'],
84
+ comment: null,
85
+ }))
86
+ for (const entry of (await history?.forItem(key)) ?? [])
87
+ lines.push({
88
+ user: entry.userKey,
89
+ timestamp: entry.occurredAt,
90
+ logType: ACTION_TYPE[entry.action] ?? 'Custom',
91
+ comment: COMMENT[entry.action] ?? (ACTION_TYPE[entry.action] ? entry.detail : entry.action),
92
+ })
93
+
94
+ let shown = Number.isNaN(sinceTime)
95
+ ? lines
96
+ : lines.filter((l) => l.timestamp.getTime() >= sinceTime)
97
+ shown = shown.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
98
+ if (ctx.url.searchParams.get('orderDirection') === 'Ascending') shown = shown.reverse()
99
+ return Response.json({
100
+ total: shown.length,
101
+ items: shown.slice(page.skip, page.skip + page.take).map((line) => ({
102
+ user: { id: line.user ?? fallbackUser },
103
+ timestamp: line.timestamp.toISOString(),
104
+ logType: line.logType,
105
+ comment: line.comment,
106
+ parameters: null,
107
+ })),
108
+ })
109
+ }
110
+
111
+ export function registerAuditLogHandlers(
112
+ router: ManagementApiRouter,
113
+ sources: {
114
+ documents?: VersionSource
115
+ media?: VersionSource
116
+ elements?: VersionSource
117
+ blueprints?: VersionSource
118
+ },
119
+ history: AuditHistoryPort | undefined,
120
+ ): void {
121
+ const { documents, media, elements, blueprints } = sources
122
+ if (documents)
123
+ router.handle('GetDocumentByIdAuditLog', (ctx) => auditLogResponse(ctx, documents, history))
124
+ if (media) router.handle('GetMediaByIdAuditLog', (ctx) => auditLogResponse(ctx, media, history))
125
+ if (elements)
126
+ router.handle('GetElementByIdAuditLog', (ctx) => auditLogResponse(ctx, elements, history))
127
+ if (blueprints)
128
+ router.handle('GetDocumentBlueprintByIdAuditLog', (ctx) =>
129
+ auditLogResponse(ctx, blueprints, history),
130
+ )
131
+ }
@@ -205,6 +205,31 @@ export function registerDataTypeHandlers(router: ManagementApiRouter, port: Data
205
205
  return Response.json({ total: result.total, items: result.items.map(toItem) })
206
206
  })
207
207
 
208
+ const isUsed = port.isUsed
209
+ if (isUsed)
210
+ router.handle('GetDataTypeByIdIsUsed', async (ctx) => {
211
+ const used = await isUsed(ctx.params.id as string)
212
+ if (used === undefined)
213
+ return problemResponse(notFound('Data type not found', 'The data type could not be found.'))
214
+ return Response.json(used)
215
+ })
216
+ const propertyIsUsed = port.propertyIsUsed
217
+ if (propertyIsUsed)
218
+ router.handle('GetPropertyTypeIsUsed', async (ctx) => {
219
+ const contentTypeId = ctx.url.searchParams.get('contentTypeId')
220
+ const alias = ctx.url.searchParams.get('propertyAlias')
221
+ const used = contentTypeId && alias ? await propertyIsUsed(contentTypeId, alias) : undefined
222
+ if (used === undefined)
223
+ return problemResponse(
224
+ problemDetails({
225
+ title: 'Property type not found',
226
+ detail: 'There is no property with that alias on that content type.',
227
+ status: 400,
228
+ }),
229
+ )
230
+ return Response.json(used)
231
+ })
232
+
208
233
  router.handle('GetDataTypeByIdReferencedBy', async (ctx) => {
209
234
  const page = paging(ctx)
210
235
  if (!page) return problemResponse(invalidSkipTake())