@crombolo/api-management 0.10.0 → 0.11.0

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.11.0",
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.11.0",
32
+ "@crombolo/contracts": "0.11.0"
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,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
+ }
@@ -7,7 +7,7 @@ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@cro
7
7
  import type { BlueprintPort, PortFailure } from '../ports-content.ts'
8
8
  import type { ManagementApiRouter, RequestContext } from '../router.ts'
9
9
  import { created, paging, registerFolderHandlers } from './content-type.ts'
10
- import { auditLogResponse, readBody, toDocumentResponse, toSaveDocument } from './document.ts'
10
+ import { readBody, toDocumentResponse, toSaveDocument } from './document.ts'
11
11
 
12
12
  const API = '/umbraco/management/api/v1'
13
13
 
@@ -126,8 +126,6 @@ export function registerDocumentBlueprintHandlers(
126
126
  return new Response(null, { status: 200 })
127
127
  })
128
128
 
129
- router.handle('GetDocumentBlueprintByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
130
-
131
129
  router.handle('GetDocumentTypeByIdBlueprint', async (ctx) => {
132
130
  const page = paging(ctx)
133
131
  if (!page) return problemResponse(invalidSkipTake())
@@ -7,11 +7,12 @@ import type {
7
7
  DocumentTreeItem,
8
8
  DocumentValidationError,
9
9
  DocumentValue,
10
- DocumentVersionSummary,
11
10
  } from '@crombolo/core'
12
11
  import {
12
+ DocumentPermissions,
13
13
  invalidSkipTake,
14
14
  notFound,
15
+ permissionsForPath,
15
16
  problemDetails,
16
17
  problemResponse,
17
18
  ROOT_ACCESS,
@@ -24,6 +25,7 @@ import {
24
25
  type TreeSource,
25
26
  visibleChildren,
26
27
  visibleRoot,
28
+ visibleSearch,
27
29
  visibleSiblings,
28
30
  } from './start-node-trees.ts'
29
31
 
@@ -385,11 +387,28 @@ export function registerDocumentHandlers(
385
387
  const page = paging(ctx)
386
388
  if (!page) return problemResponse(invalidSkipTake())
387
389
  const query = ctx.url.searchParams.get('query') ?? ''
388
- const result = await port.search(query, {
389
- ...page,
390
- trashed: ctx.url.searchParams.get('trashed') === 'true',
391
- parentKey: ctx.url.searchParams.get('parentId'),
392
- })
390
+ const groups = ctx.principal?.groups ?? []
391
+ // Browse is checked per item only when some group could withhold it somewhere.
392
+ const browsesEverything =
393
+ !ctx.principal ||
394
+ groups.some(
395
+ (group) =>
396
+ group.defaults.includes(DocumentPermissions.Read) &&
397
+ [...group.granular.values()].every((verbs) => verbs.includes(DocumentPermissions.Read)),
398
+ )
399
+ const result = await visibleSearch(
400
+ documentStart(ctx),
401
+ browsesEverything
402
+ ? undefined
403
+ : (chain) => permissionsForPath(groups, chain).has(DocumentPermissions.Read),
404
+ (paging) =>
405
+ port.search(query, {
406
+ ...paging,
407
+ trashed: ctx.url.searchParams.get('trashed') === 'true',
408
+ parentKey: ctx.url.searchParams.get('parentId'),
409
+ }),
410
+ page,
411
+ )
393
412
  return Response.json({ total: result.total, items: result.items.map(toDocumentItemResponse) })
394
413
  })
395
414
 
@@ -849,7 +868,6 @@ export function registerDocumentHandlers(
849
868
  })
850
869
 
851
870
  /** The page's history, from its versions: who saved, published or rolled back, and when. */
852
- router.handle('GetDocumentByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
853
871
 
854
872
  router.handle('GetDocumentVersion', async (ctx) => {
855
873
  const documentId = ctx.url.searchParams.get('documentId')
@@ -922,40 +940,3 @@ export function registerDocumentHandlers(
922
940
  })
923
941
  })
924
942
  }
925
-
926
- const AUDIT_TYPE = {
927
- save: 'Save',
928
- publish: 'Publish',
929
- rollback: 'RollBack',
930
- migrate: 'System',
931
- } as const
932
-
933
- /** An audit log built from an item's versions, paged and filtered as the contract asks. */
934
- export async function auditLogResponse(
935
- ctx: RequestContext,
936
- port: {
937
- versions(key: string): Promise<DocumentVersionSummary[]>
938
- systemUserKey(): Promise<string | null>
939
- },
940
- ): Promise<Response> {
941
- const page = paging(ctx)
942
- if (!page) return problemResponse(invalidSkipTake())
943
- const since = ctx.url.searchParams.get('sinceDate')
944
- const sinceTime = since ? Date.parse(since) : Number.NaN
945
- let versions = await port.versions(ctx.params.id as string)
946
- if (!Number.isNaN(sinceTime))
947
- versions = versions.filter((v) => v.versionDate.getTime() >= sinceTime)
948
- if (ctx.url.searchParams.get('orderDirection') === 'Ascending') versions = versions.reverse()
949
- const fallbackUser = await port.systemUserKey()
950
- const window = versions.slice(page.skip, page.skip + page.take)
951
- return Response.json({
952
- total: versions.length,
953
- items: window.map((version) => ({
954
- user: { id: version.userKey ?? fallbackUser },
955
- timestamp: version.versionDate.toISOString(),
956
- logType: AUDIT_TYPE[version.kind ?? 'save'],
957
- comment: null,
958
- parameters: null,
959
- })),
960
- })
961
- }
@@ -17,7 +17,7 @@ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@cro
17
17
  import type { ElementPort, PortFailure, ReferencePort } from '../ports-content.ts'
18
18
  import type { ManagementApiRouter, RequestContext } from '../router.ts'
19
19
  import { created, paging, registerFolderHandlers } from './content-type.ts'
20
- import { auditLogResponse, failureResponse, readBody, toSaveDocument } from './document.ts'
20
+ import { failureResponse, readBody, toSaveDocument } from './document.ts'
21
21
 
22
22
  const API = '/umbraco/management/api/v1'
23
23
 
@@ -444,7 +444,6 @@ export function registerElementHandlers(
444
444
  })
