@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/dist/index.d.ts +21 -59
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +154 -68
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/middleware/createEhMiddleware.ts +11 -85
- package/src/middleware/featureRegistry.ts +29 -0
- package/src/middleware/index.ts +25 -0
- package/src/middleware/types.ts +24 -5
- package/src/modules/appCatalogAdmin/catalogBackupController.ts +182 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@env-hopper/backend-core",
|
|
3
|
-
"version": "2.0.1-alpha.
|
|
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/
|
|
52
|
-
"@env-hopper/
|
|
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 {
|
|
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
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/middleware/index.ts
CHANGED
|
@@ -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
|
|
package/src/middleware/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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<{
|
|
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
|
+
}
|