@crombolo/api-management 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +32 -4
  4. package/src/authorization.ts +453 -0
  5. package/src/handlers/content-type.ts +784 -0
  6. package/src/handlers/data-type.ts +267 -0
  7. package/src/handlers/dictionary.ts +245 -0
  8. package/src/handlers/document-blueprint.ts +193 -0
  9. package/src/handlers/document.ts +961 -0
  10. package/src/handlers/editor-services.ts +59 -0
  11. package/src/handlers/element.ts +492 -0
  12. package/src/handlers/file-systems.ts +207 -0
  13. package/src/handlers/import.ts +46 -0
  14. package/src/handlers/localization.ts +116 -0
  15. package/src/handlers/log-viewer.ts +125 -0
  16. package/src/handlers/manifest.ts +15 -0
  17. package/src/handlers/media.ts +453 -0
  18. package/src/handlers/member-group.ts +87 -0
  19. package/src/handlers/member.ts +189 -0
  20. package/src/handlers/models-builder.ts +29 -0
  21. package/src/handlers/package.ts +193 -0
  22. package/src/handlers/public-access.ts +107 -0
  23. package/src/handlers/published-cache.ts +27 -0
  24. package/src/handlers/redirects.ts +78 -0
  25. package/src/handlers/references.ts +44 -0
  26. package/src/handlers/server.ts +25 -0
  27. package/src/handlers/settings-stubs.ts +76 -0
  28. package/src/handlers/start-node-trees.ts +84 -0
  29. package/src/handlers/template-query.ts +191 -0
  30. package/src/handlers/template.ts +150 -0
  31. package/src/handlers/temporary-file.ts +62 -0
  32. package/src/handlers/user.ts +604 -0
  33. package/src/index.ts +17 -0
  34. package/src/ports-content.ts +564 -0
  35. package/src/ports-files.ts +51 -0
  36. package/src/ports-logs.ts +38 -0
  37. package/src/ports-members.ts +111 -0
  38. package/src/ports-models-builder.ts +26 -0
  39. package/src/ports-packages.ts +59 -0
  40. package/src/ports-users.ts +230 -0
  41. package/src/ports.ts +225 -0
  42. package/src/router.ts +216 -0
  43. package/src/routes.ts +99 -0
  44. package/src/server-events.ts +71 -0
  45. package/README.md +0 -3
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Partial views, stylesheets and scripts: one file-system area each, served by
3
+ * the same handlers — the tree, items, the file and its folders — keyed by path.
4
+ */
5
+ import {
6
+ invalidSkipTake,
7
+ notFound,
8
+ paged,
9
+ parseSkipTake,
10
+ problemDetails,
11
+ problemResponse,
12
+ } from '@crombolo/core'
13
+ import type { FileEntry, FileResult, FileStatus, FileSystemPort, Snippet } from '../ports-files.ts'
14
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
15
+
16
+ export type FileArea = 'PartialView' | 'Stylesheet' | 'Script'
17
+
18
+ const LABEL: Record<FileArea, string> = {
19
+ PartialView: 'Partial view',
20
+ Stylesheet: 'Stylesheet',
21
+ Script: 'Script',
22
+ }
23
+ const ROUTE: Record<FileArea, string> = {
24
+ PartialView: 'partial-view',
25
+ Stylesheet: 'stylesheet',
26
+ Script: 'script',
27
+ }
28
+
29
+ const parentRef = (parentPath: string | null) => (parentPath ? { path: parentPath } : null)
30
+
31
+ const treeItem = (entry: FileEntry) => ({
32
+ name: entry.name,
33
+ path: entry.path,
34
+ parent: parentRef(entry.parentPath),
35
+ isFolder: entry.isFolder,
36
+ hasChildren: entry.hasChildren,
37
+ })
38
+
39
+ function failure(area: FileArea, status: FileStatus, folder = false): Response {
40
+ const thing = folder ? 'Folder' : LABEL[area]
41
+ const [code, title] = (
42
+ {
43
+ AlreadyExists: [400, `${thing} already exists`],
44
+ NotFound: [404, `${thing} not found`],
45
+ ParentNotFound: [404, 'Parent not found'],
46
+ InvalidName: [400, 'Invalid name'],
47
+ InvalidFileExtension: [400, 'Invalid file extension'],
48
+ NotEmpty: [400, 'Folder is not empty'],
49
+ } as const
50
+ )[status]
51
+ return problemResponse(problemDetails({ title, status: code, operationStatus: status }))
52
+ }
53
+
54
+ async function body(ctx: RequestContext): Promise<Record<string, unknown>> {
55
+ return ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
56
+ }
57
+
58
+ const parentPathOf = (value: unknown): string | null => {
59
+ const path = (value as { path?: unknown } | null | undefined)?.path
60
+ return typeof path === 'string' && path !== '' && path !== '/' ? path : null
61
+ }
62
+
63
+ export function registerFileSystemHandlers(
64
+ router: ManagementApiRouter,
65
+ area: FileArea,
66
+ port: FileSystemPort,
67
+ snippets: readonly Snippet[] = [],
68
+ ): void {
69
+ const base = `/umbraco/management/api/v1/${ROUTE[area]}`
70
+ const created = (ctx: RequestContext, path: string, folder = false) =>
71
+ new Response(null, {
72
+ status: 201,
73
+ headers: {
74
+ location: `${ctx.url.origin}${base}${folder ? '/folder' : ''}/${encodeURIComponent(path)}`,
75
+ 'umb-generated-resource': encodeURIComponent(path),
76
+ },
77
+ })
78
+ const result = (ctx: RequestContext, outcome: FileResult, create: boolean, folder = false) => {
79
+ if (!outcome.ok) return failure(area, outcome.status, folder)
80
+ return create ? created(ctx, outcome.path, folder) : new Response(null, { status: 200 })
81
+ }
82
+ const page = (ctx: RequestContext, entries: readonly FileEntry[] | undefined) => {
83
+ const parsed = parseSkipTake(ctx.url.searchParams)
84
+ if (!parsed.ok) return problemResponse(invalidSkipTake())
85
+ const all = (entries ?? []).map(treeItem)
86
+ const { skip, take } = parsed.value
87
+ return Response.json(paged(all.slice(skip, skip + take), all.length))
88
+ }
89
+
90
+ router.handle(`GetTree${area}Root`, async (ctx) => page(ctx, await port.children(null)))
91
+ router.handle(`GetTree${area}Children`, async (ctx) =>
92
+ page(ctx, await port.children(ctx.url.searchParams.get('parentPath') || null)),
93
+ )
94
+ router.handle(`GetTree${area}Ancestors`, async (ctx) => {
95
+ const path = ctx.url.searchParams.get('descendantPath')
96
+ const chain: FileEntry[] = []
97
+ let current = path ? await port.entry(path) : undefined
98
+ while (current) {
99
+ chain.unshift(current)
100
+ current = current.parentPath ? await port.entry(current.parentPath) : undefined
101
+ }
102
+ return Response.json(chain.map(treeItem))
103
+ })
104
+ router.handle(`GetTree${area}Siblings`, async (ctx) => {
105
+ const path = ctx.url.searchParams.get('path') ?? ''
106
+ const target = await port.entry(path)
107
+ // As the tree answers for a path it cannot find: no siblings
108
+ if (!target) return Response.json({ totalBefore: 0, totalAfter: 0, items: [] })
109
+ const siblings = (await port.children(target.parentPath)) ?? []
110
+ const index = siblings.findIndex((s) => s.path === target.path)
111
+ const before = Math.max(0, Number(ctx.url.searchParams.get('before') ?? 0))
112
+ const after = Math.max(0, Number(ctx.url.searchParams.get('after') ?? 0))
113
+ const from = Math.max(0, index - before)
114
+ return Response.json({
115
+ totalBefore: from,
116
+ totalAfter: Math.max(0, siblings.length - (index + after + 1)),
117
+ items: siblings.slice(from, index + after + 1).map(treeItem),
118
+ })
119
+ })
120
+
121
+ router.handle(`GetItem${area}`, async (ctx) => {
122
+ const items = []
123
+ for (const path of ctx.url.searchParams.getAll('path')) {
124
+ const entry = await port.entry(path)
125
+ if (entry)
126
+ items.push({
127
+ isFolder: entry.isFolder,
128
+ name: entry.name,
129
+ parent: parentRef(entry.parentPath),
130
+ path: entry.path,
131
+ })
132
+ }
133
+ return Response.json(items)
134
+ })
135
+
136
+ router.handle(`Get${area}ByPath`, async (ctx) => {
137
+ const file = await port.read(ctx.params.path as string)
138
+ if (!file) return failure(area, 'NotFound')
139
+ return Response.json({
140
+ name: file.name,
141
+ path: file.path,
142
+ parent: parentRef(file.parentPath),
143
+ content: file.content,
144
+ })
145
+ })
146
+ router.handle(`Post${area}`, async (ctx) => {
147
+ const b = await body(ctx)
148
+ const outcome = await port.create(
149
+ parentPathOf(b.parent),
150
+ String(b.name ?? ''),
151
+ typeof b.content === 'string' ? b.content : '',
152
+ )
153
+ return result(ctx, outcome, true)
154
+ })
155
+ router.handle(`Put${area}ByPath`, async (ctx) => {
156
+ const b = await body(ctx)
157
+ const content = typeof b.content === 'string' ? b.content : ''
158
+ return result(ctx, await port.update(ctx.params.path as string, content), false)
159
+ })
160
+ router.handle(`Put${area}ByPathRename`, async (ctx) => {
161
+ const b = await body(ctx)
162
+ return result(ctx, await port.rename(ctx.params.path as string, String(b.name ?? '')), true)
163
+ })
164
+ router.handle(`Delete${area}ByPath`, async (ctx) =>
165
+ result(ctx, await port.delete(ctx.params.path as string), false),
166
+ )
167
+
168
+ router.handle(`Post${area}Folder`, async (ctx) => {
169
+ const b = await body(ctx)
170
+ return result(
171
+ ctx,
172
+ await port.createFolder(parentPathOf(b.parent), String(b.name ?? '')),
173
+ true,
174
+ true,
175
+ )
176
+ })
177
+ router.handle(`Get${area}FolderByPath`, async (ctx) => {
178
+ const entry = await port.entry(ctx.params.path as string)
179
+ if (!entry?.isFolder) return failure(area, 'NotFound', true)
180
+ return Response.json({
181
+ name: entry.name,
182
+ path: entry.path,
183
+ parent: parentRef(entry.parentPath),
184
+ })
185
+ })
186
+ router.handle(`Delete${area}FolderByPath`, async (ctx) =>
187
+ result(ctx, await port.deleteFolder(ctx.params.path as string), false, true),
188
+ )
189
+
190
+ if (area !== 'PartialView') return
191
+ router.handle('GetPartialViewSnippet', (ctx) => {
192
+ const parsed = parseSkipTake(ctx.url.searchParams)
193
+ if (!parsed.ok) return problemResponse(invalidSkipTake())
194
+ const { skip, take } = parsed.value
195
+ return Response.json(
196
+ paged(
197
+ snippets.slice(skip, skip + take).map((s) => ({ id: s.id, name: s.name })),
198
+ snippets.length,
199
+ ),
200
+ )
201
+ })
202
+ router.handle('GetPartialViewSnippetById', (ctx) => {
203
+ const snippet = snippets.find((s) => s.id === ctx.params.id)
204
+ if (!snippet) return problemResponse(notFound('Snippet not found'))
205
+ return Response.json(snippet)
206
+ })
207
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What an uploaded file turns out to be, which the client asks before it offers
3
+ * to import: the entity type, and the alias and key inside the file so it can
4
+ * tell a new type from an update to an existing one.
5
+ */
6
+ import { notFound, problemDetails, problemResponse } from '@crombolo/core'
7
+ import type { TemporaryFilePort } from '../ports.ts'
8
+ import type { ManagementApiRouter } from '../router.ts'
9
+
10
+ /** The root element names Umbraco's `.udt` exports use, to their entity types. */
11
+ const ENTITY_OF: Record<string, string> = {
12
+ documenttype: 'document-type',
13
+ mediatype: 'media-type',
14
+ membertype: 'member-type',
15
+ datatype: 'data-type',
16
+ dictionaryitem: 'dictionary-item',
17
+ }
18
+
19
+ export function registerImportHandlers(
20
+ router: ManagementApiRouter,
21
+ temporaryFiles: TemporaryFilePort,
22
+ ): void {
23
+ router.handle('GetImportAnalyze', async (ctx) => {
24
+ const id = ctx.url.searchParams.get('temporaryFileId')
25
+ if (!id)
26
+ return problemResponse(
27
+ problemDetails({ title: 'A temporary file id is required', status: 400 }),
28
+ )
29
+ const file = await temporaryFiles.read(id)
30
+ if (!file) return problemResponse(notFound('The file could not be found'))
31
+ const text = new TextDecoder().decode(file.bytes)
32
+ const root = /<\s*([A-Za-z][\w.-]*)/.exec(text.replace(/<\?[^?]*\?>/g, ''))?.[1]
33
+ const entityType = root ? ENTITY_OF[root.toLowerCase()] : undefined
34
+ if (!entityType)
35
+ return problemResponse(
36
+ problemDetails({
37
+ title: 'The file could not be read',
38
+ detail: 'It is not an Umbraco export.',
39
+ status: 400,
40
+ }),
41
+ )
42
+ const inside = (name: string) =>
43
+ new RegExp(`<${name}>([^<]*)</${name}>`, 'i').exec(text)?.[1]?.trim() || null
44
+ return Response.json({ entityType, alias: inside('Alias'), key: inside('Key') })
45
+ })
46
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Cultures and languages. UI translation strings are client-side manifests, not
3
+ * server endpoints — these describe which cultures exist, not what words mean.
4
+ */
5
+ import {
6
+ invalidSkipTake,
7
+ notFound,
8
+ paged,
9
+ parseSkipTake,
10
+ problemDetails,
11
+ problemResponse,
12
+ } from '@crombolo/core'
13
+ import type { LanguageInput, LocalizationPort } from '../ports.ts'
14
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
15
+
16
+ /** Applies Umbraco's paging contract, including the skip-multiple-of-take rule. */
17
+ function pageOf<T>(ctx: RequestContext, items: readonly T[]): Response {
18
+ const skipTake = parseSkipTake(ctx.url.searchParams)
19
+ if (!skipTake.ok) return problemResponse(invalidSkipTake())
20
+ const { skip, take } = skipTake.value
21
+ const window = take === 0 ? [] : items.slice(skip, skip + take)
22
+ return Response.json(paged([...window], items.length))
23
+ }
24
+
25
+ export function registerLocalizationHandlers(
26
+ router: ManagementApiRouter,
27
+ localization: LocalizationPort,
28
+ ): void {
29
+ router.handle('GetCulture', (ctx) => pageOf(ctx, localization.cultures()))
30
+ // Segments come from a segment provider; like Umbraco without one, there are none
31
+ router.handle('GetSegment', (ctx) => pageOf(ctx, []))
32
+ router.handle('GetDocumentByIdAvailableSegmentOptions', (ctx) => pageOf(ctx, []))
33
+ router.handle('GetLanguage', async (ctx) => pageOf(ctx, await localization.allLanguages()))
34
+ router.handle('GetItemLanguageDefault', async () => {
35
+ const languages = await localization.allLanguages()
36
+ const fallback = languages.find((l) => l.isDefault) ?? languages[0]
37
+ return Response.json(
38
+ fallback ?? {
39
+ isoCode: 'en-US',
40
+ name: 'English (United States)',
41
+ isDefault: true,
42
+ isMandatory: true,
43
+ fallbackIsoCode: null,
44
+ },
45
+ )
46
+ })
47
+ router.handle('GetItemLanguage', async (ctx) => {
48
+ const wanted = new Set(ctx.url.searchParams.getAll('isoCode').map((c) => c.toLowerCase()))
49
+ const languages = await localization.allLanguages()
50
+ return Response.json(
51
+ languages
52
+ .filter((l) => wanted.size === 0 || wanted.has(l.isoCode.toLowerCase()))
53
+ .map((l) => ({ name: l.name, isoCode: l.isoCode })),
54
+ )
55
+ })
56
+ router.handle('GetLanguageByIsoCode', async ({ params }) => {
57
+ const language = await localization.language(params.isoCode as string)
58
+ if (!language) return problemResponse(notFound('The language could not be found'))
59
+ return Response.json(language)
60
+ })
61
+ router.handle('PostLanguage', async (ctx) => {
62
+ const body = (await ctx.request.json()) as Record<string, unknown>
63
+ const isoCode = String(body.isoCode ?? '')
64
+ if (!isoCode)
65
+ return problemResponse(problemDetails({ title: 'isoCode is required', status: 400 }))
66
+ if (await localization.language(isoCode))
67
+ return problemResponse(
68
+ problemDetails({
69
+ title: 'The language already exists',
70
+ status: 400,
71
+ operationStatus: 'DuplicateIsoCode',
72
+ }),
73
+ )
74
+ await localization.saveLanguage(readLanguage(isoCode, body))
75
+ ctx.notifications.push({ message: 'Language created', category: 'Language', type: 'Success' })
76
+ return new Response(null, {
77
+ status: 201,
78
+ headers: { location: `${ctx.url.origin}/umbraco/management/api/v1/language/${isoCode}` },
79
+ })
80
+ })
81
+ router.handle('PutLanguageByIsoCode', async (ctx) => {
82
+ const isoCode = ctx.params.isoCode as string
83
+ const existing = await localization.language(isoCode)
84
+ if (!existing) return problemResponse(notFound('The language could not be found'))
85
+ const body = (await ctx.request.json()) as Record<string, unknown>
86
+ await localization.saveLanguage(readLanguage(existing.isoCode, body))
87
+ ctx.notifications.push({ message: 'Language saved', category: 'Language', type: 'Success' })
88
+ return new Response(null, { status: 200 })
89
+ })
90
+ router.handle('DeleteLanguageByIsoCode', async (ctx) => {
91
+ const result = await localization.deleteLanguage(ctx.params.isoCode as string)
92
+ if (result === 'not-found') return problemResponse(notFound('The language could not be found'))
93
+ if (result !== 'deleted')
94
+ return problemResponse(
95
+ problemDetails({
96
+ title:
97
+ result === 'default'
98
+ ? 'The default language cannot be deleted'
99
+ : 'Another language falls back to this one',
100
+ status: 400,
101
+ }),
102
+ )
103
+ ctx.notifications.push({ message: 'Language deleted', category: 'Language', type: 'Success' })
104
+ return new Response(null, { status: 200 })
105
+ })
106
+ }
107
+
108
+ function readLanguage(isoCode: string, body: Record<string, unknown>): LanguageInput {
109
+ return {
110
+ isoCode,
111
+ name: String(body.name ?? isoCode),
112
+ isDefault: body.isDefault === true,
113
+ isMandatory: body.isMandatory === true,
114
+ fallbackIsoCode: typeof body.fallbackIsoCode === 'string' ? body.fallbackIsoCode : null,
115
+ }
116
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The log viewer: levels, level counts, message templates, the log itself and
3
+ * saved searches, with Umbraco's defaults — the last day when either date is
4
+ * missing, 100 per page, newest first — and its size check before any read.
5
+ */
6
+ import {
7
+ invalidSkipTake,
8
+ notFound,
9
+ paged,
10
+ parseSkipTake,
11
+ problemDetails,
12
+ problemResponse,
13
+ } from '@crombolo/core'
14
+ import type { LogLevelName, LogRange, LogViewerPort } from '../ports-logs.ts'
15
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
16
+
17
+ const LEVELS: readonly LogLevelName[] = [
18
+ 'Verbose',
19
+ 'Debug',
20
+ 'Information',
21
+ 'Warning',
22
+ 'Error',
23
+ 'Fatal',
24
+ ]
25
+
26
+ const tooLarge = () =>
27
+ problemResponse(
28
+ problemDetails({
29
+ title: 'Cancelled due to log file size',
30
+ detail: 'The log file size for the requested date range prevented the operation.',
31
+ status: 400,
32
+ operationStatus: 'CancelledByLogsSizeValidation',
33
+ }),
34
+ )
35
+
36
+ function dateParam(ctx: RequestContext, name: string): Date | null {
37
+ const value = ctx.url.searchParams.get(name)
38
+ if (!value) return null
39
+ const date = new Date(value)
40
+ return Number.isNaN(date.getTime()) ? null : date
41
+ }
42
+
43
+ const rangeOf = (ctx: RequestContext): LogRange => ({
44
+ start: dateParam(ctx, 'startDate'),
45
+ end: dateParam(ctx, 'endDate'),
46
+ })
47
+
48
+ function page<T>(ctx: RequestContext, items: readonly T[]): Response {
49
+ const parsed = parseSkipTake(ctx.url.searchParams)
50
+ if (!parsed.ok) return problemResponse(invalidSkipTake())
51
+ const { skip, take } = parsed.value
52
+ return Response.json(paged(items.slice(skip, skip + take), items.length))
53
+ }
54
+
55
+ export function registerLogViewerHandlers(router: ManagementApiRouter, logs: LogViewerPort): void {
56
+ router.handle('GetLogViewerLevel', (ctx) => page(ctx, logs.loggers()))
57
+
58
+ router.handle('GetLogViewerValidateLogsSize', async (ctx) =>
59
+ (await logs.canView(rangeOf(ctx))) ? new Response(null, { status: 200 }) : tooLarge(),
60
+ )
61
+
62
+ router.handle('GetLogViewerLevelCount', async (ctx) => {
63
+ const range = rangeOf(ctx)
64
+ if (!(await logs.canView(range))) return tooLarge()
65
+ return Response.json(await logs.levelCounts(range))
66
+ })
67
+
68
+ router.handle('GetLogViewerMessageTemplate', async (ctx) => {
69
+ const range = rangeOf(ctx)
70
+ if (!(await logs.canView(range))) return tooLarge()
71
+ return page(ctx, await logs.templates(range))
72
+ })
73
+
74
+ router.handle('GetLogViewerLog', async (ctx) => {
75
+ const range = rangeOf(ctx)
76
+ if (!(await logs.canView(range))) return tooLarge()
77
+ const q = ctx.url.searchParams
78
+ const levels = q
79
+ .getAll('logLevel')
80
+ .filter((l): l is LogLevelName => LEVELS.includes(l as LogLevelName))
81
+ const entries = await logs.logs(range, {
82
+ filter: q.get('filterExpression'),
83
+ levels,
84
+ direction: q.get('orderDirection') === 'Ascending' ? 'Ascending' : 'Descending',
85
+ })
86
+ return page(ctx, entries)
87
+ })
88
+
89
+ router.handle('GetLogViewerSavedSearch', async (ctx) => page(ctx, await logs.savedSearches()))
90
+
91
+ router.handle('GetLogViewerSavedSearchByName', async (ctx) => {
92
+ const found = await logs.savedSearch(ctx.params.name as string)
93
+ if (!found) return problemResponse(notFound('The saved search could not be found'))
94
+ return Response.json(found)
95
+ })
96
+
97
+ router.handle('PostLogViewerSavedSearch', async (ctx) => {
98
+ const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
99
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
100
+ const query = typeof body.query === 'string' ? body.query : ''
101
+ if (!name) return problemResponse(problemDetails({ title: 'A name is required', status: 400 }))
102
+ if ((await logs.saveSearch(name, query)) === 'duplicate')
103
+ return problemResponse(
104
+ problemDetails({
105
+ title: 'Duplicate log search name',
106
+ detail: 'Another log search already exists with the same name.',
107
+ status: 400,
108
+ operationStatus: 'DuplicateLogSearch',
109
+ }),
110
+ )
111
+ return new Response(null, {
112
+ status: 201,
113
+ headers: {
114
+ location: `${ctx.url.origin}/umbraco/management/api/v1/log-viewer/saved-search/${encodeURIComponent(name)}`,
115
+ 'umb-generated-resource': name,
116
+ },
117
+ })
118
+ })
119
+
120
+ router.handle('DeleteLogViewerSavedSearchByName', async (ctx) =>
121
+ (await logs.deleteSearch(ctx.params.name as string))
122
+ ? new Response(null, { status: 200 })
123
+ : problemResponse(notFound('The saved search could not be found')),
124
+ )
125
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Extension discovery. `/public` is anonymous and feeds the login screen;
3
+ * `/private` requires a signed-in user and carries the rest.
4
+ */
5
+ import type { ManifestPort } from '../ports.ts'
6
+ import type { ManagementApiRouter } from '../router.ts'
7
+
8
+ export function registerManifestHandlers(
9
+ router: ManagementApiRouter,
10
+ manifests: ManifestPort,
11
+ ): void {
12
+ router.handle('GetManifestManifest', () => Response.json(manifests.list('all')))
13
+ router.handle('GetManifestManifestPublic', () => Response.json(manifests.list('public')))
14
+ router.handle('GetManifestManifestPrivate', () => Response.json(manifests.list('private')))
15
+ }