@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.
@@ -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,4 +1,6 @@
1
+ export * from './audit.ts'
1
2
  export * from './authorization.ts'
3
+ export * from './divergences.ts'
2
4
  export * from './handlers/content-type.ts'
3
5
  export * from './handlers/package.ts'
4
6
  export * from './handlers/settings-stubs.ts'
@@ -6,12 +8,15 @@ export * from './handlers/template-query.ts'
6
8
  export * from './handlers/user.ts'
7
9
  export * from './ports.ts'
8
10
  export * from './ports-content.ts'
11
+ export * from './ports-diagnostics.ts'
9
12
  export * from './ports-files.ts'
10
13
  export * from './ports-logs.ts'
11
14
  export * from './ports-members.ts'
12
15
  export * from './ports-models-builder.ts'
13
16
  export * from './ports-packages.ts'
14
17
  export * from './ports-users.ts'
18
+ export * from './ports-webhooks.ts'
15
19
  export * from './router.ts'
16
20
  export * from './routes.ts'
17
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
@@ -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
@@ -18,15 +18,18 @@ import type {
18
18
  MediaPort,
19
19
  RedirectPort,
20
20
  ReferencePort,
21
+ RelationPort,
21
22
  TemplatePort,
22
23
  TemplateQueryPort,
23
24
  } from './ports-content.ts'
25
+ import type { DiagnosticsPort } from './ports-diagnostics.ts'
24
26
  import type { FileSystemPort, Snippet } from './ports-files.ts'
25
27
  import type { LogViewerPort } from './ports-logs.ts'
26
28
  import type { MemberGroupPort, MemberPort, PublicAccessPort } from './ports-members.ts'
27
29
  import type { ModelsBuilderPort } from './ports-models-builder.ts'
28
30
  import type { PackagePort } from './ports-packages.ts'
29
31
  import type { UserDataPort, UserGroupPort, UserPort } from './ports-users.ts'
32
+ import type { WebhookPort } from './ports-webhooks.ts'
30
33
  import type { Principal } from './router.ts'
31
34
 
32
35
  export interface ServerPort {
@@ -111,6 +114,54 @@ export interface PublishedCachePort {
111
114
  isRebuilding(): boolean
112
115
  }
113
116
 
117
+ /** An index as the Examine dashboard lists it. */
118
+ export interface SearchIndexInfo {
119
+ name: string
120
+ searcherName: string
121
+ documentCount: number
122
+ fieldCount: number
123
+ /** Items waiting to be indexed; while any are, the index reports itself rebuilding. */
124
+ pending: number
125
+ }
126
+
127
+ /** An item's entries in the audit log, for its Info tab. docs/audit-log.md. */
128
+ export interface AuditHistoryPort {
129
+ forItem(
130
+ key: string,
131
+ ): Promise<
132
+ Array<{ userKey: string | null; occurredAt: Date; action: string; detail: string | null }>
133
+ >
134
+ /** When the log began; an item's versions from before then stand in for its entries. */
135
+ startedAt(): Promise<Date | undefined>
136
+ /** Entries older than this have been aged out, and versions standing in for them are too. */
137
+ retainedSince(): Date | undefined
138
+ }
139
+
140
+ /** The backoffice search index behind the Examine dashboard. docs/search.md. */
141
+ export interface SearchIndexPort {
142
+ indexes(): Promise<SearchIndexInfo[]>
143
+ index(name: string): Promise<SearchIndexInfo | undefined>
144
+ /** False when there is no index by that name. */
145
+ rebuild(name: string): Promise<boolean>
146
+ searchers(): string[]
147
+ /** Undefined when there is no searcher by that name. */
148
+ query(
149
+ searcherName: string,
150
+ term: string,
151
+ paging: { skip: number; take: number },
152
+ ): Promise<
153
+ | {
154
+ total: number
155
+ items: Array<{
156
+ key: string
157
+ score: number
158
+ fields: Array<{ name: string; values: string[] }>
159
+ }>
160
+ }
161
+ | undefined
162
+ >
163
+ }
164
+
114
165
  /** Embed markup for a URL, from its oEmbed provider. */
115
166
  export interface OEmbedPort {
116
167
  markup(
@@ -122,6 +173,16 @@ export interface OEmbedPort {
122
173
  >
123
174
  }
124
175
 
176
+ /** A content picker's start node, found from the document being edited. */
177
+ export interface DynamicRootPort {
178
+ resolve(query: {
179
+ currentKey: string | null
180
+ parentKey: string | null
181
+ origin: { alias: string; key: string | null }
182
+ steps: Array<{ alias: string; documentTypeKeys: string[] }>
183
+ }): Promise<string[]>
184
+ }
185
+
125
186
  /** Preview mode is a cookie; the server owns its name and attributes. */
126
187
  export interface PreviewPort {
127
188
  /** `Set-Cookie` values that enter preview for the signed-in editor. */
@@ -185,11 +246,14 @@ export interface AuthPort {
185
246
 
186
247
  export interface ManagementApiDeps {
187
248
  server: ServerPort
249
+ /** Health checks, troubleshooting and the upgrade check; needs the database. */
250
+ diagnostics?: DiagnosticsPort
188
251
  manifests: ManifestPort
189
252
  localization: LocalizationPort
190
253
  currentUser: CurrentUserPort
191
254
  users?: UserItemPort
192
255
  preview?: PreviewPort
256
+ dynamicRoots?: DynamicRootPort
193
257
  temporaryFiles?: TemporaryFilePort
194
258
  media?: MediaPort
195
259
  tags?: TagPort
@@ -213,6 +277,8 @@ export interface ManagementApiDeps {
213
277
  logViewer?: LogViewerPort
214
278
  modelsBuilder?: ModelsBuilderPort
215
279
  publishedCache?: PublishedCachePort
280
+ searchIndex?: SearchIndexPort
281
+ auditHistory?: AuditHistoryPort
216
282
  /** The template editor's query builder. */
217
283
  templateQuery?: TemplateQueryPort
218
284
  memberGroups?: MemberGroupPort
@@ -220,6 +286,8 @@ export interface ManagementApiDeps {
220
286
  publicAccess?: PublicAccessPort
221
287
  elements?: ElementPort
222
288
  references?: ReferencePort
289
+ relations?: RelationPort
290
+ webhooks?: WebhookPort
223
291
  redirects?: RedirectPort
224
292
  packages?: PackagePort
225
293
  }