@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,173 @@
1
+ import type { Router } from 'express'
2
+ import { toNodeHandler } from 'better-auth/node'
3
+ import type {
4
+ EhFeatureToggles,
5
+ EhMiddlewareOptions,
6
+ MiddlewareContext,
7
+ } from './types'
8
+ import { registerIconRestController } from '../modules/icons/iconRestController'
9
+ import { registerAssetRestController } from '../modules/assets/assetRestController'
10
+ import { registerScreenshotRestController } from '../modules/assets/screenshotRestController'
11
+ import { createAdminChatHandler } from '../modules/admin/chat/createAdminChatHandler'
12
+ import { getAssetByName } from '../modules/icons/iconService'
13
+ import {
14
+ exportAsset,
15
+ exportCatalog,
16
+ importAsset,
17
+ importCatalog,
18
+ listAssets,
19
+ } from '../modules/appCatalogAdmin/catalogBackupController'
20
+ import multer from 'multer'
21
+ import { createMockSessionResponse } from '../modules/auth/devMockUserUtils'
22
+
23
+ interface FeatureRegistration {
24
+ name: keyof EhFeatureToggles
25
+ defaultEnabled: boolean
26
+ register: (
27
+ router: Router,
28
+ options: Required<Pick<EhMiddlewareOptions, 'basePath'>> &
29
+ EhMiddlewareOptions,
30
+ context: MiddlewareContext,
31
+ ) => void
32
+ }
33
+
34
+ // Optional features that can be toggled
35
+ const FEATURES: Array<FeatureRegistration> = [
36
+ {
37
+ name: 'auth',
38
+ defaultEnabled: true,
39
+ register: (router, options, ctx) => {
40
+ const basePath = options.basePath
41
+
42
+ // Explicit session endpoint handler
43
+ router.get(
44
+ `${basePath}/auth/session`,
45
+ async (req, res): Promise<void> => {
46
+ try {
47
+ // Check if dev mock user is configured
48
+ if (ctx.authConfig.devMockUser) {
49
+ res.json(createMockSessionResponse(ctx.authConfig.devMockUser))
50
+ return
51
+ }
52
+
53
+ const session = await ctx.auth.api.getSession({
54
+ headers: req.headers as HeadersInit,
55
+ })
56
+ if (session) {
57
+ res.json(session)
58
+ } else {
59
+ res.status(401).json({ error: 'Not authenticated' })
60
+ }
61
+ } catch (error) {
62
+ console.error('[Auth Session Error]', error)
63
+ res.status(500).json({ error: 'Internal server error' })
64
+ }
65
+ },
66
+ )
67
+
68
+ // Use toNodeHandler to adapt better-auth for Express/Node.js
69
+ const authHandler = toNodeHandler(ctx.auth)
70
+ router.all(`${basePath}/auth/{*any}`, authHandler)
71
+ },
72
+ },
73
+ {
74
+ name: 'adminChat',
75
+ defaultEnabled: false, // Only enabled if adminChat config is provided
76
+ register: (router, options) => {
77
+ if (options.adminChat) {
78
+ router.post(
79
+ `${options.basePath}/admin/chat`,
80
+ createAdminChatHandler(options.adminChat),
81
+ )
82
+ }
83
+ },
84
+ },
85
+ {
86
+ name: 'legacyIconEndpoint',
87
+ defaultEnabled: false,
88
+ register: (router) => {
89
+ // Legacy endpoint at /static/icon/:icon for backwards compatibility
90
+ router.get('/static/icon/:icon', async (req, res) => {
91
+ const { icon } = req.params
92
+
93
+ if (!icon || !/^[a-z0-9-]+$/i.test(icon)) {
94
+ res.status(400).send('Invalid icon name')
95
+ return
96
+ }
97
+
98
+ try {
99
+ const dbIcon = await getAssetByName(icon)
100
+
101
+ if (!dbIcon) {
102
+ res.status(404).send('Icon not found')
103
+ return
104
+ }
105
+
106
+ res.setHeader('Content-Type', dbIcon.mimeType)
107
+ res.setHeader('Cache-Control', 'public, max-age=86400')
108
+ res.send(dbIcon.content)
109
+ } catch (error) {
110
+ console.error('Error fetching icon:', error)
111
+ res.status(404).send('Icon not found')
112
+ }
113
+ })
114
+ },
115
+ },
116
+ ]
117
+
118
+ /**
119
+ * Registers all enabled features on the router.
120
+ */
121
+ export function registerFeatures(
122
+ router: Router,
123
+ options: Required<Pick<EhMiddlewareOptions, 'basePath'>> &
124
+ EhMiddlewareOptions,
125
+ context: MiddlewareContext,
126
+ ): void {
127
+ const basePath = options.basePath
128
+
129
+ // Always-on features (required for core functionality)
130
+
131
+ // Icons
132
+ registerIconRestController(router, {
133
+ basePath: `${basePath}/icons`,
134
+ })
135
+
136
+ // Assets
137
+ registerAssetRestController(router, {
138
+ basePath: `${basePath}/assets`,
139
+ })
140
+
141
+ // Screenshots
142
+ registerScreenshotRestController(router, {
143
+ basePath: `${basePath}/screenshots`,
144
+ })
145
+
146
+ // Catalog backup/restore
147
+ const upload = multer({ storage: multer.memoryStorage() })
148
+ router.get(`${basePath}/catalog/backup/export`, exportCatalog)
149
+ router.post(`${basePath}/catalog/backup/import`, importCatalog)
150
+ router.get(`${basePath}/catalog/backup/assets`, listAssets)
151
+ router.get(`${basePath}/catalog/backup/assets/:name`, exportAsset)
152
+ router.post(
153
+ `${basePath}/catalog/backup/assets`,
154
+ upload.single('file'),
155
+ importAsset,
156
+ )
157
+
158
+ // Optional toggleable features
159
+ const toggles = options.features || {}
160
+
161
+ for (const feature of FEATURES) {
162
+ const isEnabled = toggles[feature.name] ?? feature.defaultEnabled
163
+
164
+ // Special case: adminChat is only enabled if config is provided
165
+ if (feature.name === 'adminChat' && !options.adminChat) {
166
+ continue
167
+ }
168
+
169
+ if (isEnabled) {
170
+ feature.register(router, options, context)
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Middleware module for app-catalog backend integration.
3
+ *
4
+ * Provides a batteries-included middleware factory that handles all backend wiring:
5
+ * - Database connection management
6
+ * - Authentication setup
7
+ * - tRPC router configuration
8
+ * - Feature registration (icons, assets, screenshots, admin chat)
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const eh = await createEhMiddleware({
13
+ * basePath: '/api',
14
+ * database: { host, port, database, username, password, schema },
15
+ * auth: { baseURL, secret, providers },
16
+ * backend: myBackendImplementation,
17
+ * })
18
+ *
19
+ * app.use(eh.router)
20
+ * await eh.connect()
21
+ * ```
22
+ *
23
+ * @module middleware
24
+ */
25
+
26
+ // Main middleware factory
27
+ export { createEhMiddleware } from './createEhMiddleware'
28
+
29
+ // Types
30
+ export type {
31
+ EhDatabaseConfig,
32
+ EhAuthConfig,
33
+ EhAdminChatConfig,
34
+ EhFeatureToggles,
35
+ EhBackendProvider,
36
+ EhLifecycleHooks,
37
+ EhMiddlewareOptions,
38
+ EhMiddlewareResult,
39
+ MiddlewareContext,
40
+ } from './types'
41
+
42
+ // Database manager (for advanced use cases)
43
+ export { EhDatabaseManager } from './database'
@@ -0,0 +1,202 @@
1
+ import type { Router } from 'express'
2
+ import type { LanguageModel, Tool } from 'ai'
3
+ import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
4
+ import type { AppCatalogCompanySpecificBackend } from '../types/backend/companySpecificBackend'
5
+ import type { BetterAuth } from '../modules/auth/auth'
6
+ import type { TRPCRouter } from '../server/controller'
7
+
8
+ /**
9
+ * Database connection configuration.
10
+ * Supports both connection URL and structured config.
11
+ */
12
+ export type EhDatabaseConfig =
13
+ | { url: string }
14
+ | {
15
+ host: string
16
+ port: number
17
+ database: string
18
+ username: string
19
+ password: string
20
+ schema?: string
21
+ }
22
+
23
+ /**
24
+ * Mock user configuration for development/testing.
25
+ * When provided, bypasses authentication and injects this user into all requests.
26
+ */
27
+ export interface EhDevMockUser {
28
+ /** User ID */
29
+ id: string
30
+ /** User email */
31
+ email: string
32
+ /** User display name */
33
+ name: string
34
+ /** User groups (for authorization) */
35
+ groups: Array<string>
36
+ }
37
+
38
+ /**
39
+ * Auth configuration for Better Auth integration.
40
+ */
41
+ export interface EhAuthConfig {
42
+ /** Base URL for auth callbacks (e.g., 'http://localhost:4000') */
43
+ baseURL: string
44
+ /** Secret for signing sessions (min 32 chars in production) */
45
+ secret: string
46
+ /** OAuth providers configuration */
47
+ providers?: BetterAuthOptions['socialProviders']
48
+ /** Additional Better Auth plugins (e.g., Okta) */
49
+ plugins?: Array<BetterAuthPlugin>
50
+ /** Session expiration in seconds (default: 30 days) */
51
+ sessionExpiresIn?: number
52
+ /** Session refresh threshold in seconds (default: 1 day) */
53
+ sessionUpdateAge?: number
54
+ /** Application name shown in auth UI */
55
+ appName?: string
56
+ /** Development mock user - bypasses auth when provided */
57
+ devMockUser?: EhDevMockUser
58
+ /** Admin group names for authorization (default: ['env_hopper_ui_super_admins']) */
59
+ adminGroups?: Array<string>
60
+ /** Okta groups claim name (e.g., 'env_hopper_ui_groups') - used to extract groups from access token JWT */
61
+ oktaGroupsClaim?: string
62
+ }
63
+
64
+ /**
65
+ * Admin chat (AI) configuration.
66
+ * When provided, enables the admin/chat endpoint.
67
+ */
68
+ export interface EhAdminChatConfig {
69
+ /** AI model instance from @ai-sdk/* packages */
70
+ model: LanguageModel
71
+ /** System prompt for the AI assistant */
72
+ systemPrompt?: string
73
+ /** Custom tools available to the AI */
74
+ tools?: Record<string, Tool>
75
+ /** Validation function called before each request */
76
+ validateConfig?: () => void
77
+ }
78
+
79
+ /**
80
+ * Feature toggles for enabling/disabling specific functionality.
81
+ *
82
+ * Note: Icons, assets, screenshots, and catalog backup are always enabled.
83
+ * Only these optional features can be toggled:
84
+ */
85
+ export interface EhFeatureToggles {
86
+ /** Enable tRPC endpoints (default: true) */
87
+ trpc?: boolean
88
+ /** Enable auth endpoints (default: true) */
89
+ auth?: boolean
90
+ /** Enable admin chat endpoint (default: true if adminChat config provided) */
91
+ adminChat?: boolean
92
+ /** Enable legacy icon endpoint at /static/icon/:icon (default: false) */
93
+ legacyIconEndpoint?: boolean
94
+ }
95
+
96
+ /**
97
+ * Company-specific backend can be provided as:
98
+ * 1. Direct object implementing the interface
99
+ * 2. Factory function called per-request (for DI integration)
100
+ * 3. Async factory function
101
+ */
102
+ export type EhBackendProvider =
103
+ | AppCatalogCompanySpecificBackend
104
+ | (() => AppCatalogCompanySpecificBackend)
105
+ | (() => Promise<AppCatalogCompanySpecificBackend>)
106
+
107
+ /**
108
+ * Lifecycle hooks for database and middleware events.
109
+ */
110
+ export interface EhLifecycleHooks {
111
+ /** Called after database connection is established */
112
+ onDatabaseConnected?: () => void | Promise<void>
113
+ /** Called before database disconnection (for cleanup) */
114
+ onDatabaseDisconnecting?: () => void | Promise<void>
115
+ /** Called after all routes are registered - use to add custom routes */
116
+ onRoutesRegistered?: (router: Router) => void | Promise<void>
117
+ /** Custom error handler for middleware errors */
118
+ onError?: (error: Error, context: { path: string }) => void
119
+ }
120
+
121
+ /**
122
+ * Main configuration options for the app-catalog middleware.
123
+ */
124
+ export interface EhMiddlewareOptions {
125
+ /**
126
+ * Base path prefix for all routes (default: '/api')
127
+ * - tRPC: {basePath}/trpc
128
+ * - Auth: {basePath}/auth (note: auth basePath is hardcoded, this affects where router mounts)
129
+ * - Icons: {basePath}/icons
130
+ * - Assets: {basePath}/assets
131
+ * - Screenshots: {basePath}/screenshots
132
+ * - Admin Chat: {basePath}/admin/chat
133
+ */
134
+ basePath?: string
135
+
136
+ /**
137
+ * Database connection configuration (required).
138
+ * Backend-core manages the database for all features.
139
+ */
140
+ database: EhDatabaseConfig
141
+
142
+ /** Auth configuration (required) */
143
+ auth: EhAuthConfig
144
+
145
+ /** Company-specific backend implementation (required) */
146
+ backend: EhBackendProvider
147
+
148
+ /** AI admin chat configuration (optional) */
149
+ adminChat?: EhAdminChatConfig
150
+
151
+ /** Feature toggles (all enabled by default) */
152
+ features?: EhFeatureToggles
153
+
154
+ /** Lifecycle hooks */
155
+ hooks?: EhLifecycleHooks
156
+ }
157
+
158
+ /**
159
+ * Result of middleware initialization.
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const eh = await createEhMiddleware({ ... })
164
+ *
165
+ * // Mount routes
166
+ * app.use(eh.router)
167
+ *
168
+ * // Connect to database
169
+ * await eh.connect()
170
+ *
171
+ * // Cleanup on shutdown
172
+ * process.on('SIGTERM', async () => {
173
+ * await eh.disconnect()
174
+ * })
175
+ * ```
176
+ */
177
+ export interface EhMiddlewareResult {
178
+ /** Express router with all app-catalog routes */
179
+ router: Router
180
+ /** Better Auth instance (for extending auth functionality) */
181
+ auth: BetterAuth
182
+ /** tRPC router (for extending with custom procedures) */
183
+ trpcRouter: TRPCRouter
184
+ /** Connect to database (call during app startup) */
185
+ connect: () => Promise<void>
186
+ /** Disconnect from database (call during app shutdown) */
187
+ disconnect: () => Promise<void>
188
+ /** Add custom routes to the middleware router */
189
+ addRoutes: (callback: (router: Router) => void) => void
190
+ }
191
+
192
+ /**
193
+ * Internal context passed to feature registration functions.
194
+ */
195
+ export interface MiddlewareContext {
196
+ auth: BetterAuth
197
+ trpcRouter: TRPCRouter
198
+ createContext: () => Promise<{
199
+ companySpecificBackend: AppCatalogCompanySpecificBackend
200
+ }>
201
+ authConfig: EhAuthConfig
202
+ }
@@ -0,0 +1,152 @@
1
+ import { stepCountIs, streamText, tool } from 'ai'
2
+ import type { LanguageModel, Tool } from 'ai'
3
+
4
+ import type { Request, Response } from 'express'
5
+
6
+ export interface AdminChatHandlerOptions {
7
+ /** The AI model to use (from @ai-sdk/openai, @ai-sdk/anthropic, etc.) */
8
+ model: LanguageModel
9
+ /** System prompt for the AI assistant */
10
+ systemPrompt?: string
11
+ /** Tools available to the AI assistant */
12
+ tools?: Record<string, Tool>
13
+ /**
14
+ * Optional function to validate configuration before processing requests.
15
+ * Should throw an error if configuration is invalid (e.g., missing API key).
16
+ * @example
17
+ * validateConfig: () => {
18
+ * if (!process.env.OPENAI_API_KEY) {
19
+ * throw new Error('OPENAI_API_KEY is not configured')
20
+ * }
21
+ * }
22
+ */
23
+ validateConfig?: () => void
24
+ }
25
+
26
+ interface TextPart {
27
+ type: 'text'
28
+ text: string
29
+ }
30
+
31
+ interface UIMessageInput {
32
+ role: 'user' | 'assistant' | 'system'
33
+ content?: string
34
+ parts?: Array<TextPart | { type: string }>
35
+ }
36
+
37
+ interface CoreMessage {
38
+ role: 'user' | 'assistant' | 'system'
39
+ content: string
40
+ }
41
+
42
+ function convertToCoreMessages(
43
+ messages: Array<UIMessageInput>,
44
+ ): Array<CoreMessage> {
45
+ return messages.map((msg) => {
46
+ if (msg.content) {
47
+ return { role: msg.role, content: msg.content }
48
+ }
49
+ // Extract text from parts array (AI SDK v3 format)
50
+ const textContent =
51
+ msg.parts
52
+ ?.filter((part): part is TextPart => part.type === 'text')
53
+ .map((part) => part.text)
54
+ .join('') ?? ''
55
+ return { role: msg.role, content: textContent }
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Creates an Express handler for the admin chat endpoint.
61
+ *
62
+ * Usage in thin wrappers:
63
+ *
64
+ * ```typescript
65
+ * // With OpenAI
66
+ * import { openai } from '@ai-sdk/openai'
67
+ * app.post('/api/admin/chat', createAdminChatHandler({
68
+ * model: openai('gpt-4o-mini'),
69
+ * }))
70
+ *
71
+ * // With Claude
72
+ * import { anthropic } from '@ai-sdk/anthropic'
73
+ * app.post('/api/admin/chat', createAdminChatHandler({
74
+ * model: anthropic('claude-sonnet-4-20250514'),
75
+ * }))
76
+ * ```
77
+ */
78
+ export function createAdminChatHandler(options: AdminChatHandlerOptions) {
79
+ const {
80
+ model,
81
+ systemPrompt = 'You are a helpful admin assistant for the App Catalog application. Help users manage apps, data sources, and MCP server configurations.',
82
+ tools = {},
83
+ validateConfig,
84
+ } = options
85
+
86
+ return async (req: Request, res: Response) => {
87
+ try {
88
+ // Validate configuration if validator provided
89
+ if (validateConfig) {
90
+ validateConfig()
91
+ }
92
+
93
+ const { messages } = req.body as { messages: Array<UIMessageInput> }
94
+ const coreMessages = convertToCoreMessages(messages)
95
+
96
+ console.log(
97
+ '[Admin Chat] Received messages:',
98
+ JSON.stringify(coreMessages, null, 2),
99
+ )
100
+ console.log('[Admin Chat] Available tools:', Object.keys(tools))
101
+
102
+ const result = streamText({
103
+ model,
104
+ system: systemPrompt,
105
+ messages: coreMessages,
106
+ tools,
107
+ // Allow up to 5 steps so the model can call tools and then generate a response
108
+ stopWhen: stepCountIs(5),
109
+ onFinish: (event) => {
110
+ console.log('[Admin Chat] Finished:', {
111
+ finishReason: event.finishReason,
112
+ usage: event.usage,
113
+ hasText: !!event.text,
114
+ textLength: event.text.length,
115
+ })
116
+ },
117
+ })
118
+
119
+ // Use UI message stream response which is compatible with AI SDK React hooks
120
+ const response = result.toUIMessageStreamResponse()
121
+
122
+ // Copy headers from the response
123
+ response.headers.forEach((value, key) => {
124
+ res.setHeader(key, value)
125
+ })
126
+
127
+ // Pipe the stream to the response
128
+ if (response.body) {
129
+ const reader = response.body.getReader()
130
+ const pump = async (): Promise<void> => {
131
+ const { done, value } = await reader.read()
132
+ if (done) {
133
+ res.end()
134
+ return
135
+ }
136
+ res.write(value)
137
+ return pump()
138
+ }
139
+ await pump()
140
+ } else {
141
+ console.error('[Admin Chat] No response body')
142
+ res.status(500).json({ error: 'No response from AI model' })
143
+ }
144
+ } catch (error) {
145
+ console.error('[Admin Chat] Error:', error)
146
+ res.status(500).json({ error: 'Failed to process chat request' })
147
+ }
148
+ }
149
+ }
150
+
151
+ // Re-export tool helper for convenience
152
+ export { tool }