@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/api-management",
|
|
3
|
-
"version": "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.
|
|
32
|
-
"@crombolo/contracts": "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())
|