@igstack/app-catalog-backend-core 0.0.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1934 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +2539 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +84 -0
  7. package/prisma/migrations/20250526183023_init/migration.sql +71 -0
  8. package/prisma/migrations/migration_lock.toml +3 -0
  9. package/prisma/schema.prisma +149 -0
  10. package/src/__tests__/dummy.test.ts +7 -0
  11. package/src/db/client.ts +42 -0
  12. package/src/db/index.ts +21 -0
  13. package/src/db/syncAppCatalog.ts +312 -0
  14. package/src/db/tableSyncMagazine.ts +32 -0
  15. package/src/db/tableSyncPrismaAdapter.ts +203 -0
  16. package/src/index.ts +126 -0
  17. package/src/middleware/backendResolver.ts +42 -0
  18. package/src/middleware/createEhMiddleware.ts +171 -0
  19. package/src/middleware/database.ts +62 -0
  20. package/src/middleware/featureRegistry.ts +173 -0
  21. package/src/middleware/index.ts +43 -0
  22. package/src/middleware/types.ts +202 -0
  23. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  24. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  25. package/src/modules/appCatalog/service.ts +130 -0
  26. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
  27. package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
  28. package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
  29. package/src/modules/approvalMethod/slugUtils.ts +17 -0
  30. package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
  31. package/src/modules/assets/assetRestController.ts +271 -0
  32. package/src/modules/assets/assetUtils.ts +114 -0
  33. package/src/modules/assets/screenshotRestController.ts +195 -0
  34. package/src/modules/assets/screenshotRouter.ts +112 -0
  35. package/src/modules/assets/syncAssets.ts +277 -0
  36. package/src/modules/assets/upsertAsset.ts +46 -0
  37. package/src/modules/auth/auth.ts +51 -0
  38. package/src/modules/auth/authProviders.ts +40 -0
  39. package/src/modules/auth/authRouter.ts +75 -0
  40. package/src/modules/auth/authorizationUtils.ts +132 -0
  41. package/src/modules/auth/devMockUserUtils.ts +49 -0
  42. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  43. package/src/modules/icons/iconRestController.ts +171 -0
  44. package/src/modules/icons/iconRouter.ts +180 -0
  45. package/src/modules/icons/iconService.ts +73 -0
  46. package/src/modules/icons/iconUtils.ts +46 -0
  47. package/src/prisma-json-types.d.ts +34 -0
  48. package/src/server/controller.ts +47 -0
  49. package/src/server/ehStaticControllerContract.ts +19 -0
  50. package/src/server/ehTrpcContext.ts +26 -0
  51. package/src/server/trpcSetup.ts +89 -0
  52. package/src/types/backend/api.ts +73 -0
  53. package/src/types/backend/common.ts +10 -0
  54. package/src/types/backend/companySpecificBackend.ts +5 -0
  55. package/src/types/backend/dataSources.ts +25 -0
  56. package/src/types/backend/deployments.ts +40 -0
  57. package/src/types/common/app/appTypes.ts +13 -0
  58. package/src/types/common/app/ui/appUiTypes.ts +12 -0
  59. package/src/types/common/appCatalogTypes.ts +65 -0
  60. package/src/types/common/approvalMethodTypes.ts +149 -0
  61. package/src/types/common/env/envTypes.ts +7 -0
  62. package/src/types/common/resourceTypes.ts +8 -0
  63. package/src/types/common/sharedTypes.ts +5 -0
  64. package/src/types/index.ts +21 -0
