@crombolo/api-management 0.11.0 → 0.13.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.11.0",
3
+ "version": "0.13.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.11.0",
32
- "@crombolo/contracts": "0.11.0"
31
+ "@crombolo/core": "0.13.1",
32
+ "@crombolo/contracts": "0.13.1"
33
33
  }
34
34
  }
@@ -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>> = {}
@@ -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())
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Health checks, telemetry, profiling and the server's troubleshooting and
3
+ * upgrade-check answers. Telemetry and profiling are fixed: nothing is
4
+ * collected or sent, and there is no profiler. docs/diagnostics.md.
5
+ */
6
+ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@crombolo/core'
7
+ import type { DiagnosticsPort } from '../ports-diagnostics.ts'
8
+ import type { ManagementApiRouter } from '../router.ts'
9
+ import { paging } from './content-type.ts'
10
+ import { readBody } from './document.ts'
11
+
12
+ const TELEMETRY_LEVEL = 'Minimal'
13
+
14
+ const badRequest = (title: string, detail: string) => problemDetails({ title, detail, status: 400 })
15
+
16
+ export function registerTelemetryAndProfilingHandlers(router: ManagementApiRouter): void {
17
+ router.handle('GetTelemetry', (ctx) => {
18
+ const page = paging(ctx)
19
+ if (!page) return problemResponse(invalidSkipTake())
20
+ const items = [{ telemetryLevel: TELEMETRY_LEVEL }]
21
+ return Response.json({
22
+ total: items.length,
23
+ items: items.slice(page.skip, page.skip + page.take),
24
+ })
25
+ })
26
+ router.handle('GetTelemetryLevel', () => Response.json({ telemetryLevel: TELEMETRY_LEVEL }))
27
+ router.handle('PostTelemetryLevel', async (ctx) => {
28
+ const { telemetryLevel } = await readBody(ctx)
29
+ if (telemetryLevel === TELEMETRY_LEVEL) return new Response(null, { status: 200 })
30
+ return problemResponse(
31
+ badRequest(
32
+ 'Telemetry cannot be raised',
33
+ 'This site collects no telemetry and sends nothing anywhere, so the level stays at Minimal.',
34
+ ),
35
+ )
36
+ })
37
+ router.handle('GetProfilingStatus', () => Response.json({ enabled: false }))
38
+ router.handle('PutProfilingStatus', async (ctx) => {
39
+ const { enabled } = await readBody(ctx)
40
+ if (enabled === false) return new Response(null, { status: 200 })
41
+ return problemResponse(
42
+ badRequest('Profiling is not available', 'This site has no profiler to switch on.'),
43
+ )
44
+ })
45
+ }
46
+
47
+ export function registerDiagnosticsHandlers(
48
+ router: ManagementApiRouter,
49
+ diagnostics: DiagnosticsPort,
50
+ ): void {
51
+ const groupNamed = (name: string) =>
52
+ diagnostics.groups().find((group) => group.name.toLowerCase() === name.toLowerCase())
53
+ const noGroup = () => problemResponse(notFound('The health check group could not be found'))
54
+
55
+ router.handle('GetHealthCheckGroup', (ctx) => {
56
+ const page = paging(ctx)
57
+ if (!page) return problemResponse(invalidSkipTake())
58
+ const groups = diagnostics.groups()
59
+ return Response.json({
60
+ total: groups.length,
61
+ items: groups.slice(page.skip, page.skip + page.take).map(({ name }) => ({ name })),
62
+ })
63
+ })
64
+ router.handle('GetHealthCheckGroupByName', (ctx) => {
65
+ const group = groupNamed(String(ctx.params.name))
66
+ return group ? Response.json(group) : noGroup()
67
+ })
68
+ router.handle('PostHealthCheckGroupByNameCheck', async (ctx) => {
69
+ const group = groupNamed(String(ctx.params.name))
70
+ const checks = group && (await diagnostics.run(group.name))
71
+ return checks ? Response.json({ checks }) : noGroup()
72
+ })
73
+ // No check here offers an action: each one reports, and the fix is a change
74
+ // to the site's configuration rather than something a button can do.
75
+ router.handle('PostHealthCheckExecuteAction', () =>
76
+ problemResponse(
77
+ badRequest(
78
+ 'There is nothing to run',
79
+ 'These health checks report only; none offers an action.',
80
+ ),
81
+ ),
82
+ )
83
+ router.handle('GetServerTroubleshooting', async () =>
84
+ Response.json(await diagnostics.troubleshooting()),
85
+ )
86
+ router.handle('GetServerUpgradeCheck', async () =>
87
+ Response.json(await diagnostics.upgradeCheck()),
88
+ )
89
+ }
@@ -7,8 +7,10 @@ import type {
7
7
  DocumentTreeItem,
8
8
  DocumentValidationError,
9
9
  DocumentValue,
10
+ PatchOperation,
10
11
  } from '@crombolo/core'
