@env-hopper/backend-core 2.0.1-alpha.3 → 2.0.1-alpha.4

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": "@env-hopper/backend-core",
3
- "version": "2.0.1-alpha.3",
3
+ "version": "2.0.1-alpha.4",
4
4
  "description": "Backend core library for Env Hopper",
5
5
  "homepage": "https://github.com/lislon/env-hopper",
6
6
  "repository": {
@@ -48,8 +48,8 @@
48
48
  "tsyringe": "^4.10.0",
49
49
  "yaml": "^2.8.0",
50
50
  "zod": "^4.3.5",
51
- "@env-hopper/table-sync": "2.0.1-alpha.3",
52
- "@env-hopper/shared-core": "2.0.1-alpha.1"
51
+ "@env-hopper/shared-core": "2.0.1-alpha.1",
52
+ "@env-hopper/table-sync": "2.0.1-alpha"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@tanstack/vite-config": "^0.4.3",
@@ -1,6 +1,10 @@
1
1
  import express, { Router } from 'express'
2
2
  import * as trpcExpress from '@trpc/server/adapters/express'
3
- import type { EhMiddlewareOptions, EhMiddlewareResult, MiddlewareContext } from './types'
3
+ import type {
4
+ EhMiddlewareOptions,
5
+ EhMiddlewareResult,
6
+ MiddlewareContext,
7
+ } from './types'
4
8
  import { EhDatabaseManager } from './database'
5
9
  import { createBackendResolver } from './backendResolver'
6
10
  import { registerFeatures } from './featureRegistry'
@@ -8,61 +12,6 @@ import { createTrpcRouter } from '../server/controller'
8
12
  import { createEhTrpcContext } from '../server/ehTrpcContext'
9
13
  import { createAuth } from '../modules/auth/auth'
10
14
 
11
- /**
12
- * Creates a fully-configured env-hopper middleware.
13
- *
14
- * @example
15
- * ```typescript
16
- * // Simple usage with inline backend
17
- * const eh = await createEhMiddleware({
18
- * basePath: '/api',
19
- * database: { url: process.env.DATABASE_URL! },
20
- * auth: {
21
- * baseURL: 'http://localhost:4000',
22
- * secret: process.env.AUTH_SECRET!,
23
- * },
24
- * backend: {
25
- * getBootstrapData: async () => loadStaticData(),
26
- * getAvailabilityMatrix: async () => ({}),
27
- * getNameMigrations: async () => false,
28
- * getResourceJumps: async () => ({ resourceJumps: [], envs: [], lateResolvableParams: [] }),
29
- * getResourceJumpsExtended: async () => ({ envs: [] }),
30
- * },
31
- * })
32
- *
33
- * app.use(eh.router)
34
- * await eh.connect()
35
- * ```
36
- *
37
- * @example
38
- * ```typescript
39
- * // With DI-resolved backend (e.g., tsyringe)
40
- * const eh = await createEhMiddleware({
41
- * basePath: '/api',
42
- * database: {
43
- * host: cfg.db.host,
44
- * port: cfg.db.port,
45
- * database: cfg.db.name,
46
- * username: cfg.db.username,
47
- * password: cfg.db.password,
48
- * schema: cfg.db.schema,
49
- * },
50
- * auth: {
51
- * baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:4000',
52
- * secret: process.env.BETTER_AUTH_SECRET!,
53
- * providers: getAuthProvidersFromEnv(),
54
- * plugins: getAuthPluginsFromEnv(),
55
- * },
56
- * // Factory function - resolved fresh per request from DI container
57
- * backend: () => container.resolve(NateraEhBackend),
58
- * hooks: {
59
- * onRoutesRegistered: (router) => {
60
- * router.get('/health', (_, res) => res.send('ok'))
61
- * },
62
- * },
63
- * })
64
- * ```
65
- */
66
15
  export async function createEhMiddleware(
67
16
  options: EhMiddlewareOptions,
68
17
  ): Promise<EhMiddlewareResult> {
@@ -70,29 +19,10 @@ export async function createEhMiddleware(
70
19
  const basePath = options.basePath ?? '/api'
71
20
  const normalizedOptions = { ...options, basePath }
72
21
 
73
- // Check if database-dependent features are enabled
74
- const features = options.features ?? {}
75
- const iconsEnabled = features.icons !== false
76
- const assetsEnabled = features.assets !== false
77
- const screenshotsEnabled = features.screenshots !== false
78
- const legacyIconEnabled = features.legacyIconEndpoint === true
79
- const needsDatabase = iconsEnabled || assetsEnabled || screenshotsEnabled || legacyIconEnabled
80
-
81
- // Validate database is provided when needed
82
- if (needsDatabase && !options.database) {
83
- throw new Error(
84
- 'Database configuration is required when icons, assets, screenshots, or legacyIconEndpoint features are enabled. ' +
85
- 'Either provide database config or disable these features.',
86
- )
87
- }
88
-
89
- // Initialize database manager only if database config is provided
90
- let dbManager: EhDatabaseManager | null = null
91
- if (options.database) {
92
- dbManager = new EhDatabaseManager(options.database)
93
- // Initialize the client (which also sets the global singleton)
94
- dbManager.getClient()
95
- }
22
+ // Initialize database manager
23
+ const dbManager = new EhDatabaseManager(options.database)
24
+ // Initialize the client (which also sets the global singleton)
25
+ dbManager.getClient()
96
26
 
97
27
  // Create auth instance
98
28
  const auth = createAuth({
@@ -153,9 +83,7 @@ export async function createEhMiddleware(
153
83
  trpcRouter,
154
84
 
155
85
  async connect(): Promise<void> {
156
- if (dbManager) {
157
- await dbManager.connect()
158
- }
86
+ await dbManager.connect()
159
87
  if (options.hooks?.onDatabaseConnected) {
160
88
  await options.hooks.onDatabaseConnected()
161
89
  }
@@ -165,9 +93,7 @@ export async function createEhMiddleware(
165
93
  if (options.hooks?.onDatabaseDisconnecting) {
166
94
  await options.hooks.onDatabaseDisconnecting()
167
95
  }
168
- if (dbManager) {
169
- await dbManager.disconnect()
170
- }
96
+ await dbManager.disconnect()
171
97
  },
172
98
 
173
99
  addRoutes(callback: (router: Router) => void): void {
@@ -10,6 +10,14 @@ import { registerAssetRestController } from '../modules/assets/assetRestControll
10
10
  import { registerScreenshotRestController } from '../modules/assets/screenshotRestController'
11
11
  import { createAdminChatHandler } from '../modules/admin/chat/createAdminChatHandler'
12
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'
13
21
 
14
22
  interface FeatureRegistration {
15
23
  name: keyof EhFeatureToggles
@@ -121,6 +129,27 @@ const FEATURES: Array<FeatureRegistration> = [
121
129
  })
122
130
  },
123
131
  },
132
+ {
133
+ name: 'catalogBackup',
134
+ defaultEnabled: true,
135
+ register: (router, options) => {
136
+ const basePath = options.basePath
137
+ const upload = multer({ storage: multer.memoryStorage() })
138
+
139
+ // Catalog backup/restore endpoints
140
+ router.get(`${basePath}/catalog/backup/export`, exportCatalog)
141
+ router.post(`${basePath}/catalog/backup/import`, importCatalog)
142
+
143
+ // Asset backup/restore endpoints
144
+ router.get(`${basePath}/catalog/backup/assets`, listAssets)
145
+ router.get(`${basePath}/catalog/backup/assets/:name`, exportAsset)
146
+ router.post(
147
+ `${basePath}/catalog/backup/assets`,
148
+ upload.single('file'),
149
+ importAsset,
150
+ )
151
+ },
152
+ },
124
153
  ]
125
154
 
126
155
  /**
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Middleware module for env-hopper 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
+
1
26
  // Main middleware factory
2
27
  export { createEhMiddleware } from './createEhMiddleware'
3
28
 
@@ -74,6 +74,8 @@ export interface EhFeatureToggles {
74
74
  adminChat?: boolean
75
75
  /** Enable legacy icon endpoint at /static/icon/:icon (default: false) */
76
76
  legacyIconEndpoint?: boolean
77
+ /** Enable catalog backup/restore endpoints (default: true) */
78
+ catalogBackup?: boolean
77
79
  }
78
80
 
79
81
  /**
@@ -117,11 +119,10 @@ export interface EhMiddlewareOptions {
117
119
  basePath?: string
118
120
 
119
121
  /**
120
- * Database connection configuration.
121
- * Required when icons, assets, or screenshots features are enabled.
122
- * Can be omitted when these features are disabled and you manage your own database.
122
+ * Database connection configuration (required).
123
+ * Backend-core manages the database for all features.
123
124
  */
124
- database?: EhDatabaseConfig
125
+ database: EhDatabaseConfig
125
126
 
126
127
  /** Auth configuration (required) */
127
128
  auth: EhAuthConfig
@@ -141,6 +142,22 @@ export interface EhMiddlewareOptions {
141
142
 
142
143
  /**
143
144
  * Result of middleware initialization.
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * const eh = await createEhMiddleware({ ... })
149
+ *
150
+ * // Mount routes
151
+ * app.use(eh.router)
152
+ *
153
+ * // Connect to database
154
+ * await eh.connect()
155
+ *
156
+ * // Cleanup on shutdown
157
+ * process.on('SIGTERM', async () => {
158
+ * await eh.disconnect()
159
+ * })
160
+ * ```
144
161
  */
145
162
  export interface EhMiddlewareResult {
146
163
  /** Express router with all env-hopper routes */
@@ -163,5 +180,7 @@ export interface EhMiddlewareResult {
163
180
  export interface MiddlewareContext {
164
181
  auth: BetterAuth
165
182
  trpcRouter: TRPCRouter
166
- createContext: () => Promise<{ companySpecificBackend: EhBackendCompanySpecificBackend }>
183
+ createContext: () => Promise<{
184
+ companySpecificBackend: EhBackendCompanySpecificBackend
185
+ }>
167
186
  }
@@ -0,0 +1,182 @@
1
+ import type { Request, Response } from 'express'
2
+ import { getDbClient } from '../../db'
3
+
4
+ /**
5
+ * Export the complete app catalog as JSON
6
+ * Includes all fields from DbAppForCatalog
7
+ */
8
+ export async function exportCatalog(
9
+ _req: Request,
10
+ res: Response,
11
+ ): Promise<void> {
12
+ try {
13
+ const prisma = getDbClient()
14
+
15
+ // Fetch all catalog entries
16
+ const apps = await prisma.dbAppForCatalog.findMany({
17
+ orderBy: { slug: 'asc' },
18
+ })
19
+
20
+ res.json({
21
+ version: '1.0',
22
+ exportDate: new Date().toISOString(),
23
+ apps,
24
+ })
25
+ } catch (error) {
26
+ console.error('Error exporting catalog:', error)
27
+ res.status(500).json({ error: 'Failed to export catalog' })
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Import/restore the complete app catalog from JSON
33
+ * Overwrites existing data
34
+ */
35
+ export async function importCatalog(
36
+ req: Request,
37
+ res: Response,
38
+ ): Promise<void> {
39
+ try {
40
+ const prisma = getDbClient()
41
+ const { apps } = req.body
42
+
43
+ if (!Array.isArray(apps)) {
44
+ res
45
+ .status(400)
46
+ .json({ error: 'Invalid data format: apps must be an array' })
47
+ return
48
+ }
49
+
50
+ // Use transaction to ensure atomicity
51
+ await prisma.$transaction(async (tx) => {
52
+ // Delete all existing catalog entries
53
+ await tx.dbAppForCatalog.deleteMany({})
54
+
55
+ // Insert new entries
56
+ for (const app of apps) {
57
+ // Remove id, createdAt, updatedAt to let Prisma generate new ones
58
+ const { id, createdAt, updatedAt, ...appData } = app
59
+
60
+ await tx.dbAppForCatalog.create({
61
+ data: appData,
62
+ })
63
+ }
64
+ })
65
+
66
+ res.json({ success: true, imported: apps.length })
67
+ } catch (error) {
68
+ console.error('Error importing catalog:', error)
69
+ res.status(500).json({ error: 'Failed to import catalog' })
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Export an asset (icon or screenshot) by name
75
+ */
76
+ export async function exportAsset(req: Request, res: Response): Promise<void> {
77
+ try {
78
+ const { name } = req.params
79
+ const prisma = getDbClient()
80
+
81
+ const asset = await prisma.dbAsset.findUnique({
82
+ where: { name },
83
+ })
84
+
85
+ if (!asset) {
86
+ res.status(404).json({ error: 'Asset not found' })
87
+ return
88
+ }
89
+
90
+ // Set appropriate content type and send binary data
91
+ res.set('Content-Type', asset.mimeType)
92
+ res.set('Content-Disposition', `attachment; filename="${name}"`)
93
+ res.send(Buffer.from(asset.content))
94
+ } catch (error) {
95
+ console.error('Error exporting asset:', error)
96
+ res.status(500).json({ error: 'Failed to export asset' })
97
+ }
98
+ }
99
+
100
+ /**
101
+ * List all assets with metadata
102
+ */
103
+ export async function listAssets(_req: Request, res: Response): Promise<void> {
104
+ try {
105
+ const prisma = getDbClient()
106
+
107
+ const assets = await prisma.dbAsset.findMany({
108
+ select: {
109
+ id: true,
110
+ name: true,
111
+ assetType: true,
112
+ mimeType: true,
113
+ fileSize: true,
114
+ width: true,
115
+ height: true,
116
+ checksum: true,
117
+ },
118
+ orderBy: { name: 'asc' },
119
+ })
120
+
121
+ res.json({ assets })
122
+ } catch (error) {
123
+ console.error('Error listing assets:', error)
124
+ res.status(500).json({ error: 'Failed to list assets' })
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Import an asset (icon or screenshot)
130
+ */
131
+ export async function importAsset(req: Request, res: Response): Promise<void> {
132
+ try {
133
+ const file = req.file
134
+ const { name, assetType, mimeType, width, height } = req.body
135
+
136
+ if (!file) {
137
+ res.status(400).json({ error: 'No file uploaded' })
138
+ return
139
+ }
140
+
141
+ const prisma = getDbClient()
142
+ const crypto = await import('node:crypto')
143
+
144
+ // Calculate checksum
145
+ const checksum = crypto
146
+ .createHash('sha256')
147
+ .update(file.buffer)
148
+ .digest('hex')
149
+
150
+ // Convert Buffer to Uint8Array for Prisma Bytes type
151
+ const content = new Uint8Array(file.buffer)
152
+
153
+ // Upsert asset (update if exists, create if not)
154
+ await prisma.dbAsset.upsert({
155
+ where: { name },
156
+ update: {
157
+ content,
158
+ checksum,
159
+ mimeType: mimeType || file.mimetype,
160
+ fileSize: file.size,
161
+ width: width ? parseInt(width) : null,
162
+ height: height ? parseInt(height) : null,
163
+ assetType: assetType || 'icon',
164
+ },
165
+ create: {
166
+ name,
167
+ content,
168
+ checksum,
169
+ mimeType: mimeType || file.mimetype,
170
+ fileSize: file.size,
171
+ width: width ? parseInt(width) : null,
172
+ height: height ? parseInt(height) : null,
173
+ assetType: assetType || 'icon',
174
+ },
175
+ })
176
+
177
+ res.json({ success: true, name, size: file.size })
178
+ } catch (error) {
179
+ console.error('Error importing asset:', error)
180
+ res.status(500).json({ error: 'Failed to import asset' })
181
+ }
182
+ }