@@ -0,0 +1,46 @@
1
+ import { parseAssetMeta } from './assetUtils'
2
+ import type { AssetType, PrismaClient } from '@prisma/client'
3
+
4
+ export interface UpsertAssetParams {
5
+ prisma: PrismaClient
6
+ buffer: Buffer
7
+ name: string
8
+ originalFilename: string
9
+ assetType: AssetType
10
+ }
11
+
12
+ export async function upsertAsset({
13
+ prisma,
14
+ buffer,
15
+ name,
16
+ originalFilename,
17
+ assetType,
18
+ }: UpsertAssetParams) {
19
+ const { checksum, fileSize, width, height, mimeType } = await parseAssetMeta({
20
+ buffer,
21
+ originalFilename,
22
+ })
23
+
24
+ // If an asset with the same checksum already exists, reuse it instead of storing duplicate binary.
25
+ const existing = await prisma.dbAsset.findUnique({
26
+ where: { name },
27
+ })
28
+
29
+ if (existing) {
30
+ return existing.id
31
+ }
32
+
33
+ const asset = await prisma.dbAsset.create({
34
+ data: {
35
+ name,
36
+ checksum,
37
+ assetType,
38
+ content: new Uint8Array(buffer),
39
+ mimeType,
40
+ fileSize,
41
+ width,
42
+ height,
43
+ },
44
+ })
45
+ return asset.id
46
+ }
@@ -0,0 +1,51 @@
1
+ import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
2
+ import { betterAuth } from 'better-auth'
3
+ import { prismaAdapter } from 'better-auth/adapters/prisma'
4
+ import { getDbClient } from '../../db'
5
+
6
+ export interface AuthConfig {
7
+ appName?: string
8
+ baseURL: string
9
+ secret: string
10
+ providers?: BetterAuthOptions['socialProviders']
11
+ plugins?: Array<BetterAuthPlugin>
12
+ /** Session expiration in seconds. Default: 7 days (604800) */
13
+ sessionExpiresIn?: number
14
+ /** Session update age in seconds. Default: 1 day (86400) */
15
+ sessionUpdateAge?: number
16
+ }
17
+
18
+ export function createAuth(config: AuthConfig) {
19
+ const prisma = getDbClient()
20
+ const isProduction = process.env.NODE_ENV === 'production'
21
+
22
+ const auth = betterAuth({
23
+ appName: config.appName || 'EnvHopper',
24
+ baseURL: config.baseURL,
25
+ basePath: '/api/auth',
26
+ secret: config.secret,
27
+ database: prismaAdapter(prisma, {
28
+ provider: 'postgresql',
29
+ }),
30
+ socialProviders: config.providers || {},
31
+ plugins: config.plugins || [],
32
+ emailAndPassword: {
33
+ enabled: true,
34
+ },
35
+ session: {
36
+ expiresIn: config.sessionExpiresIn ?? 60 * 60 * 24 * 30,
37
+ updateAge: config.sessionUpdateAge ?? 60 * 60 * 24,
38
+ cookieCache: {
39
+ enabled: true,
40
+ maxAge: 300,
41
+ },
42
+ },
43
+ advanced: {
44
+ useSecureCookies: isProduction,
45
+ },
46
+ })
47
+
48
+ return auth
49
+ }
50
+
51
+ export type BetterAuth = ReturnType<typeof createAuth>
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Auth provider types and utilities
3
+ *
4
+ * Note: app-catalog backend-core does not read environment variables directly.
5
+ * Auth configuration (providers, plugins, admin groups) should be passed via
6
+ * middleware parameters by the client application.
7
+ *
8
+ * Example client-side configuration:
9
+ *
10
+ * ```typescript
11
+ * import { genericOAuth, okta } from 'better-auth/plugins'
12
+ *
13
+ * const authConfig = {
14
+ * baseURL: process.env.BETTER_AUTH_URL,
15
+ * secret: process.env.BETTER_AUTH_SECRET,
16
+ * providers: {
17
+ * github: {
18
+ * clientId: process.env.AUTH_GITHUB_CLIENT_ID,
19
+ * clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET,
20
+ * },
21
+ * google: {
22
+ * clientId: process.env.AUTH_GOOGLE_CLIENT_ID,
23
+ * clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET,
24
+ * },
25
+ * },
26
+ * plugins: [
27
+ * genericOAuth({
28
+ * config: [
29
+ * okta({
30
+ * clientId: process.env.AUTH_OKTA_CLIENT_ID,
31
+ * clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET,
32
+ * issuer: process.env.AUTH_OKTA_ISSUER,
33
+ * }),
34
+ * ],
35
+ * }),
36
+ * ],
37
+ * adminGroups: ['env_hopper_ui_super_admins'],
38
+ * }
39
+ * ```
40
+ */
@@ -0,0 +1,75 @@
1
+ import type { BetterAuthPlugin } from 'better-auth'
2
+ import type { TRPCRootObject } from '@trpc/server'
3
+ import type { EhTrpcContext } from '../../server/ehTrpcContext'
4
+ import type { BetterAuth } from './auth'
5
+
6
+ /**
7
+ * Create auth tRPC procedures
8
+ * @param t - tRPC instance
9
+ * @param auth - Better Auth instance (optional, for future extensions)
10
+ * @returns tRPC router with auth procedures
11
+ */
12
+ export function createAuthRouter(
13
+ t: TRPCRootObject<EhTrpcContext, {}, {}>,
14
+ auth?: BetterAuth,
15
+ ) {
16
+ const router = t.router
17
+ const publicProcedure = t.procedure
18
+
19
+ return router({
20
+ getSession: publicProcedure.query(async ({ ctx }) => {
21
+ // User is now extracted in the tRPC context creation
22
+ return {
23
+ user: ctx.user ?? null,
24
+ isAuthenticated: !!ctx.user,
25
+ }
26
+ }),
27
+ getProviders: publicProcedure.query(() => {
28
+ // Return configured social providers and OAuth providers from plugins
29
+ const providers: Array<string> = []
30
+ const authOptions = auth?.options
31
+
32
+ // Add built-in social providers (github, google, etc.)
33
+ if (authOptions?.socialProviders) {
34
+ const socialProviders = authOptions.socialProviders as Record<
35
+ string,
36
+ unknown
37
+ >
38
+ Object.keys(socialProviders).forEach((key) => {
39
+ if (socialProviders[key]) {
40
+ providers.push(key)
41
+ }
42
+ })
43
+ }
44
+
45
+ // Add OAuth providers from plugins (like Okta via genericOAuth)
46
+ if (authOptions?.plugins) {
47
+ const plugins = authOptions.plugins
48
+ plugins.forEach((plugin) => {
49
+ const pluginWithConfig = plugin as BetterAuthPlugin & {
50
+ options?: {
51
+ config?: Array<{ providerId?: string }>
52
+ }
53
+ }
54
+ if (
55
+ pluginWithConfig.id === 'generic-oauth' &&
56
+ pluginWithConfig.options?.config
57
+ ) {
58
+ const configs = Array.isArray(pluginWithConfig.options.config)
59
+ ? pluginWithConfig.options.config
60
+ : [pluginWithConfig.options.config]
61
+ configs.forEach((config) => {
62
+ if (config.providerId) {
63
+ providers.push(config.providerId)
64
+ }
65
+ })
66
+ }
67
+ })
68
+ }
69
+
70
+ return { providers }
71
+ }),
72
+ })
73
+ }
74
+
75
+ export type AuthRouter = ReturnType<typeof createAuthRouter>
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Authorization utilities for checking user permissions based on groups
3
+ *
4
+ * Groups are automatically included in the user session when:
5
+ * 1. Okta auth server has a "groups" claim configured
6
+ * 2. The auth policy rule includes "groups" in scope_whitelist
7
+ *
8
+ * Example usage in tRPC procedures:
9
+ * ```typescript
10
+ * myProcedure: protectedProcedure.query(async ({ ctx }) => {
11
+ * if (requireAdmin(ctx.user)) {
12
+ * // Admin-only logic
13
+ * }
14
+ * // Regular user logic
15
+ * })
16
+ * ```
17
+ */
18
+
19
+ export interface UserWithGroups {
20
+ id: string
21
+ email: string
22
+ name?: string
23
+ // Groups from Okta (or other identity provider)
24
+ // This will be populated if groups claim is configured
25
+ [key: string]: any
26
+ }
27
+
28
+ /**
29
+ * Extract groups from user object
30
+ * Groups can be stored in different locations depending on the OAuth provider
31
+ */
32
+ export function getUserGroups(
33
+ user: UserWithGroups | null | undefined,
34
+ ): Array<string> {
35
+ if (!user) {
36
+ console.log('[getUserGroups] No user provided')
37
+ return []
38
+ }
39
+
40
+ // Debug: Log all user properties to see what's available
41
+ console.log('[getUserGroups] === USER OBJECT DEBUG ===')
42
+ console.log('[getUserGroups] User ID:', user.id)
43
+ console.log('[getUserGroups] User email:', user.email)
44
+ console.log(
45
+ '[getUserGroups] User.env_hopper_groups:',
46
+ (user as any).env_hopper_groups,
47
+ )
48
+ console.log('[getUserGroups] User.groups:', user.groups)
49
+ console.log('[getUserGroups] User.oktaGroups:', (user as any).oktaGroups)
50
+ console.log('[getUserGroups] User.roles:', (user as any).roles)
51
+ console.log('[getUserGroups] All user keys:', Object.keys(user))
52
+ console.log(
53
+ '[getUserGroups] Full user object:',
54
+ JSON.stringify(user, null, 2),
55
+ )
56
+
57
+ // Check common locations for group information
58
+ // Order of preference: custom env_hopper_groups first
59
+ const groups =
60
+ (user as any).env_hopper_groups || // Custom env_hopper_groups claim (stores natera.env_hopper_ui.groups)
61
+ user.groups || // Standard "groups" claim
62
+ (user as any).oktaGroups || // Okta-specific
63
+ (user as any).roles || // Some providers use "roles"
64
+ []
65
+
66
+ const result = Array.isArray(groups) ? groups : []
67
+ console.log('[getUserGroups] Final groups result:', result)
68
+
69
+ return result
70
+ }
71
+
72
+ /**
73
+ * Check if user is a member of any of the specified groups
74
+ */
75
+ export function isMemberOfAnyGroup(
76
+ user: UserWithGroups | null | undefined,
77
+ allowedGroups: Array<string>,
78
+ ): boolean {
79
+ const userGroups = getUserGroups(user)
80
+ return allowedGroups.some((group) => userGroups.includes(group))
81
+ }
82
+
83
+ /**
84
+ * Check if user is a member of all specified groups
85
+ */
86
+ export function isMemberOfAllGroups(
87
+ user: UserWithGroups | null | undefined,
88
+ requiredGroups: Array<string>,
89
+ ): boolean {
90
+ const userGroups = getUserGroups(user)
91
+ return requiredGroups.every((group) => userGroups.includes(group))
92
+ }
93
+
94
+ /**
95
+ * Check if user has admin permissions
96
+ * @param user User object with groups
97
+ * @param adminGroups List of admin group names (default: ['env_hopper_ui_super_admins'])
98
+ */
99
+ export function isAdmin(
100
+ user: UserWithGroups | null | undefined,
101
+ adminGroups: Array<string> = ['env_hopper_ui_super_admins'],
102
+ ): boolean {
103
+ return isMemberOfAnyGroup(user, adminGroups)
104
+ }
105
+
106
+ /**
107
+ * Require admin permissions - throws error if not admin
108
+ * @param user User object with groups
109
+ * @param adminGroups List of admin group names (default: ['env_hopper_ui_super_admins'])
110
+ */
111
+ export function requireAdmin(
112
+ user: UserWithGroups | null | undefined,
113
+ adminGroups: Array<string> = ['env_hopper_ui_super_admins'],
114
+ ): void {
115
+ if (!isAdmin(user, adminGroups)) {
116
+ throw new Error('Forbidden: Admin access required')
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Require membership in specific groups - throws error if not member
122
+ */
123
+ export function requireGroups(
124
+ user: UserWithGroups | null | undefined,
125
+ groups: Array<string>,
126
+ ): void {
127
+ if (!isMemberOfAnyGroup(user, groups)) {
128
+ throw new Error(
129
+ `Forbidden: Membership in one of these groups required: ${groups.join(', ')}`,
130
+ )
131
+ }
132
+ }
@@ -0,0 +1,49 @@
1
+ import type { EhDevMockUser } from '../../middleware/types'
2
+ import type { User } from 'better-auth/types'
3
+
4
+ /**
5
+ * Extended User type with app-catalog specific fields
6
+ */
7
+ type EhUser = User & {
8
+ env_hopper_groups?: Array<string>
9
+ }
10
+
11
+ /**
12
+ * Creates a complete User object from basic dev mock user details
13
+ */
14
+ export function createMockUserFromDevConfig(devUser: EhDevMockUser): EhUser {
15
+ return {
16
+ id: devUser.id,
17
+ email: devUser.email,
18
+ name: devUser.name,
19
+ emailVerified: true,
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ env_hopper_groups: devUser.groups,
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Creates a mock session response for /api/auth/session endpoint
28
+ */
29
+ export function createMockSessionResponse(devUser: EhDevMockUser) {
30
+ return {
31
+ user: {
32
+ id: devUser.id,
33
+ email: devUser.email,
34
+ name: devUser.name,
35
+ emailVerified: true,
36
+ createdAt: new Date().toISOString(),
37
+ updatedAt: new Date().toISOString(),
38
+ env_hopper_groups: devUser.groups,
39
+ },
40
+ session: {
41
+ id: `${devUser.id}-session`,
42
+ userId: devUser.id,
43
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), // 30 days
44
+ token: `${devUser.id}-token`,
45
+ createdAt: new Date().toISOString(),
46
+ updatedAt: new Date().toISOString(),
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,33 @@
1
+ import { toNodeHandler } from 'better-auth/node'
2
+ import type { Express, Request, Response } from 'express'
3
+ import type { BetterAuth } from './auth'
4
+
5
+ /**
6
+ * Register Better Auth routes with Express
7
+ * @param app - Express application instance
8
+ * @param auth - Better Auth instance
9
+ */
10
+ export function registerAuthRoutes(app: Express, auth: BetterAuth) {
11
+ // Explicit session endpoint handler
12
+ // Better Auth's toNodeHandler doesn't expose a direct /session endpoint
13
+ app.get('/api/auth/session', async (req: Request, res: Response) => {
14
+ try {
15
+ const session = await auth.api.getSession({
16
+ headers: req.headers as HeadersInit,
17
+ })
18
+ if (session) {
19
+ res.json(session)
20
+ } else {
21
+ res.status(401).json({ error: 'Not authenticated' })
22
+ }
23
+ } catch (error) {
24
+ console.error('[Auth Session Error]', error)
25
+ res.status(500).json({ error: 'Internal server error' })
26
+ }
27
+ })
28
+
29
+ // Use toNodeHandler to adapt better-auth for Express/Node.js
30
+ // Express v5 wildcard syntax: /{*any} (also works with Express v4)
31
+ const authHandler = toNodeHandler(auth)
32
+ app.all('/api/auth/{*any}', authHandler)
33
+ }
@@ -0,0 +1,171 @@
1
+ import type { Request, Response, Router } from 'express'
2
+ import multer from 'multer'
3
+ import { createHash } from 'node:crypto'
4
+ import { getDbClient } from '../../db'
5
+ import { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'
6
+
7
+ // Configure multer for memory storage
8
+ const upload = multer({
9
+ storage: multer.memoryStorage(),
10
+ limits: {
11
+ fileSize: 10 * 1024 * 1024, // 10MB limit
12
+ },
13
+ fileFilter: (_req, file, cb) => {
14
+ // Accept images only
15
+ if (!file.mimetype.startsWith('image/')) {
16
+ cb(new Error('Only image files are allowed'))
17
+ return
18
+ }
19
+ cb(null, true)
20
+ },
21
+ })
22
+
23
+ export interface IconRestControllerConfig {
24
+ /**
25
+ * Base path for icon endpoints (e.g., '/api/icons')
26
+ */
27
+ basePath: string
28
+ }
29
+
30
+ /**
31
+ * Registers REST endpoints for icon upload and retrieval
32
+ *
33
+ * Endpoints:
34
+ * - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)
35
+ * - GET {basePath}/:id - Get icon binary by ID
36
+ * - GET {basePath}/:id/metadata - Get icon metadata only
37
+ */
38
+ export function registerIconRestController(
39
+ router: Router,
40
+ config: IconRestControllerConfig,
41
+ ): void {
42
+ const { basePath } = config
43
+
44
+ // Upload endpoint - accepts multipart/form-data
45
+ router.post(
46
+ `${basePath}/upload`,
47
+ upload.single('icon'),
48
+ async (req: Request, res: Response) => {
49
+ try {
50
+ if (!req.file) {
51
+ res.status(400).json({ error: 'No file uploaded' })
52
+ return
53
+ }
54
+
55
+ let name = req.body['name'] as string
56
+ if (!name) {
57
+ res.status(400).json({ error: 'Name is required' })
58
+ return
59
+ }
60
+
61
+ // Extract extension from original filename or derive from MIME type
62
+ const extension =
63
+ getExtensionFromFilename(req.file.originalname) ||
64
+ getExtensionFromMimeType(req.file.mimetype)
65
+
66
+ // Add extension to name if not already present
67
+ if (!name.includes('.')) {
68
+ name = `${name}.${extension}`
69
+ }
70
+
71
+ const prisma = getDbClient()
72
+ const checksum = createHash('sha256')
73
+ .update(req.file.buffer)
74
+ .digest('hex')
75
+ const icon = await prisma.dbAsset.create({
76
+ data: {
77
+ name,
78
+ assetType: 'icon',
79
+ content: new Uint8Array(req.file.buffer),
80
+ mimeType: req.file.mimetype,
81
+ fileSize: req.file.size,
82
+ checksum,
83
+ },
84
+ })
85
+
86
+ res.status(201).json({
87
+ id: icon.id,
88
+ name: icon.name,
89
+ mimeType: icon.mimeType,
90
+ fileSize: icon.fileSize,
91
+ createdAt: icon.createdAt,
92
+ })
93
+ } catch (error) {
94
+ console.error('Error uploading icon:', error)
95
+ res.status(500).json({ error: 'Failed to upload icon' })
96
+ }
97
+ },
98
+ )
99
+
100
+ // Get icon binary by name (e.g., /api/icons/jira.svg)
101
+ router.get(`${basePath}/:name`, async (req: Request, res: Response) => {
102
+ try {
103
+ const { name } = req.params
104
+
105
+ const prisma = getDbClient()
106
+ const icon = await prisma.dbAsset.findFirst({
107
+ where: {
108
+ name,
109
+ assetType: 'icon',
110
+ },
111
+ select: {
112
+ content: true,
113
+ mimeType: true,
114
+ name: true,
115
+ },
116
+ })
117
+
118
+ if (!icon) {
119
+ res.status(404).json({ error: 'Icon not found' })
120
+ return
121
+ }
122
+
123
+ // Set appropriate headers
124
+ res.setHeader('Content-Type', icon.mimeType)
125
+ res.setHeader('Content-Disposition', `inline; filename="${icon.name}"`)
126
+ res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
127
+
128
+ // Send binary content
129
+ res.send(icon.content)
130
+ } catch (error) {
131
+ console.error('Error fetching icon:', error)
132
+ res.status(500).json({ error: 'Failed to fetch icon' })
133
+ }
134
+ })
135
+
136
+ // Get icon metadata only (no binary content)
137
+ router.get(
138
+ `${basePath}/:name/metadata`,
139
+ async (req: Request, res: Response) => {
140
+ try {
141
+ const { name } = req.params
142
+
143
+ const prisma = getDbClient()
144
+ const icon = await prisma.dbAsset.findFirst({
145
+ where: {
146
+ name,
147
+ assetType: 'icon',
148
+ },
149
+ select: {
150
+ id: true,
151
+ name: true,
152
+ mimeType: true,
153
+ fileSize: true,
154
+ createdAt: true,
155
+ updatedAt: true,
156
+ },
157
+ })
158
+
159
+ if (!icon) {
160
+ res.status(404).json({ error: 'Icon not found' })
161
+ return
162
+ }
163
+
164
+ res.json(icon)
165
+ } catch (error) {
166
+ console.error('Error fetching icon metadata:', error)
167
+ res.status(500).json({ error: 'Failed to fetch icon metadata' })
168
+ }
169
+ },
170
+ )
171
+ }