11
12
  import {
13
+ applyJsonPatch,
12
14
  DocumentPermissions,
13
15
  invalidSkipTake,
14
16
  notFound,
@@ -18,7 +20,7 @@ import {
18
20
  ROOT_ACCESS,
19
21
  } from '@crombolo/core'
20
22
  import type { PreviewPort } from '../ports.ts'
21
- import type { DocumentPort, PortFailure, SaveDocument } from '../ports-content.ts'
23
+ import type { ApprovalGatePort, DocumentPort, PortFailure, SaveDocument } from '../ports-content.ts'
22
24
  import type { ManagementApiRouter, RequestContext } from '../router.ts'
23
25
  import { created, paging } from './content-type.ts'
24
26
  import {
@@ -275,7 +277,14 @@ export function registerDocumentHandlers(
275
277
  router: ManagementApiRouter,
276
278
  port: DocumentPort,
277
279
  preview?: PreviewPort,
280
+ approvals?: ApprovalGatePort,
278
281
  ): void {
282
+ /** A page a workflow covers goes through it: the gate's refusal, as a problem. */
283
+ const needsApproval = (title: string, detail: string) =>
284
+ problemResponse(
285
+ problemDetails({ title, status: 400, detail, operationStatus: 'NeedsApproval' }),
286
+ )
287
+ const principalOf = (ctx: RequestContext) => ctx.principal as NonNullable<typeof ctx.principal>
279
288
  const trees: TreeSource = {
280
289
  treeRoot: (paging) => port.treeRoot(paging),
281
290
  treeChildren: (key, paging) => port.treeChildren(key, paging),
@@ -328,6 +337,12 @@ export function registerDocumentHandlers(
328
337
  const body = await readBody(ctx)
329
338
  const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
330
339
  const input = toSaveDocument(key, body)
340
+ const refusal = await approvals?.newPageRefusal(
341
+ principalOf(ctx),
342
+ input.parentKey,
343
+ input.contentTypeKey,
344
+ )
345
+ if (refusal) return needsApproval('The document needs approval before it is published', refusal)
331
346
  const result = await port.createAndPublish(
332
347
  input,
333
348
  readCultures(body),
@@ -344,6 +359,10 @@ export function registerDocumentHandlers(
344
359
  const body = await readBody(ctx)
345
360
  const key = ctx.params.id as string
346
361
  const input = toSaveDocument(key, body)
362
+ const refusal =
363
+ (await approvals?.beforeSave(principalOf(ctx), key)) ??
364
+ (await approvals?.publishRefusal(principalOf(ctx), key, 'publish'))
365
+ if (refusal) return needsApproval('The document needs approval before it is published', refusal)
347
366
  const result = await port.updateAndPublish(
348
367
  key,
349
368
  input,
@@ -471,11 +490,14 @@ export function registerDocumentHandlers(
471
490
  return created(`${ctx.url.origin}/umbraco/management/api/v1/document/${result.key}`, result.key)
472
491
  })
473
492
 
474
- router.handle('PutDocumentById', async (ctx) => {
475
- const key = ctx.params.id as string
476
- const existing = await port.byKey(key)
477
- if (!existing) return problemResponse(notFound('The document could not be found'))
478
- const body = await readBody(ctx)
493
+ const saveUpdate = async (
494
+ ctx: RequestContext,
495
+ key: string,
496
+ existing: DocumentAggregate,
497
+ body: Record<string, unknown>,
498
+ ) => {
499
+ const refusal = await approvals?.beforeSave(principalOf(ctx), key)
500
+ if (refusal) return needsApproval('The document is waiting for approval', refusal)
479
501
  const result = await port.update(
480
502
  key,
481
503
  {
@@ -494,8 +516,51 @@ export function registerDocumentHandlers(
494
516
  }),
495
517
  )
496
518
  }
519
+ await approvals?.afterSave(principalOf(ctx), key)
497
520
  ctx.notifications.push({ message: 'Document saved', category: 'Content', type: 'Success' })
498
521
  return new Response(null, { status: 200 })
522
+ }
523
+
524
+ router.handle('PutDocumentById', async (ctx) => {
525
+ const key = ctx.params.id as string
526
+ const existing = await port.byKey(key)
527
+ if (!existing) return problemResponse(notFound('The document could not be found'))
528
+ return saveUpdate(ctx, key, existing, await readBody(ctx))
529
+ })
530
+
531
+ // JSON Patch over the update model, Umbraco's filter segments included; the
532
+ // patched model then saves exactly as a PUT would.
533
+ router.handle('PatchDocumentByIdPatch', async (ctx) => {
534
+ const key = ctx.params.id as string
535
+ const existing = await port.byKey(key)
536
+ if (!existing) return problemResponse(notFound('The document could not be found'))
537
+ const invalid = (detail: string, status = 400) =>
538
+ problemResponse(problemDetails({ title: 'Could not patch the document', status, detail }))
539
+ const { operations } = await readBody(ctx)
540
+ if (!Array.isArray(operations) || operations.length === 0)
541
+ return invalid('A patch needs at least one operation.')
542
+ const current = {
543
+ template: existing.componentKey ? { id: existing.componentKey } : null,
544
+ values: existing.values.map(({ alias, culture, segment, value }) => ({
545
+ alias,
546
+ culture,
547
+ segment,
548
+ value,
549
+ })),
550
+ variants: existing.variants.map(({ culture, segment, name }) => ({ culture, segment, name })),
551
+ }
552
+ const patched = applyJsonPatch(current, operations as PatchOperation[])
553
+ if (!patched.ok) return invalid(patched.reason)
554
+ const body = patched.value as Record<string, unknown>
555
+ if (!Array.isArray(body.values) || !Array.isArray(body.variants))
556
+ return invalid('A patch may change values and variants, but not remove them.')
557
+ const aliases = new Set((await port.propertyAliases?.(key)) ?? [])
558
+ const unknown = (body.values as Array<{ alias?: unknown }>).find(
559
+ (value) => !aliases.has(String(value?.alias)),
560
+ )
561
+ if (unknown)
562
+ return invalid(`This document's type has no property "${String(unknown.alias)}".`, 422)
563
+ return saveUpdate(ctx, key, existing, body)
499
564
  })
500
565
 
501
566
  router.handle('PutDocumentByIdPublish', async (ctx) => {
@@ -503,6 +568,8 @@ export function registerDocumentHandlers(
503
568
  if (!(await port.byKey(key)))
504
569
  return problemResponse(notFound('The document could not be found'))
505
570
  const body = await readBody(ctx)
571
+ const refusal = await approvals?.publishRefusal(principalOf(ctx), key, 'publish')
572
+ if (refusal) return needsApproval('The document needs approval before it is published', refusal)
506
573
  const schedules = readSchedules(body)
507
574
  if (schedules === 'invalid')
508
575
  return problemResponse(
@@ -547,6 +614,8 @@ export function registerDocumentHandlers(
547
614
  const key = ctx.params.id as string
548
615
  if (!(await port.byKey(key)))
549
616
  return problemResponse(notFound('The document could not be found'))
617
+ const refusal = await approvals?.publishRefusal(principalOf(ctx), key, 'unpublish')
618
+ if (refusal) return needsApproval('Taking the document down needs approval', refusal)
550
619
  const result = await port.unpublish(key, readCultures(await readBody(ctx)))
551
620
  if (!result.ok) {
552
621
  return problemResponse(
@@ -665,6 +734,9 @@ export function registerDocumentHandlers(
665
734
  const branchTasks = new Map<string, { documentKey: string }>()
666
735
  router.handle('PutDocumentByIdPublishWithDescendants', async (ctx) => {
667
736
  const key = ctx.params.id as string
737
+ const refusal = await approvals?.branchRefusal(principalOf(ctx), key)
738
+ if (refusal)
739
+ return needsApproval('Part of this branch needs approval before it is published', refusal)
668
740
  const body = await readBody(ctx)
669
741
  const cultures = Array.isArray(body.cultures) ? (body.cultures as unknown[]).map(String) : []
670
742
  const result = await port.publishBranch(
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Dynamic roots: a content picker's start node, resolved from the document being
3
+ * edited by an origin and a list of steps.
4
+ */
5
+ import { problemDetails, problemResponse } from '@crombolo/core'
6
+ import type { DynamicRootPort } from '../ports.ts'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+ import { readBody } from './document.ts'
9
+
10
+ export const DYNAMIC_ROOT_STEP_ALIASES = [
11
+ 'NearestAncestorOrSelf',
12
+ 'FurthestAncestorOrSelf',
13
+ 'NearestDescendantOrSelf',
14
+ 'FurthestDescendantOrSelf',
15
+ ]
16
+
17
+ const EMPTY_GUID = '00000000-0000-0000-0000-000000000000'
18
+
19
+ const keyOf = (value: unknown): string | null => {
20
+ const id = (value as { id?: unknown } | null | undefined)?.id ?? value
21
+ return typeof id === 'string' && id !== '' && id !== EMPTY_GUID ? id : null
22
+ }
23
+
24
+ export function registerDynamicRootHandlers(
25
+ router: ManagementApiRouter,
26
+ port: DynamicRootPort,
27
+ ): void {
28
+ router.handle('GetDynamicRootSteps', () => Response.json(DYNAMIC_ROOT_STEP_ALIASES))
29
+ router.handle('PostDynamicRootQuery', async (ctx) => {
30
+ const body = await readBody(ctx)
31
+ const context = body.context as { id?: unknown; parent?: unknown } | undefined
32
+ const query = body.query as
33
+ | { origin?: { alias?: unknown; id?: unknown }; steps?: unknown }
34
+ | undefined
35
+ if (!context || typeof query?.origin?.alias !== 'string')
36
+ return problemResponse(
37
+ problemDetails({
38
+ title: 'Invalid dynamic root query',
39
+ detail: 'A query needs a context and an origin.',
40
+ status: 400,
41
+ }),
42
+ )
43
+ const steps = Array.isArray(query.steps) ? query.steps : []
44
+ const roots = await port.resolve({
45
+ currentKey: keyOf(context.id),
46
+ parentKey: keyOf(context.parent),
47
+ origin: { alias: query.origin.alias, key: keyOf(query.origin.id) },
48
+ steps: steps.map((step) => {
49
+ const { alias, documentTypeIds } = (step ?? {}) as {
50
+ alias?: unknown
51
+ documentTypeIds?: unknown
52
+ }
53
+ return {
54
+ alias: String(alias ?? ''),
55
+ documentTypeKeys: Array.isArray(documentTypeIds)
56
+ ? documentTypeIds.filter((id): id is string => typeof id === 'string')
57
+ : [],
58
+ }
59
+ }),
60
+ })
61
+ return Response.json({ roots })
62
+ })
63
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Fixed reference answers: the node object types a relation type can join, and
3
+ * the help pages. Umbraco relays its help pages from our.umbraco.com; there is
4
+ * nothing to relay here, so the list is honestly empty.
5
+ */
6
+ import { invalidSkipTake, ObjectTypes, problemResponse } from '@crombolo/core'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+ import { paging } from './content-type.ts'
9
+
10
+ /** Internal markers rather than things a relation can point at. */
11
+ const HIDDEN = new Set<string>(['SystemRoot', 'LockObject'])
12
+
13
+ /** `DocumentTypeContainer` → `Document Type Container`. */
14
+ const spaced = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2')
15
+
16
+ export const OBJECT_TYPES = Object.entries(ObjectTypes)
17
+ .filter(([name]) => !HIDDEN.has(name))
18
+ .map(([name, id]) => ({ id, name: spaced(name) }))
19
+ .sort((a, b) => a.name.localeCompare(b.name))
20
+
21
+ export function registerReferenceDataHandlers(router: ManagementApiRouter): void {
22
+ router.handle('GetObjectTypes', (ctx) => {
23
+ const page = paging(ctx)
24
+ if (!page) return problemResponse(invalidSkipTake())
25
+ return Response.json({
26
+ total: OBJECT_TYPES.length,
27
+ items: OBJECT_TYPES.slice(page.skip, page.skip + page.take),
28
+ })
29
+ })
30
+ router.handle('GetHelp', (ctx) => {
31
+ const page = paging(ctx)
32
+ if (!page) return problemResponse(invalidSkipTake())
33
+ return Response.json({ total: 0, items: [] })
34
+ })
35
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Relation types and their relations, read-only and derived (story 046), and
3
+ * the recycle bins' "what refers to what is in here" lists.
4
+ */
5
+ import { invalidSkipTake, notFound, paged, parseSkipTake, problemResponse } from '@crombolo/core'
6
+ import type { ReferencePort, RelationPort } from '../ports-content.ts'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+
9
+ const missing = () => problemResponse(notFound('The relation type could not be found'))
10
+
11
+ export function registerRelationHandlers(router: ManagementApiRouter, port: RelationPort): void {
12
+ router.handle('GetRelationType', (ctx) => {
13
+ const page = parseSkipTake(ctx.url.searchParams)
14
+ if (!page.ok) return problemResponse(invalidSkipTake())
15
+ const types = port.types()
16
+ return Response.json(
17
+ paged(types.slice(page.value.skip, page.value.skip + page.value.take), types.length),
18
+ )
19
+ })
20
+
21
+ router.handle('GetRelationTypeById', (ctx) => {
22
+ const id = String(ctx.params.id).toLowerCase()
23
+ const type = port.types().find((t) => t.id === id)
24
+ return type ? Response.json(type) : missing()
25
+ })
26
+
27
+ router.handle('GetItemRelationType', (ctx) => {
28
+ const ids = new Set(ctx.url.searchParams.getAll('id').map((id) => id.toLowerCase()))
29
+ // Built in, so none of them can be deleted.
30
+ return Response.json(
31
+ port
32
+ .types()
33
+ .filter((t) => ids.has(t.id))
34
+ .map((t) => ({ id: t.id, name: t.name, isDeletable: false, flags: [] })),
35
+ )
36
+ })
37
+
38
+ router.handle('GetRelationByRelationTypeId', async (ctx) => {
39
+ const page = parseSkipTake(ctx.url.searchParams)
40
+ if (!page.ok) return problemResponse(invalidSkipTake())
41
+ const result = await port.relations(String(ctx.params.id), page.value)
42
+ return result ? Response.json(paged(result.items, result.total)) : missing()
43
+ })
44
+ }
45
+
46
+ export function registerRecycleBinReferenceHandlers(
47
+ router: ManagementApiRouter,
48
+ port: ReferencePort,
49
+ ): void {
50
+ const ask = port.recycleBinReferencedBy?.bind(port)
51
+ if (!ask) return
52
+ for (const area of ['Document', 'Media', 'Element'] as const)
53
+ router.handle(`GetRecycleBin${area}ReferencedBy`, async (ctx) => {
54
+ // Umbraco pages these 20 at a time, as it does every references list.
55
+ const page = parseSkipTake(ctx.url.searchParams, 20)
56
+ if (!page.ok) return problemResponse(invalidSkipTake())
57
+ const result = await ask(area, page.value)
58
+ return Response.json(paged(result.items, result.total))
59
+ })
60
+ }
@@ -23,18 +23,9 @@ const PAGED = [
23
23
  `GetRecycleBin${b}Children`,
24
24
  `GetRecycleBin${b}ReferencedBy`,
25
25
  ]),
26
- 'GetRecycleBinDocumentReferencedBy', // needs relations, story 046
27
- 'GetRecycleBinMediaReferencedBy',
28
- 'GetRecycleBinElementReferencedBy',
29
- // The recycle bin and the trash and delete dialogs; needs relations, story 046. Until
30
- // then they cannot warn that other content links to what is being removed.
31
- 'GetTreeStaticFileRoot',
32
- 'GetTreeStaticFileChildren',
33
- 'GetRelationType',
34
- 'GetWebhook',
35
26
  ]
36
27
  /** Ancestor lookups: a bare array. */
37
- const ANCESTORS = [...STUB_TREES.map((t) => `GetTree${t}Ancestors`), 'GetTreeStaticFileAncestors']
28
+ const ANCESTORS = STUB_TREES.map((t) => `GetTree${t}Ancestors`)
38
29
  /** Sibling windows: `{ totalBefore, totalAfter, items }`. */
39
30
  const SIBLINGS = [
40
31
  ...STUB_TREES.map((t) => `GetTree${t}Siblings`),
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The static-file picker's tree: the stylesheets and scripts folders the site
3
+ * serves at `/css/` and `/scripts/`, so every path it offers is one a browser
4
+ * can load as it stands. Read-only; the files are edited in their own areas.
5
+ */
6
+ import { invalidSkipTake, paged, parseSkipTake, problemResponse } from '@crombolo/core'
7
+ import type { FileEntry, FileSystemPort } from '../ports-files.ts'
8
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
9
+
10
+ type Area = Pick<FileSystemPort, 'children' | 'entry'>
11
+
12
+ /** The two served folders, by the URL segment each is served under. */
13
+ export function createStaticFiles(mounts: Record<string, Area>): Area {
14
+ const split = (path: string): [Area, string, string] | undefined => {
15
+ const [, mount = '', ...rest] = path.replace(/\/+$/, '').split('/')
16
+ const area = mounts[mount]
17
+ return area ? [area, mount, `/${rest.join('/')}`] : undefined
18
+ }
19
+ const mounted = (mount: string, entry: FileEntry): FileEntry => ({
20
+ ...entry,
21
+ path: `/${mount}${entry.path}`,
22
+ parentPath: entry.parentPath ? `/${mount}${entry.parentPath}` : `/${mount}`,
23
+ })
24
+ const folder = async (mount: string, area: Area): Promise<FileEntry | undefined> => {
25
+ const children = await area.children(null)
26
+ if (!children || children.length === 0) return undefined
27
+ return { name: mount, path: `/${mount}`, parentPath: null, isFolder: true, hasChildren: true }
28
+ }
29
+ return {
30
+ async children(parentPath) {
31
+ if (!parentPath || parentPath === '/') {
32
+ const roots = await Promise.all(
33
+ Object.entries(mounts).map(([mount, area]) => folder(mount, area)),
34
+ )
35
+ return roots.filter((entry): entry is FileEntry => entry !== undefined)
36
+ }
37
+ const found = split(parentPath)
38
+ if (!found) return undefined
39
+ const [area, mount, inner] = found
40
+ const children = await area.children(inner === '/' ? null : inner)
41
+ return children?.map((entry) => mounted(mount, entry))
42
+ },
43
+ async entry(path) {
44
+ const found = split(path)
45
+ if (!found) return undefined
46
+ const [area, mount, inner] = found
47
+ if (inner === '/') return folder(mount, area)
48
+ const entry = await area.entry(inner)
49
+ return entry ? mounted(mount, entry) : undefined
50
+ },
51
+ }
52
+ }
53
+
54
+ const treeItem = (entry: FileEntry) => ({
55
+ name: entry.name,
56
+ path: entry.path,
57
+ parent: entry.parentPath ? { path: entry.parentPath } : null,
58
+ isFolder: entry.isFolder,
59
+ hasChildren: entry.hasChildren,
60
+ })
61
+
62
+ export function registerStaticFileHandlers(router: ManagementApiRouter, files: Area): void {
63
+ const page = (ctx: RequestContext, entries: readonly FileEntry[] | undefined) => {
64
+ const parsed = parseSkipTake(ctx.url.searchParams)
65
+ if (!parsed.ok) return problemResponse(invalidSkipTake())
66
+ const all = (entries ?? []).map(treeItem)
67
+ const { skip, take } = parsed.value
68
+ return Response.json(paged(all.slice(skip, skip + take), all.length))
69
+ }
70
+ router.handle('GetTreeStaticFileRoot', async (ctx) => page(ctx, await files.children(null)))
71
+ router.handle('GetTreeStaticFileChildren', async (ctx) =>
72
+ page(ctx, await files.children(ctx.url.searchParams.get('parentPath') || null)),
73
+ )
74
+ router.handle('GetTreeStaticFileAncestors', async (ctx) => {
75
+ const path = ctx.url.searchParams.get('descendantPath')
76
+ const chain: FileEntry[] = []
77
+ let current = path ? await files.entry(path) : undefined
78
+ while (current) {
79
+ chain.unshift(current)
80
+ current = current.parentPath ? await files.entry(current.parentPath) : undefined
81
+ }
82
+ return Response.json(chain.map(treeItem))
83
+ })
84
+ router.handle('GetItemStaticFile', async (ctx) => {
85
+ const items = []
86
+ for (const path of ctx.url.searchParams.getAll('path')) {
87
+ const entry = await files.entry(path)
88
+ if (entry)
89
+ items.push({
90
+ isFolder: entry.isFolder,
91
+ name: entry.name,
92
+ parent: entry.parentPath ? { path: entry.parentPath } : null,
93
+ path: entry.path,
94
+ })
95
+ }
96
+ return Response.json(items)
97
+ })
98
+ }
@@ -22,6 +22,8 @@ function toResponse(model: ComponentModel): ResponseOf<'GetTemplateById'> {
22
22
  }
23
23
 
24
24
  export function registerTemplateHandlers(router: ManagementApiRouter, port: TemplatePort): void {
25
+ // Templates are the site's components and are always on.
26
+ router.handle('GetTemplateConfiguration', () => Response.json({ disabled: false }))
25
27
  router.handle('GetTemplateById', async ({ params }) => {
26
28
  const model = await port.byKey(params.id as string)
27
29
  if (!model) return problemResponse(notFound('The template could not be found'))
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The webhook operations: definitions, the events they may name, and the
3
+ * delivery log. docs/webhooks.md.
4
+ */
5
+ import {
6
+ invalidSkipTake,
7
+ notFound,
8
+ paged,
9
+ parseSkipTake,
10
+ problemDetails,
11
+ problemResponse,
12
+ } from '@crombolo/core'
13
+ import type {
14
+ WebhookInput,
15
+ WebhookPort,
16
+ WebhookPortResult,
17
+ WebhookPortView,
18
+ } from '../ports-webhooks.ts'
19
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
20
+ import { WEBHOOK_EVENTS } from '../webhook-events.ts'
21
+ import { created } from './content-type.ts'
22
+
23
+ const V1 = '/umbraco/management/api/v1'
24
+
25
+ const EVENTS = new Map(WEBHOOK_EVENTS.map((event) => [event.alias, event]))
26
+
27
+ /** Umbraco names each event in the response, not just by alias. */
28
+ function toResponse(webhook: WebhookPortView) {
29
+ return {
30
+ ...webhook,
31
+ events: webhook.events.map(
32
+ (alias) => EVENTS.get(alias) ?? { alias, eventName: alias, eventType: 'Other' },
33
+ ),
34
+ }
35
+ }
36
+
37
+ const strings = (value: unknown): string[] =>
38
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
39
+
40
+ /** The request body as a webhook; what is not there reads as empty, for the validator to refuse. */
41
+ async function inputOf(ctx: RequestContext): Promise<WebhookInput> {
42
+ const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
43
+ const text = (key: string) => (typeof body[key] === 'string' ? (body[key] as string) : null)
44
+ const headers: Record<string, string> = {}
45
+ if (body.headers && typeof body.headers === 'object' && !Array.isArray(body.headers))
46
+ for (const [name, value] of Object.entries(body.headers as Record<string, unknown>))
47
+ if (typeof value === 'string') headers[name] = value
48
+ return {
49
+ id: text('id') ?? undefined,
50
+ name: text('name'),
51
+ description: text('description'),
52
+ url: text('url') ?? '',
53
+ enabled: body.enabled !== false,
54
+ events: strings(body.events),
55
+ contentTypeKeys: strings(body.contentTypeKeys).map((key) => key.toLowerCase()),
56
+ headers,
57
+ }
58
+ }
59
+
60
+ function refused(result: Exclude<WebhookPortResult, { ok: true }>): Response {
61
+ if (result.status === 404) return problemResponse(notFound('The webhook could not be found'))
62
+ return problemResponse(
63
+ problemDetails({
64
+ title: result.reason,
65
+ status: result.status,
66
+ detail: result.problems?.join(' '),
67
+ ...(result.problems ? { errors: { webhook: result.problems } } : {}),
68
+ }),
69
+ )
70
+ }
71
+
72
+ export function registerWebhookHandlers(router: ManagementApiRouter, port: WebhookPort): void {
73
+ router.handle('GetWebhook', async (ctx) => {
74
+ const page = parseSkipTake(ctx.url.searchParams)
75
+ if (!page.ok) return problemResponse(invalidSkipTake())
76
+ const all = await port.list()
77
+ const { skip, take } = page.value
78
+ return Response.json(paged(all.slice(skip, skip + take).map(toResponse), all.length))
79
+ })
80
+
81
+ router.handle('GetWebhookById', async (ctx) => {
82
+ const webhook = await port.get(ctx.params.id as string)
83
+ return webhook
84
+ ? Response.json(toResponse(webhook))
85
+ : problemResponse(notFound('The webhook could not be found'))
86
+ })
87
+
88
+ router.handle('GetItemWebhook', async (ctx) => {
89
+ const ids = new Set(ctx.url.searchParams.getAll('id').map((id) => id.toLowerCase()))
90
+ const all = await port.list()
91
+ return Response.json(
92
+ all
93
+ .filter((webhook) => ids.has(webhook.id))
94
+ .map((webhook) => ({
95
+ id: webhook.id,
96
+ name: webhook.name ?? webhook.url,
97
+ enabled: webhook.enabled,
98
+ url: webhook.url,
99
+ events: webhook.events.map((alias) => EVENTS.get(alias)?.eventName ?? alias).join(', '),
100
+ types: webhook.contentTypeKeys.join(', '),
101
+ flags: [],
102
+ })),
103
+ )
104
+ })
105
+
106
+ router.handle('PostWebhook', async (ctx) => {
107
+ const input = await inputOf(ctx)
108
+ const result = await port.create({ ...input, id: input.id ?? crypto.randomUUID() })
109
+ if (!result.ok) return refused(result)
110
+ return created(`${ctx.url.origin}${V1}/webhook/${result.id}`, result.id)
111
+ })
112
+
113
+ router.handle('PutWebhookById', async (ctx) => {
114
+ const result = await port.update(ctx.params.id as string, await inputOf(ctx))
115
+ return result.ok ? new Response(null, { status: 200 }) : refused(result)
116
+ })
117
+
118
+ router.handle('DeleteWebhookById', async (ctx) =>
119
+ (await port.remove(ctx.params.id as string))
120
+ ? new Response(null, { status: 200 })
121
+ : problemResponse(notFound('The webhook could not be found')),
122
+ )
123
+
124
+ router.handle('GetWebhookEvents', (ctx) => {
125
+ const page = parseSkipTake(ctx.url.searchParams)
126
+ if (!page.ok) return problemResponse(invalidSkipTake())
127
+ const { skip, take } = page.value
128
+ return Response.json(paged(WEBHOOK_EVENTS.slice(skip, skip + take), WEBHOOK_EVENTS.length))
129
+ })
130
+
131
+ router.handle('GetWebhookLogs', async (ctx) => {
132
+ const page = parseSkipTake(ctx.url.searchParams)
133
+ if (!page.ok) return problemResponse(invalidSkipTake())
134
+ const result = await port.logs(undefined, page.value)
135
+ return Response.json(paged(result.items, result.total))
136
+ })
137
+
138
+ router.handle('GetWebhookByIdLogs', async (ctx) => {
139
+ const page = parseSkipTake(ctx.url.searchParams)
140
+ if (!page.ok) return problemResponse(invalidSkipTake())
141
+ if (!(await port.get(ctx.params.id as string)))
142
+ return problemResponse(notFound('The webhook could not be found'))
143
+ const result = await port.logs(ctx.params.id as string, page.value)
144
+ return Response.json(paged(result.items, result.total))
145
+ })
146
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './audit.ts'
2
2
  export * from './authorization.ts'
3
+ export * from './divergences.ts'
3
4
  export * from './handlers/content-type.ts'
4
5
  export * from './handlers/package.ts'
5
6
  export * from './handlers/settings-stubs.ts'
@@ -7,12 +8,15 @@ export * from './handlers/template-query.ts'
7
8
  export * from './handlers/user.ts'
8
9
  export * from './ports.ts'
9
10
  export * from './ports-content.ts'
11
+ export * from './ports-diagnostics.ts'
10
12
  export * from './ports-files.ts'
11
13
  export * from './ports-logs.ts'
12
14
  export * from './ports-members.ts'
13
15
  export * from './ports-models-builder.ts'
14
16
  export * from './ports-packages.ts'
15
17
  export * from './ports-users.ts'
18
+ export * from './ports-webhooks.ts'
16
19
  export * from './router.ts'
17
20
  export * from './routes.ts'
18
21
  export * from './server-events.ts'
22
+ export * from './webhook-events.ts'
@@ -231,6 +231,10 @@ export interface DataTypePort extends TreePort {
231
231
  paging: SkipTake,
232
232
  ): Promise<Page<DataTypeModel>>
233
233
  search(query: string, paging: SkipTake): Promise<Page<DataTypeModel>>
234
+ /** Whether any current value is saved through it; undefined when it does not exist. */
235
+ isUsed?(key: string): Promise<boolean | undefined>
236
+ /** Whether any current value is saved for a property; undefined when there is no such property. */
237
+ propertyIsUsed?(contentTypeKey: string, alias: string): Promise<boolean | undefined>
234
238
  referencedBy(key: string): Promise<
235
239
  Array<{
236
240
  propertyKey: string
@@ -262,6 +266,8 @@ export interface TemplatePort extends TreePort {
262
266
 
263
267
  export interface DocumentPort extends TreePort {
264
268
  byKey(key: string): Promise<DocumentAggregate | undefined>
269
+ /** Every property alias its type has, compositions included. */
270
+ propertyAliases?(key: string): Promise<string[] | undefined>
265
271
  /** The published version's values; undefined when nothing is published. */
266
272
  byKeyPublished(key: string): Promise<DocumentAggregate | undefined>
267
273
  /** What the editor asks before a save: the rules the server would enforce. */
@@ -498,11 +504,34 @@ export interface MediaPort {
498
504
 
499
505
  /** What refers to a document, media item or member — the Info tab's list. */
500
506
  export interface ReferencePort {
507
+ /** What refers to anything of this kind in the recycle bin, as the empty-bin dialog asks. */
508
+ recycleBinReferencedBy?(
509
+ area: 'Document' | 'Media' | 'Element',
510
+ paging: SkipTake,
511
+ ): Promise<Page<unknown>>
501
512
  referencedBy(key: string, paging: SkipTake): Promise<Page<unknown>>
502
513
  referencedDescendants(key: string, paging: SkipTake): Promise<Page<{ id: string }>>
503
514
  areReferenced(keys: readonly string[], paging: SkipTake): Promise<Page<{ id: string }>>
504
515
  }
505
516
 
517
+ /** A relation type as the wire carries it: `RelationTypeResponseModel`. */
518
+ export interface RelationTypeView {
519
+ id: string
520
+ alias: string
521
+ name: string
522
+ parentObject: { id: string; name: string } | null
523
+ childObject: { id: string; name: string } | null
524
+ isBidirectional: boolean
525
+ isDependency: boolean
526
+ }
527
+
528
+ /** Umbraco's relation types, read-only, with relations derived for each (story 046). */
529
+ export interface RelationPort {
530
+ types(): readonly RelationTypeView[]
531
+ /** Undefined when there is no such type. */
532
+ relations(typeKey: string, paging: SkipTake): Promise<Page<unknown> | undefined>
533
+ }
534
+
506
535
  /** One redirect as the dashboard and the Info tab read it. */
507
536
  export interface RedirectListing {
508
537
  key: string
@@ -562,3 +591,26 @@ export interface TemplateQueryPort {
562
591
  documentTypeAliases(): Promise<string[]>
563
592
  execute(request: TemplateQueryRequest): Promise<{ items: TemplateQueryItem[]; total: number }>
564
593
  }
594
+
595
+ /**
596
+ * Approval before publishing (story 028): asked before a publish, an unpublish
597
+ * or a save, so a page a workflow covers goes through it. Each answer is
598
+ * undefined to go ahead, or what to tell the person.
599
+ */
600
+ export interface ApprovalGatePort {
601
+ publishRefusal(
602
+ principal: Principal,
603
+ key: string,
604
+ action: 'publish' | 'unpublish',
605
+ ): Promise<string | undefined>
606
+ /** A branch publish: the root and every page below it. */
607
+ branchRefusal(principal: Principal, key: string): Promise<string | undefined>
608
+ /** A page created and published in one go, by where it will sit and its type. */
609
+ newPageRefusal(
610
+ principal: Principal,
611
+ parentKey: string | null,
612
+ contentTypeKey: string,
613
+ ): Promise<string | undefined>
614
+ beforeSave(principal: Principal, key: string): Promise<string | undefined>
615
+ afterSave(principal: Principal, key: string): Promise<void>
616
+ }
@@ -0,0 +1,17 @@
1
+ import type { ResponseOf, Schemas } from '@crombolo/contracts'
2
+
3
+ export type HealthCheckResult = Schemas['HealthCheckResultResponseModel']
4
+
5
+ export interface HealthCheckGroup {
6
+ name: string
7
+ checks: Schemas['HealthCheckModel'][]
8
+ }
9
+
10
+ /** The health checks, troubleshooting report and upgrade check. docs/diagnostics.md. */
11
+ export interface DiagnosticsPort {
12
+ groups(): HealthCheckGroup[]
13
+ /** Runs every check in the group; undefined when there is no such group. */
14
+ run(group: string): Promise<Schemas['HealthCheckWithResultPresentationModel'][] | undefined>
15
+ troubleshooting(): Promise<ResponseOf<'GetServerTroubleshooting'>>
16
+ upgradeCheck(): Promise<ResponseOf<'GetServerUpgradeCheck'>>
17
+ }
@@ -0,0 +1,32 @@
1
+ /** Webhooks, as the Management API's eight operations and the item lookup need them. */
2
+ import type { Page, SkipTake } from '@crombolo/core'
3
+
4
+ /** One webhook as the backoffice edits it; a typed-in secret comes back masked. */
5
+ export interface WebhookInput {
6
+ id?: string
7
+ name: string | null
8
+ description: string | null
9
+ url: string
10
+ enabled: boolean
11
+ events: string[]
12
+ contentTypeKeys: string[]
13
+ headers: Record<string, string>
14
+ }
15
+
16
+ export interface WebhookPortView extends WebhookInput {
17
+ id: string
18
+ }
19
+
20
+ export type WebhookPortResult =
21
+ | { ok: true; id: string }
22
+ | { ok: false; status: 400 | 404 | 409; reason: string; problems?: string[] }
23
+
24
+ export interface WebhookPort {
25
+ list(): Promise<WebhookPortView[]>
26
+ get(id: string): Promise<WebhookPortView | undefined>
27
+ create(input: WebhookInput): Promise<WebhookPortResult>
28
+ update(id: string, input: WebhookInput): Promise<WebhookPortResult>
29
+ remove(id: string): Promise<boolean>
30
+ /** Already in the wire's `WebhookLogResponseModel` shape; one webhook's when `id` is given. */
31
+ logs(id: string | undefined, paging: SkipTake): Promise<Page<unknown>>
32
+ }
package/src/ports.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import type { ResponseOf } from '@crombolo/contracts'
11
11
  import type { UploadSettings } from '@crombolo/core'
12
12
  import type {
13
+ ApprovalGatePort,
13
14
  BlueprintPort,
14
15
  ContentTypePort,
15
16
  DataTypePort,
@@ -18,15 +19,18 @@ import type {
18
19
  MediaPort,
19
20
  RedirectPort,
20
21
  ReferencePort,
22
+ RelationPort,
21
23
  TemplatePort,
22
24
  TemplateQueryPort,
23
25
  } from './ports-content.ts'
26
+ import type { DiagnosticsPort } from './ports-diagnostics.ts'
24
27
  import type { FileSystemPort, Snippet } from './ports-files.ts'
25
28
  import type { LogViewerPort } from './ports-logs.ts'
26
29
  import type { MemberGroupPort, MemberPort, PublicAccessPort } from './ports-members.ts'
27
30
  import type { ModelsBuilderPort } from './ports-models-builder.ts'
28
31
  import type { PackagePort } from './ports-packages.ts'
29
32
  import type { UserDataPort, UserGroupPort, UserPort } from './ports-users.ts'
33
+ import type { WebhookPort } from './ports-webhooks.ts'
30
34
  import type { Principal } from './router.ts'
31
35
 
32
36
  export interface ServerPort {
@@ -170,6 +174,16 @@ export interface OEmbedPort {
170
174
  >
171
175
  }
172
176
 
177
+ /** A content picker's start node, found from the document being edited. */
178
+ export interface DynamicRootPort {
179
+ resolve(query: {
180
+ currentKey: string | null
181
+ parentKey: string | null
182
+ origin: { alias: string; key: string | null }
183
+ steps: Array<{ alias: string; documentTypeKeys: string[] }>
184
+ }): Promise<string[]>
185
+ }
186
+
173
187
  /** Preview mode is a cookie; the server owns its name and attributes. */
174
188
  export interface PreviewPort {
175
189
  /** `Set-Cookie` values that enter preview for the signed-in editor. */
@@ -233,11 +247,14 @@ export interface AuthPort {
233
247
 
234
248
  export interface ManagementApiDeps {
235
249
  server: ServerPort
250
+ /** Health checks, troubleshooting and the upgrade check; needs the database. */
251
+ diagnostics?: DiagnosticsPort
236
252
  manifests: ManifestPort
237
253
  localization: LocalizationPort
238
254
  currentUser: CurrentUserPort
239
255
  users?: UserItemPort
240
256
  preview?: PreviewPort
257
+ dynamicRoots?: DynamicRootPort
241
258
  temporaryFiles?: TemporaryFilePort
242
259
  media?: MediaPort
243
260
  tags?: TagPort
@@ -249,6 +266,8 @@ export interface ManagementApiDeps {
249
266
  dataTypes?: DataTypePort
250
267
  templates?: TemplatePort
251
268
  documents?: DocumentPort
269
+ /** Approval workflows: the gate on publishing and saving a page a workflow covers. */
270
+ approvals?: ApprovalGatePort
252
271
  dictionary?: DictionaryPort
253
272
  userManagement?: UserPort
254
273
  userGroups?: UserGroupPort
@@ -270,6 +289,8 @@ export interface ManagementApiDeps {
270
289
  publicAccess?: PublicAccessPort
271
290
  elements?: ElementPort
272
291
  references?: ReferencePort
292
+ relations?: RelationPort
293
+ webhooks?: WebhookPort
273
294
  redirects?: RedirectPort
274
295
  packages?: PackagePort
275
296
  }
package/src/router.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * so a path that is not in the contract cannot be served, and an operation in
4
4
  * the contract without a handler answers 501 rather than 404.
5
5
  */
6
+
6
7
  import { type HttpMethod, listOperations, loadSpec, type OperationInfo } from '@crombolo/contracts'
7
8
  import {
8
9
  applyNotifications,
@@ -25,6 +26,7 @@ import {
25
26
  } from './audit.ts'
26
27
  import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
27
28
  import { type ServerEvent, serverEventFor } from './server-events.ts'
29
+ import { type RaisedWebhookEvent, webhookEventFor } from './webhook-events.ts'
28
30
 
29
31
  /**
30
32
  * Resolved back-office identity, assembled from a reference token plus the
@@ -88,6 +90,10 @@ export interface RouterOptions {
88
90
  audit?: AuditSink
89
91
  /** Where a request came from, for its audit entry. */
90
92
  clientAddress?: (request: Request) => ClientAddress
93
+ /** Answers an error a handler threw; undefined lets it propagate. */
94
+ onError?: (error: unknown) => Response | undefined
95
+ /** Told about every webhook event a successful operation raises. */
96
+ onWebhookEvent?: (event: RaisedWebhookEvent) => void
91
97
  }
92
98
 
93
99
  export interface ClientAddress {
@@ -122,6 +128,8 @@ export class ManagementApiRouter {
122
128
  readonly #allowLocalLogin: boolean
123
129
  readonly #audit: AuditSink | undefined
124
130
  readonly #clientAddress: (request: Request) => ClientAddress
131
+ readonly #onError: RouterOptions['onError']
132
+ readonly #onWebhookEvent: RouterOptions['onWebhookEvent']
125
133
 
126
134
  constructor(options: RouterOptions = {}) {
127
135
  const operations = options.operations ?? listOperations(loadSpec())
@@ -133,6 +141,8 @@ export class ManagementApiRouter {
133
141
  this.#allowLocalLogin = options.allowLocalLogin ?? true
134
142
  this.#audit = options.audit
135
143
  this.#clientAddress = options.clientAddress ?? forwardedOnly
144
+ this.#onError = options.onError
145
+ this.#onWebhookEvent = options.onWebhookEvent
136
146
  for (const operation of operations) {
137
147
  const segments = operation.path.split('/').filter((s) => s.length > 0)
138
148
  const paramNames = segments.map((s) =>
@@ -233,7 +243,7 @@ export class ManagementApiRouter {
233
243
  const plan = handler && this.#audit ? auditPlanFor(operation.operationId) : undefined
234
244
  const audit = plan ? await this.#beforeAudit(plan, params, request) : undefined
235
245
  const response = handler
236
- ? await handler({ request, url, params, operation, notifications, principal })
246
+ ? await this.#run(handler, { request, url, params, operation, notifications, principal })
237
247
  : problemResponse(notImplemented(operation.operationId))
238
248
  if (plan && audit && response.status >= 200 && response.status < 300)
239
249
  await this.#recordAudit(plan, audit, params, request, response, principal)
@@ -241,12 +251,26 @@ export class ManagementApiRouter {
241
251
  const event = serverEventFor(operation, params, response)
242
252
  if (event) this.#onServerEvent(event)
243
253
  }
254
+ if (handler && this.#onWebhookEvent) {
255
+ const event = webhookEventFor(operation.operationId, params, response)
256
+ if (event) this.#onWebhookEvent(event)
257
+ }
244
258
  const headers = new Headers(response.headers)
245
259
  applyNotifications(headers, request.method, notifications)
246
260
  headers.set('cache-control', 'no-store')
247
261
  return new Response(response.body, { status: response.status, headers })
248
262
  }
249
263
 
264
+ async #run(handler: OperationHandler, ctx: RequestContext): Promise<Response> {
265
+ try {
266
+ return await handler(ctx)
267
+ } catch (error) {
268
+ const answered = this.#onError?.(error)
269
+ if (answered) return answered
270
+ throw error
271
+ }
272
+ }
273
+
250
274
  /** Reads what the entry needs before the handler consumes the body or removes the item. */
251
275
  async #beforeAudit(
252
276
  plan: AuditPlan,
package/src/routes.ts CHANGED
@@ -5,9 +5,14 @@
5
5
  import { registerAuditLogHandlers } from './handlers/audit-log.ts'
6
6
  import { registerContentTypeHandlers } from './handlers/content-type.ts'
7
7
  import { registerDataTypeHandlers } from './handlers/data-type.ts'
8
+ import {
9
+ registerDiagnosticsHandlers,
10
+ registerTelemetryAndProfilingHandlers,
11
+ } from './handlers/diagnostics.ts'
8
12
  import { registerDictionaryHandlers } from './handlers/dictionary.ts'
9
13
  import { registerDocumentHandlers } from './handlers/document.ts'
10
14
  import { registerDocumentBlueprintHandlers } from './handlers/document-blueprint.ts'
15
+ import { registerDynamicRootHandlers } from './handlers/dynamic-root.ts'
11
16
  import { registerEditorServiceHandlers } from './handlers/editor-services.ts'
12
17
  import { registerElementHandlers } from './handlers/element.ts'
13
18
  import { registerFileSystemHandlers } from './handlers/file-systems.ts'
@@ -23,14 +28,21 @@ import { registerPackageHandlers } from './handlers/package.ts'
23
28
  import { registerPublicAccessHandlers } from './handlers/public-access.ts'
24
29
  import { registerPublishedCacheHandlers } from './handlers/published-cache.ts'
25
30
  import { registerRedirectHandlers } from './handlers/redirects.ts'
31
+ import { registerReferenceDataHandlers } from './handlers/reference-data.ts'
26
32
  import { registerReferenceHandlers } from './handlers/references.ts'
33
+ import {
34
+ registerRecycleBinReferenceHandlers,
35
+ registerRelationHandlers,
36
+ } from './handlers/relations.ts'
27
37
  import { registerSearchIndexHandlers } from './handlers/search-index.ts'
28
38
  import { registerServerHandlers } from './handlers/server.ts'
29
39
  import { registerSettingsStubHandlers } from './handlers/settings-stubs.ts'
40
+ import { createStaticFiles, registerStaticFileHandlers } from './handlers/static-files.ts'
30
41
  import { registerTemplateHandlers } from './handlers/template.ts'
31
42
  import { registerTemplateQueryHandlers } from './handlers/template-query.ts'
32
43
  import { registerTemporaryFileHandlers } from './handlers/temporary-file.ts'
33
44
  import { registerUserHandlers } from './handlers/user.ts'
45
+ import { registerWebhookHandlers } from './handlers/webhook.ts'
34
46
  import type { ManagementApiDeps } from './ports.ts'
35
47
  import { type Authenticator, ManagementApiRouter, type RouterOptions } from './router.ts'
36
48
 
@@ -43,6 +55,8 @@ export interface ManagementApiOptions {
43
55
  allowLocalLogin?: boolean
44
56
  audit?: RouterOptions['audit']
45
57
  clientAddress?: RouterOptions['clientAddress']
58
+ onError?: RouterOptions['onError']
59
+ onWebhookEvent?: RouterOptions['onWebhookEvent']
46
60
  }
47
61
 
48
62
  export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
@@ -54,6 +68,8 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
54
68
  allowLocalLogin: options.allowLocalLogin,
55
69
  audit: options.audit,
56
70
  clientAddress: options.clientAddress,
71
+ onError: options.onError,
72
+ onWebhookEvent: options.onWebhookEvent,
57
73
  })
58
74
  const { deps } = options
59
75
  if (!deps) return router
@@ -67,6 +83,10 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
67
83
  userData: deps.userData,
68
84
  })
69
85
  registerSettingsStubHandlers(router)
86
+ registerTelemetryAndProfilingHandlers(router)
87
+ registerReferenceDataHandlers(router)
88
+ if (deps.dynamicRoots) registerDynamicRootHandlers(router, deps.dynamicRoots)
89
+ if (deps.diagnostics) registerDiagnosticsHandlers(router, deps.diagnostics)
70
90
  if (deps.contentTypes)
71
91
  registerContentTypeHandlers(router, deps.contentTypes, 'document', deps.temporaryFiles)
72
92
  if (deps.mediaTypes)
@@ -76,7 +96,7 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
76
96
  if (deps.dataTypes) registerDataTypeHandlers(router, deps.dataTypes)
77
97
  if (deps.templates) registerTemplateHandlers(router, deps.templates)
78
98
  if (deps.templateQuery) registerTemplateQueryHandlers(router, deps.templateQuery)
79
- if (deps.documents) registerDocumentHandlers(router, deps.documents, deps.preview)
99
+ if (deps.documents) registerDocumentHandlers(router, deps.documents, deps.preview, deps.approvals)
80
100
  if (deps.blueprints) registerDocumentBlueprintHandlers(router, deps.blueprints)
81
101
  if (deps.temporaryFiles) registerTemporaryFileHandlers(router, deps.temporaryFiles)
82
102
  if (deps.media) registerMediaHandlers(router, deps.media)
@@ -87,6 +107,11 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
87
107
  registerFileSystemHandlers(router, 'PartialView', deps.components, deps.componentSnippets)
88
108
  if (deps.stylesheets) registerFileSystemHandlers(router, 'Stylesheet', deps.stylesheets)
89
109
  if (deps.scripts) registerFileSystemHandlers(router, 'Script', deps.scripts)
110
+ if (deps.stylesheets && deps.scripts)
111
+ registerStaticFileHandlers(
112
+ router,
113
+ createStaticFiles({ css: deps.stylesheets, scripts: deps.scripts }),
114
+ )
90
115
  if (deps.logViewer) registerLogViewerHandlers(router, deps.logViewer)
91
116
  if (deps.modelsBuilder) registerModelsBuilderHandlers(router, deps.modelsBuilder)
92
117
  if (deps.publishedCache) registerPublishedCacheHandlers(router, deps.publishedCache)
@@ -108,9 +133,13 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
108
133
  )
109
134
  if (deps.redirects) registerRedirectHandlers(router, deps.redirects)
110
135
  if (deps.packages) registerPackageHandlers(router, deps.packages)
111
- if (deps.references)
136
+ if (deps.references) {
112
137
  for (const area of ['Document', 'Media', 'Member', 'Element'] as const)
113
138
  registerReferenceHandlers(router, area, deps.references)
139
+ registerRecycleBinReferenceHandlers(router, deps.references)
140
+ }
141
+ if (deps.relations) registerRelationHandlers(router, deps.relations)
142
+ if (deps.webhooks) registerWebhookHandlers(router, deps.webhooks)
114
143
  registerEditorServiceHandlers(router, { tags: deps.tags, oembed: deps.oembed })
115
144
  return router
116
145
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The webhook events this site raises, and which Management API operation
3
+ * raises each. Aliases are Umbraco's, so the backoffice's event picker and an
4
+ * imported site's choices mean the same thing. docs/webhooks.md.
5
+ */
6
+
7
+ export type WebhookEventType = 'Content' | 'Media'
8
+
9
+ export interface WebhookEventInfo {
10
+ alias: string
11
+ eventName: string
12
+ eventType: WebhookEventType
13
+ }
14
+
15
+ export const WEBHOOK_EVENTS: readonly WebhookEventInfo[] = [
16
+ { alias: 'Umbraco.ContentSaved', eventName: 'Content Saved', eventType: 'Content' },
17
+ { alias: 'Umbraco.ContentPublish', eventName: 'Content Published', eventType: 'Content' },
18
+ { alias: 'Umbraco.ContentUnpublish', eventName: 'Content Unpublished', eventType: 'Content' },
19
+ { alias: 'Umbraco.ContentDelete', eventName: 'Content Deleted', eventType: 'Content' },
20
+ {
21
+ alias: 'Umbraco.ContentMovedToRecycleBin',
22
+ eventName: 'Content Moved to Recycle Bin',
23
+ eventType: 'Content',
24
+ },
25
+ { alias: 'Umbraco.ContentMoved', eventName: 'Content Moved', eventType: 'Content' },
26
+ { alias: 'Umbraco.ContentCopied', eventName: 'Content Copied', eventType: 'Content' },
27
+ { alias: 'Umbraco.MediaSave', eventName: 'Media Saved', eventType: 'Media' },
28
+ { alias: 'Umbraco.MediaDelete', eventName: 'Media Deleted', eventType: 'Media' },
29
+ ]
30
+
31
+ export const WEBHOOK_EVENT_ALIASES: ReadonlySet<string> = new Set(
32
+ WEBHOOK_EVENTS.map((event) => event.alias),
33
+ )
34
+
35
+ /** Something happened to one item: what, and to which. */
36
+ export interface RaisedWebhookEvent {
37
+ alias: string
38
+ key: string
39
+ }
40
+
41
+ const BY_OPERATION: Record<string, string> = {
42
+ PostDocument: 'Umbraco.ContentSaved',
43
+ PutDocumentById: 'Umbraco.ContentSaved',
44
+ PutDocumentByIdPublish: 'Umbraco.ContentPublish',
45
+ PutDocumentByIdPublishWithDescendants: 'Umbraco.ContentPublish',
46
+ PostDocumentCreateAndPublish: 'Umbraco.ContentPublish',
47
+ PutDocumentByIdUpdateAndPublish: 'Umbraco.ContentPublish',
48
+ PutDocumentByIdUnpublish: 'Umbraco.ContentUnpublish',
49
+ DeleteDocumentById: 'Umbraco.ContentDelete',
50
+ DeleteRecycleBinDocumentById: 'Umbraco.ContentDelete',
51
+ PutDocumentByIdMoveToRecycleBin: 'Umbraco.ContentMovedToRecycleBin',
52
+ PutDocumentByIdMove: 'Umbraco.ContentMoved',
53
+ PostDocumentByIdCopy: 'Umbraco.ContentCopied',
54
+ PostMedia: 'Umbraco.MediaSave',
55
+ PutMediaById: 'Umbraco.MediaSave',
56
+ DeleteMediaById: 'Umbraco.MediaDelete',
57
+ DeleteRecycleBinMediaById: 'Umbraco.MediaDelete',
58
+ }
59
+
60
+ /**
61
+ * The event a successful operation raises, if any. Pure. The item is the one
62
+ * the operation created (its generated-resource header) or the one it names.
63
+ */
64
+ export function webhookEventFor(
65
+ operationId: string,
66
+ params: Readonly<Record<string, string>>,
67
+ response: Pick<Response, 'status' | 'headers'>,
68
+ ): RaisedWebhookEvent | undefined {
69
+ if (response.status < 200 || response.status >= 300) return undefined
70
+ const alias = BY_OPERATION[operationId]
71
+ if (!alias) return undefined
72
+ const key = response.headers.get('umb-generated-resource') ?? params.id
73
+ return key ? { alias, key: key.toLowerCase() } : undefined
74
+ }