445
445
 
446
446
  // ── history ───────────────────────────────────────────────────────────────
447
- handle('GetElementByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
448
447
 
449
448
  handle('GetElementVersion', async (ctx) => {
450
449
  const elementId = ctx.url.searchParams.get('elementId')
@@ -13,8 +13,8 @@ import {
13
13
  import type { MediaPort, PortFailure } from '../ports-content.ts'
14
14
  import type { ManagementApiRouter, RequestContext } from '../router.ts'
15
15
  import { created, paging } from './content-type.ts'
16
- import { auditLogResponse, readBody, toSaveDocument, validationProblem } from './document.ts'
17
- import { visibleChildren, visibleRoot, visibleSiblings } from './start-node-trees.ts'
16
+ import { readBody, toSaveDocument, validationProblem } from './document.ts'
17
+ import { visibleChildren, visibleRoot, visibleSearch, visibleSiblings } from './start-node-trees.ts'
18
18
 
19
19
  const API = '/umbraco/management/api/v1'
20
20
 
@@ -251,8 +251,6 @@ export function registerMediaHandlers(router: ManagementApiRouter, port: MediaPo
251
251
  router.handle('PutMediaByIdSortChildren', (ctx) => sortByField(ctx, ctx.params.id as string))
252
252
  router.handle('PutMediaRootSortChildren', (ctx) => sortByField(ctx, null))
253
253
 
254
- router.handle('GetMediaByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
255
-
256
254
  /** Thumbnail and crop URLs in the query vocabulary the media route understands. */
257
255
  router.handle('GetImagingResizeUrls', async (ctx) => {
258
256
  const params = new URLSearchParams()
@@ -341,11 +339,17 @@ export function registerMediaHandlers(router: ManagementApiRouter, port: MediaPo
341
339
  const page = paging(ctx)
342
340
  if (!page) return problemResponse(invalidSkipTake())
343
341
  const parentId = ctx.url.searchParams.get('parentId')
344
- const result = await port.search(ctx.url.searchParams.get('query') ?? '', {
345
- trashed: ctx.url.searchParams.get('trashed') === 'true',
346
- parentKey: parentId,
347
- ...page,
348
- })
342
+ const result = await visibleSearch(
343
+ mediaStart(ctx),
344
+ undefined,
345
+ (paging) =>
346
+ port.search(ctx.url.searchParams.get('query') ?? '', {
347
+ trashed: ctx.url.searchParams.get('trashed') === 'true',
348
+ parentKey: parentId,
349
+ ...paging,
350
+ }),
351
+ page,
352
+ )
349
353
  return Response.json({ total: result.total, items: result.items.map(toMediaItem) })
350
354
  })
351
355
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Examine dashboard: the indexes, their health, rebuilding one, and searching
3
+ * one directly. An index with items still waiting reports itself rebuilding, which
4
+ * is what makes the dashboard poll until they are done. docs/search.md.
5
+ */
6
+ import { invalidSkipTake, problemDetails, problemResponse } from '@crombolo/core'
7
+ import type { SearchIndexInfo, SearchIndexPort } from '../ports.ts'
8
+ import type { ManagementApiRouter } from '../router.ts'
9
+ import { paging } from './content-type.ts'
10
+
11
+ const waiting = (n: number) => `${n} ${n === 1 ? 'item is' : 'items are'} waiting to be indexed`
12
+
13
+ function toIndexResponse(index: SearchIndexInfo) {
14
+ return {
15
+ name: index.name,
16
+ healthStatus:
17
+ index.pending > 0
18
+ ? { status: 'Rebuilding', message: waiting(index.pending) }
19
+ : { status: 'Healthy', message: null },
20
+ canRebuild: true,
21
+ searcherName: index.searcherName,
22
+ documentCount: index.documentCount,
23
+ fieldCount: index.fieldCount,
24
+ providerProperties: null,
25
+ uniqueKeyFieldName: '__Key',
26
+ }
27
+ }
28
+
29
+ const indexNotFound = (name: string) =>
30
+ problemResponse(
31
+ problemDetails({
32
+ title: 'Index Not Found',
33
+ detail: `No index found with name = ${name}`,
34
+ status: 400,
35
+ }),
36
+ )
37
+
38
+ export function registerSearchIndexHandlers(
39
+ router: ManagementApiRouter,
40
+ port: SearchIndexPort,
41
+ ): void {
42
+ router.handle('GetIndexer', async (ctx) => {
43
+ const page = paging(ctx)
44
+ if (!page) return problemResponse(invalidSkipTake())
45
+ const indexes = await port.indexes()
46
+ return Response.json({
47
+ total: indexes.length,
48
+ items: indexes.slice(page.skip, page.skip + page.take).map(toIndexResponse),
49
+ })
50
+ })
51
+
52
+ router.handle('GetIndexerByIndexName', async ({ params }) => {
53
+ const name = params.indexName as string
54
+ const index = await port.index(name)
55
+ return index ? Response.json(toIndexResponse(index)) : indexNotFound(name)
56
+ })
57
+
58
+ router.handle('PostIndexerByIndexNameRebuild', async ({ params }) => {
59
+ const name = params.indexName as string
60
+ return (await port.rebuild(name)) ? new Response(null, { status: 200 }) : indexNotFound(name)
61
+ })
62
+
63
+ router.handle('GetSearcher', (ctx) => {
64
+ const page = paging(ctx)
65
+ if (!page) return problemResponse(invalidSkipTake())
66
+ const names = port.searchers()
67
+ return Response.json({
68
+ total: names.length,
69
+ items: names.slice(page.skip, page.skip + page.take).map((name) => ({ name })),
70
+ })
71
+ })
72
+
73
+ router.handle('GetSearcherBySearcherNameQuery', async (ctx) => {
74
+ const page = paging(ctx)
75
+ if (!page) return problemResponse(invalidSkipTake())
76
+ const name = ctx.params.searcherName as string
77
+ const result = await port.query(name, ctx.url.searchParams.get('term') ?? '', page)
78
+ if (!result)
79
+ return problemResponse(
80
+ problemDetails({
81
+ title: 'Searcher Not Found',
82
+ detail: `No searcher found with name = ${name}`,
83
+ status: 404,
84
+ }),
85
+ )
86
+ return Response.json({
87
+ total: result.total,
88
+ items: result.items.map((hit) => ({
89
+ id: hit.key,
90
+ score: hit.score,
91
+ fieldCount: hit.fields.length,
92
+ fields: hit.fields,
93
+ })),
94
+ })
95
+ })
96
+ }
@@ -82,3 +82,29 @@ export async function visibleSiblings(
82
82
  const visible = await onTheWay(start, source, window.items)
83
83
  return { items: visible.items, totalBefore: 0, totalAfter: 0 }
84
84
  }
85
+
86
+ /** How far a filtered search looks before paging: past it, an editor refines the query. */
87
+ const SEARCH_WINDOW = 1_000
88
+
89
+ /**
90
+ * A page of search results as a user may see them: under a start node, and
91
+ * passing `allowed` (Browse, for documents). Unrestricted access pages in the
92
+ * database; otherwise a window of the best matches is filtered, then paged.
93
+ */
94
+ export async function visibleSearch(
95
+ start: StartNodes,
96
+ allowed: ((chain: readonly string[]) => boolean) | undefined,
97
+ search: (paging: SkipTake) => Promise<Page<DocumentTreeItem>>,
98
+ paging: SkipTake,
99
+ ): Promise<Page<DocumentTreeItem>> {
100
+ if (start.root && !allowed) return search(paging)
101
+ const window = await search({ skip: 0, take: SEARCH_WINDOW })
102
+ const visible = window.items.filter((item) => {
103
+ const chain = chainOf(item)
104
+ return hasPathAccess(start, chain, item.isTrashed) && (allowed?.(chain) ?? true)
105
+ })
106
+ return {
107
+ total: visible.length,
108
+ items: visible.slice(paging.skip, paging.skip + paging.take),
109
+ }
110
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './audit.ts'
1
2
  export * from './authorization.ts'
2
3
  export * from './handlers/content-type.ts'
3
4
  export * from './handlers/package.ts'
package/src/ports.ts CHANGED
@@ -111,6 +111,54 @@ export interface PublishedCachePort {
111
111
  isRebuilding(): boolean
112
112
  }
113
113
 
114
+ /** An index as the Examine dashboard lists it. */
115
+ export interface SearchIndexInfo {
116
+ name: string
117
+ searcherName: string
118
+ documentCount: number
119
+ fieldCount: number
120
+ /** Items waiting to be indexed; while any are, the index reports itself rebuilding. */
121
+ pending: number
122
+ }
123
+
124
+ /** An item's entries in the audit log, for its Info tab. docs/audit-log.md. */
125
+ export interface AuditHistoryPort {
126
+ forItem(
127
+ key: string,
128
+ ): Promise<
129
+ Array<{ userKey: string | null; occurredAt: Date; action: string; detail: string | null }>
130
+ >
131
+ /** When the log began; an item's versions from before then stand in for its entries. */
132
+ startedAt(): Promise<Date | undefined>
133
+ /** Entries older than this have been aged out, and versions standing in for them are too. */
134
+ retainedSince(): Date | undefined
135
+ }
136
+
137
+ /** The backoffice search index behind the Examine dashboard. docs/search.md. */
138
+ export interface SearchIndexPort {
139
+ indexes(): Promise<SearchIndexInfo[]>
140
+ index(name: string): Promise<SearchIndexInfo | undefined>
141
+ /** False when there is no index by that name. */
142
+ rebuild(name: string): Promise<boolean>
143
+ searchers(): string[]
144
+ /** Undefined when there is no searcher by that name. */
145
+ query(
146
+ searcherName: string,
147
+ term: string,
148
+ paging: { skip: number; take: number },
149
+ ): Promise<
150
+ | {
151
+ total: number
152
+ items: Array<{
153
+ key: string
154
+ score: number
155
+ fields: Array<{ name: string; values: string[] }>
156
+ }>
157
+ }
158
+ | undefined
159
+ >
160
+ }
161
+
114
162
  /** Embed markup for a URL, from its oEmbed provider. */
115
163
  export interface OEmbedPort {
116
164
  markup(
@@ -213,6 +261,8 @@ export interface ManagementApiDeps {
213
261
  logViewer?: LogViewerPort
214
262
  modelsBuilder?: ModelsBuilderPort
215
263
  publishedCache?: PublishedCachePort
264
+ searchIndex?: SearchIndexPort
265
+ auditHistory?: AuditHistoryPort
216
266
  /** The template editor's query builder. */
217
267
  templateQuery?: TemplateQueryPort
218
268
  memberGroups?: MemberGroupPort
package/src/router.ts CHANGED
@@ -14,6 +14,15 @@ import {
14
14
  type StartNodes,
15
15
  unauthorized,
16
16
  } from '@crombolo/core'
17
+ import {
18
+ type AuditPlan,
19
+ type AuditRecord,
20
+ type AuditSink,
21
+ auditDrafts,
22
+ auditNamesFirst,
23
+ auditPlanFor,
24
+ auditReadsBody,
25
+ } from './audit.ts'
17
26
  import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
18
27
  import { type ServerEvent, serverEventFor } from './server-events.ts'
19
28
 
@@ -75,8 +84,24 @@ export interface RouterOptions {
75
84
  nodes?: NodeLookup
76
85
  /** Whether users may sign in with a password, which opens the password-reset endpoints to everyone. */
77
86
  allowLocalLogin?: boolean
87
+ /** Where every successful change is recorded. docs/audit-log.md. */
88
+ audit?: AuditSink
89
+ /** Where a request came from, for its audit entry. */
90
+ clientAddress?: (request: Request) => ClientAddress
91
+ }
92
+
93
+ export interface ClientAddress {
94
+ /** The peer that connected: a proxy, when there is one. */
95
+ ipAddress: string | null
96
+ /** What a proxy said the client was; anyone can send it, so it is kept apart. */
97
+ forwardedFor: string | null
78
98
  }
79
99
 
100
+ const forwardedOnly = (request: Request): ClientAddress => ({
101
+ ipAddress: null,
102
+ forwardedFor: request.headers.get('x-forwarded-for'),
103
+ })
104
+
80
105
  export type OperationHandler = (ctx: RequestContext) => Response | Promise<Response>
81
106
 
82
107
  interface CompiledRoute {
@@ -95,6 +120,8 @@ export class ManagementApiRouter {
95
120
  readonly #onServerEvent: RouterOptions['onServerEvent']
96
121
  readonly #nodes: NodeLookup | undefined
97
122
  readonly #allowLocalLogin: boolean
123
+ readonly #audit: AuditSink | undefined
124
+ readonly #clientAddress: (request: Request) => ClientAddress
98
125
 
99
126
  constructor(options: RouterOptions = {}) {
100
127
  const operations = options.operations ?? listOperations(loadSpec())
@@ -104,6 +131,8 @@ export class ManagementApiRouter {
104
131
  this.#onServerEvent = options.onServerEvent
105
132
  this.#nodes = options.nodes
106
133
  this.#allowLocalLogin = options.allowLocalLogin ?? true
134
+ this.#audit = options.audit
135
+ this.#clientAddress = options.clientAddress ?? forwardedOnly
107
136
  for (const operation of operations) {
108
137
  const segments = operation.path.split('/').filter((s) => s.length > 0)
109
138
  const paramNames = segments.map((s) =>
@@ -201,9 +230,13 @@ export class ManagementApiRouter {
201
230
 
202
231
  const handler = this.#handlers.get(operation.operationId)
203
232
  if (!handler) this.#onNotImplemented?.(operation, request)
233
+ const plan = handler && this.#audit ? auditPlanFor(operation.operationId) : undefined
234
+ const audit = plan ? await this.#beforeAudit(plan, params, request) : undefined
204
235
  const response = handler
205
236
  ? await handler({ request, url, params, operation, notifications, principal })
206
237
  : problemResponse(notImplemented(operation.operationId))
238
+ if (plan && audit && response.status >= 200 && response.status < 300)
239
+ await this.#recordAudit(plan, audit, params, request, response, principal)
207
240
  if (handler && this.#onServerEvent) {
208
241
  const event = serverEventFor(operation, params, response)
209
242
  if (event) this.#onServerEvent(event)
@@ -213,4 +246,76 @@ export class ManagementApiRouter {
213
246
  headers.set('cache-control', 'no-store')
214
247
  return new Response(response.body, { status: response.status, headers })
215
248
  }
249
+
250
+ /** Reads what the entry needs before the handler consumes the body or removes the item. */
251
+ async #beforeAudit(
252
+ plan: AuditPlan,
253
+ params: Readonly<Record<string, string>>,
254
+ request: Request,
255
+ ): Promise<AuditState> {
256
+ const body = auditReadsBody(plan)
257
+ ? await request
258
+ .clone()
259
+ .json()
260
+ .catch(() => undefined)
261
+ : undefined
262
+ const named = new Map<string, Subject>()
263
+ if (auditNamesFirst(plan) && this.#audit)
264
+ for (const draft of auditDrafts(plan, params, new Response(), body))
265
+ if (draft.entityKey)
266
+ named.set(draft.entityKey, await this.#audit.describe(draft.entityType, draft.entityKey))
267
+ return { body, named }
268
+ }
269
+
270
+ async #recordAudit(
271
+ plan: AuditPlan,
272
+ state: AuditState,
273
+ params: Readonly<Record<string, string>>,
274
+ request: Request,
275
+ response: Response,
276
+ principal: Principal | undefined,
277
+ ): Promise<void> {
278
+ const sink = this.#audit
279
+ if (!sink) return
280
+ const address = this.#clientAddress(request)
281
+ let detail: string | null = null
282
+ if (plan.related) {
283
+ const names: string[] = []
284
+ for (const key of plan.related.keys(state.body))
285
+ names.push((await sink.describe(plan.related.entityType, key)).entityName ?? key)
286
+ detail = `${plan.related.label}: ${names.length > 0 ? names.join(', ') : 'none'}`
287
+ }
288
+ const records: AuditRecord[] = []
289
+ for (const draft of auditDrafts(plan, params, response, state.body)) {
290
+ const key = draft.entityKey ?? (plan.entityType === 'User' ? (principal?.id ?? null) : null)
291
+ const subject = key
292
+ ? (state.named.get(key) ?? (await sink.describe(draft.entityType, key)))
293
+ : undefined
294
+ // A reset link or an accepted invite is anonymous: the user it names is who acted.
295
+ const self = !principal && draft.entityType === 'User' && subject
296
+ records.push({
297
+ ...draft,
298
+ entityType: subject?.entityType ?? draft.entityType,
299
+ entityKey: subject?.entityKey ?? key,
300
+ entityName: subject?.entityName ?? null,
301
+ detail,
302
+ userKey: principal?.id ?? (self ? subject.entityKey : null),
303
+ userName: principal?.name ?? (self ? subject.entityName : null),
304
+ ipAddress: address.ipAddress,
305
+ forwardedFor: address.forwardedFor,
306
+ })
307
+ }
308
+ await sink.record(records)
309
+ }
310
+ }
311
+
312
+ interface Subject {
313
+ entityType: string
314
+ entityKey: string
315
+ entityName: string | null
316
+ }
317
+
318
+ interface AuditState {
319
+ body: unknown
320
+ named: Map<string, Subject>
216
321
  }
package/src/routes.ts CHANGED
@@ -2,6 +2,7 @@
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'
7
8
  import { registerDictionaryHandlers } from './handlers/dictionary.ts'
@@ -23,6 +24,7 @@ import { registerPublicAccessHandlers } from './handlers/public-access.ts'
23
24
  import { registerPublishedCacheHandlers } from './handlers/published-cache.ts'
24
25
  import { registerRedirectHandlers } from './handlers/redirects.ts'
25
26
  import { registerReferenceHandlers } from './handlers/references.ts'
27
+ import { registerSearchIndexHandlers } from './handlers/search-index.ts'
26
28
  import { registerServerHandlers } from './handlers/server.ts'
27
29
  import { registerSettingsStubHandlers } from './handlers/settings-stubs.ts'
28
30
  import { registerTemplateHandlers } from './handlers/template.ts'
@@ -39,6 +41,8 @@ export interface ManagementApiOptions {
39
41
  onServerEvent?: RouterOptions['onServerEvent']
40
42
  nodes?: RouterOptions['nodes']
41
43
  allowLocalLogin?: boolean
44
+ audit?: RouterOptions['audit']
45
+ clientAddress?: RouterOptions['clientAddress']
42
46
  }
43
47
 
44
48
  export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
@@ -48,6 +52,8 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
48
52
  onServerEvent: options.onServerEvent,
49
53
  nodes: options.nodes,
50
54
  allowLocalLogin: options.allowLocalLogin,
55
+ audit: options.audit,
56
+ clientAddress: options.clientAddress,
51
57
  })
52
58
  const { deps } = options
53
59
  if (!deps) return router
@@ -84,11 +90,22 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
84
90
  if (deps.logViewer) registerLogViewerHandlers(router, deps.logViewer)
85
91
  if (deps.modelsBuilder) registerModelsBuilderHandlers(router, deps.modelsBuilder)
86
92
  if (deps.publishedCache) registerPublishedCacheHandlers(router, deps.publishedCache)
93
+ if (deps.searchIndex) registerSearchIndexHandlers(router, deps.searchIndex)
87
94
  if (deps.temporaryFiles) registerImportHandlers(router, deps.temporaryFiles)
88
95
  if (deps.memberGroups) registerMemberGroupHandlers(router, deps.memberGroups)
89
96
  if (deps.members) registerMemberHandlers(router, deps.members)
90
97
  if (deps.publicAccess) registerPublicAccessHandlers(router, deps.publicAccess)
91
98
  if (deps.elements) registerElementHandlers(router, deps.elements, deps.references)
99
+ registerAuditLogHandlers(
100
+ router,
101
+ {
102
+ documents: deps.documents,
103
+ media: deps.media,
104
+ elements: deps.elements,
105
+ blueprints: deps.blueprints,
106
+ },
107
+ deps.auditHistory,
108
+ )
92
109
  if (deps.redirects) registerRedirectHandlers(router, deps.redirects)
93
110
  if (deps.packages) registerPackageHandlers(router, deps.packages)
94
111
  if (deps.references)