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

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.5",
4
4
  "description": "Backend core library for Env Hopper",
5
5
  "homepage": "https://github.com/lislon/env-hopper",
6
6
  "repository": {
@@ -48,7 +48,7 @@
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",
51
+ "@env-hopper/table-sync": "2.0.1-alpha",
52
52
  "@env-hopper/shared-core": "2.0.1-alpha.1"
53
53
  },
54
54
  "devDependencies": {
@@ -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
@@ -22,6 +30,7 @@ interface FeatureRegistration {
22
30
  ) => void
23
31
  }
24
32
 
33
+ // Optional features that can be toggled
25
34
  const FEATURES: Array<FeatureRegistration> = [
26
35
  {
27
36
  name: 'auth',
@@ -51,33 +60,6 @@ const FEATURES: Array<FeatureRegistration> = [
51
60
  router.all(`${basePath}/auth/{*any}`, authHandler)
52
61
  },
53
62
  },
54
- {
55
- name: 'icons',
56
- defaultEnabled: true,
57
- register: (router, options) => {
58
- registerIconRestController(router, {
59
- basePath: `${options.basePath}/icons`,
60
- })
61
- },
62
- },
63
- {
64
- name: 'assets',
65
- defaultEnabled: true,
66
- register: (router, options) => {
67
- registerAssetRestController(router, {
68
- basePath: `${options.basePath}/assets`,
69
- })
70
- },
71
- },
72
- {
73
- name: 'screenshots',
74
- defaultEnabled: true,
75
- register: (router, options) => {
76
- registerScreenshotRestController(router, {
77
- basePath: `${options.basePath}/screenshots`,
78
- })
79
- },
80
- },
81
63
  {
82
64
  name: 'adminChat',
83
65
  defaultEnabled: false, // Only enabled if adminChat config is provided
@@ -132,6 +114,38 @@ export function registerFeatures(
132
114
  EhMiddlewareOptions,
133
115
  context: MiddlewareContext,
134
116
  ): void {
117
+ const basePath = options.basePath
118
+
119
+ // Always-on features (required for core functionality)
120
+
121
+ // Icons
122
+ registerIconRestController(router, {
123
+ basePath: `${basePath}/icons`,
124
+ })
125
+
126
+ // Assets
127
+ registerAssetRestController(router, {
128
+ basePath: `${basePath}/assets`,
129
+ })
130
+
131
+ // Screenshots
132
+ registerScreenshotRestController(router, {
133
+ basePath: `${basePath}/screenshots`,
134
+ })
135
+
136
+ // Catalog backup/restore
137
+ const upload = multer({ storage: multer.memoryStorage() })
138
+ router.get(`${basePath}/catalog/backup/export`, exportCatalog)
139
+ router.post(`${basePath}/catalog/backup/import`, importCatalog)
140
+ router.get(`${basePath}/catalog/backup/assets`, listAssets)
141
+ router.get(`${basePath}/catalog/backup/assets/:name`, exportAsset)
142
+ router.post(
143
+ `${basePath}/catalog/backup/assets`,
144
+ upload.single('file'),
145
+ importAsset,
146
+ )
147
+
148
+ // Optional toggleable features
135
149
  const toggles = options.features || {}
136
150
 
137
151
  for (const feature of FEATURES) {
@@ -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
 
@@ -57,19 +57,15 @@ export interface EhAdminChatConfig {
57
57
 
58
58
  /**
59
59
  * Feature toggles for enabling/disabling specific functionality.
60
- * All features are enabled by default.
60
+ *
61
+ * Note: Icons, assets, screenshots, and catalog backup are always enabled.
62
+ * Only these optional features can be toggled:
61
63
  */
62
64
  export interface EhFeatureToggles {
63
65
  /** Enable tRPC endpoints (default: true) */
64
66
  trpc?: boolean
65
67
  /** Enable auth endpoints (default: true) */
66
68
  auth?: boolean
67
- /** Enable icon REST endpoints (default: true) */
68
- icons?: boolean
69
- /** Enable asset REST endpoints (default: true) */
70
- assets?: boolean
71
- /** Enable screenshot REST endpoints (default: true) */
72
- screenshots?: boolean
73
69
  /** Enable admin chat endpoint (default: true if adminChat config provided) */
74
70
  adminChat?: boolean
75
71
  /** Enable legacy icon endpoint at /static/icon/:icon (default: false) */
@@ -117,11 +113,10 @@ export interface EhMiddlewareOptions {
117
113
  basePath?: string
118
114
 
119
115
  /**
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.
116
+ * Database connection configuration (required).
117
+ * Backend-core manages the database for all features.
123
118
  */
124
- database?: EhDatabaseConfig
119
+ database: EhDatabaseConfig
125
120
 
126
121
  /** Auth configuration (required) */
127
122
  auth: EhAuthConfig
@@ -141,6 +136,22 @@ export interface EhMiddlewareOptions {
141
136
 
142
137
  /**
143
138
  * Result of middleware initialization.
139
+ *
140
+ * @example
141
+ * ```typescript
142
+ * const eh = await createEhMiddleware({ ... })
143
+ *
144
+ * // Mount routes
145
+ * app.use(eh.router)
146
+ *
147
+ * // Connect to database
148
+ * await eh.connect()
149
+ *
150
+ * // Cleanup on shutdown
151
+ * process.on('SIGTERM', async () => {
152
+ * await eh.disconnect()
153
+ * })
154
+ * ```
144
155
  */
145
156
  export interface EhMiddlewareResult {
146
157
  /** Express router with all env-hopper routes */
@@ -163,5 +174,7 @@ export interface EhMiddlewareResult {
163
174
  export interface MiddlewareContext {
164
175
  auth: BetterAuth
165
176
  trpcRouter: TRPCRouter
166
- createContext: () => Promise<{ companySpecificBackend: EhBackendCompanySpecificBackend }>
177
+ createContext: () => Promise<{
178
+ companySpecificBackend: EhBackendCompanySpecificBackend
179
+ }>
167
180
  }
@@ -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
+ }
@@ -47,7 +47,7 @@ export async function upsertIcon(input: UpsertIconInput) {
47
47
  * This is more efficient than calling upsertIcon multiple times.
48
48
  */
49
49
  export async function upsertIcons(icons: Array<UpsertIconInput>) {
50
- const results = []
50
+ const results: Array<Awaited<ReturnType<typeof upsertIcon>>> = []
51
51
  for (const icon of icons) {
52
52
  const result = await upsertIcon(icon)
53
53
  results.push(result)