@env-hopper/backend-core 2.0.1-alpha.2 → 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.2",
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": {
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.8.0",
50
50
  "zod": "^4.3.5",
51
51
  "@env-hopper/shared-core": "2.0.1-alpha.1",
52
- "@env-hopper/table-sync": "2.0.1-alpha.2"
52
+ "@env-hopper/table-sync": "2.0.1-alpha"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@tanstack/vite-config": "^0.4.3",
package/src/db/client.ts CHANGED
@@ -13,6 +13,14 @@ export function getDbClient(): PrismaClient {
13
13
  return prismaClient
14
14
  }
15
15
 
16
+ /**
17
+ * Sets the internal Prisma client instance.
18
+ * Used by middleware to bridge with existing getDbClient() usage.
19
+ */
20
+ export function setDbClient(client: PrismaClient): void {
21
+ prismaClient = client
22
+ }
23
+
16
24
  /**
17
25
  * Connects to the database.
18
26
  * Call this before performing database operations.
package/src/db/index.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  // Database connection
2
- export { connectDb, disconnectDb, getDbClient } from './client'
2
+ export { connectDb, disconnectDb, getDbClient, setDbClient } from './client'
3
3
 
4
4
  // Table sync utilities
5
5
  export {
6
- tableSyncPrisma, type MakeTFromPrismaModel, type ObjectKeys,
7
- type ScalarFilter, type ScalarKeys, type TableSyncParamsPrisma
6
+ tableSyncPrisma,
7
+ type MakeTFromPrismaModel,
8
+ type ObjectKeys,
9
+ type ScalarFilter,
10
+ type ScalarKeys,
11
+ type TableSyncParamsPrisma,
8
12
  } from './tableSyncPrismaAdapter'
9
13
 
10
14
  export {
11
15
  TABLE_SYNC_MAGAZINE,
12
16
  type TableSyncMagazine,
13
- type TableSyncMagazineModelNameKey
17
+ type TableSyncMagazineModelNameKey,
14
18
  } from './tableSyncMagazine'
15
19
 
16
20
  // App catalog sync
package/src/index.ts CHANGED
@@ -4,8 +4,8 @@ export { createTrpcRouter } from './server/controller'
4
4
  export type { TRPCRouter } from './server/controller'
5
5
  export { createEhTrpcContext } from './server/ehTrpcContext'
6
6
  export type {
7
- EhTrpcContext,
8
- EhTrpcContextOptions
7
+ EhTrpcContext,
8
+ EhTrpcContextOptions,
9
9
  } from './server/ehTrpcContext'
10
10
 
11
11
  export { staticControllerContract } from './server/ehStaticControllerContract'
@@ -20,92 +20,107 @@ export * from './types/index'
20
20
 
21
21
  // Auth
22
22
  export {
23
- createAuth,
24
- type AuthConfig,
25
- type BetterAuth
23
+ createAuth,
24
+ type AuthConfig,
25
+ type BetterAuth,
26
26
  } from './modules/auth/auth'
27
27
 
28
- export {
29
- registerAuthRoutes
30
- } from './modules/auth/registerAuthRoutes'
28
+ export { registerAuthRoutes } from './modules/auth/registerAuthRoutes'
31
29
 
32
- export {
33
- createAuthRouter,
34
- type AuthRouter
35
- } from './modules/auth/authRouter'
30
+ export { createAuthRouter, type AuthRouter } from './modules/auth/authRouter'
36
31
 
37
32
  export {
38
- getAuthProvidersFromEnv,
39
- getAuthPluginsFromEnv,
40
- validateAuthConfig
33
+ getAuthProvidersFromEnv,
34
+ getAuthPluginsFromEnv,
35
+ validateAuthConfig,
41
36
  } from './modules/auth/authProviders'
42
37
 
43
38
  export {
44
- getUserGroups,
45
- isMemberOfAnyGroup,
46
- isMemberOfAllGroups,
47
- getAdminGroupsFromEnv,
48
- isAdmin,
49
- requireAdmin,
50
- requireGroups,
51
- type UserWithGroups
39
+ getUserGroups,
40
+ isMemberOfAnyGroup,
41
+ isMemberOfAllGroups,
42
+ getAdminGroupsFromEnv,
43
+ isAdmin,
44
+ requireAdmin,
45
+ requireGroups,
46
+ type UserWithGroups,
52
47
  } from './modules/auth/authorizationUtils'
53
48
 
54
49
  // Admin
55
50
  export {
56
- createAdminChatHandler,
57
- tool,
58
- type AdminChatHandlerOptions
51
+ createAdminChatHandler,
52
+ tool,
53
+ type AdminChatHandlerOptions,
59
54
  } from './modules/admin/chat/createAdminChatHandler'
60
55
 
61
56
  export {
62
- createDatabaseTools,
63
- createPrismaDatabaseClient, DEFAULT_ADMIN_SYSTEM_PROMPT, type DatabaseClient
57
+ createDatabaseTools,
58
+ createPrismaDatabaseClient,
59
+ DEFAULT_ADMIN_SYSTEM_PROMPT,
60
+ type DatabaseClient,
64
61
  } from './modules/admin/chat/createDatabaseTools'
65
62
 
66
63
  // Icon management
67
64
  export {
68
- registerIconRestController,
69
- type IconRestControllerConfig
65
+ registerIconRestController,
66
+ type IconRestControllerConfig,
70
67
  } from './modules/icons/iconRestController'
71
68
 
72
69
  export {
73
- getAssetByName,
74
- upsertIcon,
75
- upsertIcons,
76
- type UpsertIconInput
70
+ getAssetByName,
71
+ upsertIcon,
72
+ upsertIcons,
73
+ type UpsertIconInput,
77
74
  } from './modules/icons/iconService'
78
75
 
79
76
  // Asset management (universal for icons, screenshots, etc.)
80
77
  export {
81
- registerAssetRestController,
82
- type AssetRestControllerConfig
78
+ registerAssetRestController,
79
+ type AssetRestControllerConfig,
83
80
  } from './modules/assets/assetRestController'
84
81
 
85
82
  export {
86
- registerScreenshotRestController,
87
- type ScreenshotRestControllerConfig
83
+ registerScreenshotRestController,
84
+ type ScreenshotRestControllerConfig,
88
85
  } from './modules/assets/screenshotRestController'
89
86
 
90
- export {
91
- createScreenshotRouter
92
- } from './modules/assets/screenshotRouter'
87
+ export { createScreenshotRouter } from './modules/assets/screenshotRouter'
93
88
 
94
- export {
95
- syncAssets,
96
- type SyncAssetsConfig
97
- } from './modules/assets/syncAssets'
89
+ export { syncAssets, type SyncAssetsConfig } from './modules/assets/syncAssets'
98
90
 
99
91
  // App Catalog Admin
100
- export {
101
- createAppCatalogAdminRouter
102
- } from './modules/appCatalogAdmin/appCatalogAdminRouter'
92
+ export { createAppCatalogAdminRouter } from './modules/appCatalogAdmin/appCatalogAdminRouter'
103
93
 
104
94
  // Database utilities
105
95
  export {
106
- connectDb,
107
- disconnectDb, getDbClient, syncAppCatalog, TABLE_SYNC_MAGAZINE, tableSyncPrisma, type MakeTFromPrismaModel, type ObjectKeys,
108
- type ScalarFilter, type ScalarKeys, type SyncAppCatalogResult, type TableSyncMagazine,
109
- type TableSyncMagazineModelNameKey, type TableSyncParamsPrisma
96
+ connectDb,
97
+ disconnectDb,
98
+ getDbClient,
99
+ setDbClient,
100
+ syncAppCatalog,
101
+ TABLE_SYNC_MAGAZINE,
102
+ tableSyncPrisma,
103
+ type MakeTFromPrismaModel,
104
+ type ObjectKeys,
105
+ type ScalarFilter,
106
+ type ScalarKeys,
107
+ type SyncAppCatalogResult,
108
+ type TableSyncMagazine,
109
+ type TableSyncMagazineModelNameKey,
110
+ type TableSyncParamsPrisma,
110
111
  } from './db'
111
112
 
113
+ // Middleware (batteries-included backend setup)
114
+ export {
115
+ createEhMiddleware,
116
+ EhDatabaseManager,
117
+ type EhDatabaseConfig,
118
+ type EhAuthConfig,
119
+ type EhAdminChatConfig,
120
+ type EhFeatureToggles,
121
+ type EhBackendProvider,
122
+ type EhLifecycleHooks,
123
+ type EhMiddlewareOptions,
124
+ type EhMiddlewareResult,
125
+ type MiddlewareContext,
126
+ } from './middleware'
@@ -0,0 +1,49 @@
1
+ import type { EhBackendCompanySpecificBackend } from '../types/backend/companySpecificBackend'
2
+ import type { EhBackendProvider } from './types'
3
+
4
+ /**
5
+ * Type guard to check if an object implements EhBackendCompanySpecificBackend.
6
+ */
7
+ function isBackendInstance(obj: unknown): obj is EhBackendCompanySpecificBackend {
8
+ return (
9
+ typeof obj === 'object' &&
10
+ obj !== null &&
11
+ typeof (obj as EhBackendCompanySpecificBackend).getBootstrapData === 'function' &&
12
+ typeof (obj as EhBackendCompanySpecificBackend).getAvailabilityMatrix ===
13
+ 'function' &&
14
+ typeof (obj as EhBackendCompanySpecificBackend).getNameMigrations ===
15
+ 'function' &&
16
+ typeof (obj as EhBackendCompanySpecificBackend).getResourceJumps ===
17
+ 'function' &&
18
+ typeof (obj as EhBackendCompanySpecificBackend).getResourceJumpsExtended ===
19
+ 'function'
20
+ )
21
+ }
22
+
23
+ /**
24
+ * Normalizes different backend provider types into a consistent async factory function.
25
+ * Supports:
26
+ * - Direct object implementing EhBackendCompanySpecificBackend
27
+ * - Sync factory function that returns the backend
28
+ * - Async factory function that returns the backend
29
+ */
30
+ export function createBackendResolver(
31
+ provider: EhBackendProvider,
32
+ ): () => Promise<EhBackendCompanySpecificBackend> {
33
+ // If it's already an object with the required methods, wrap it
34
+ if (isBackendInstance(provider)) {
35
+ return async () => provider
36
+ }
37
+
38
+ // If it's a function, call it and handle both sync and async results
39
+ if (typeof provider === 'function') {
40
+ return async () => {
41
+ const result = provider()
42
+ return result instanceof Promise ? result : result
43
+ }
44
+ }
45
+
46
+ throw new Error(
47
+ 'Invalid backend provider: must be an object implementing EhBackendCompanySpecificBackend or a factory function',
48
+ )
49
+ }
@@ -0,0 +1,103 @@
1
+ import express, { Router } from 'express'
2
+ import * as trpcExpress from '@trpc/server/adapters/express'
3
+ import type {
4
+ EhMiddlewareOptions,
5
+ EhMiddlewareResult,
6
+ MiddlewareContext,
7
+ } from './types'
8
+ import { EhDatabaseManager } from './database'
9
+ import { createBackendResolver } from './backendResolver'
10
+ import { registerFeatures } from './featureRegistry'
11
+ import { createTrpcRouter } from '../server/controller'
12
+ import { createEhTrpcContext } from '../server/ehTrpcContext'
13
+ import { createAuth } from '../modules/auth/auth'
14
+
15
+ export async function createEhMiddleware(
16
+ options: EhMiddlewareOptions,
17
+ ): Promise<EhMiddlewareResult> {
18
+ // Normalize options with defaults
19
+ const basePath = options.basePath ?? '/api'
20
+ const normalizedOptions = { ...options, basePath }
21
+
22
+ // Initialize database manager
23
+ const dbManager = new EhDatabaseManager(options.database)
24
+ // Initialize the client (which also sets the global singleton)
25
+ dbManager.getClient()
26
+
27
+ // Create auth instance
28
+ const auth = createAuth({
29
+ appName: options.auth.appName,
30
+ baseURL: options.auth.baseURL,
31
+ secret: options.auth.secret,
32
+ providers: options.auth.providers,
33
+ plugins: options.auth.plugins,
34
+ sessionExpiresIn: options.auth.sessionExpiresIn,
35
+ sessionUpdateAge: options.auth.sessionUpdateAge,
36
+ })
37
+
38
+ // Create tRPC router
39
+ const trpcRouter = createTrpcRouter(auth)
40
+
41
+ // Normalize backend provider to async factory function
42
+ const resolveBackend = createBackendResolver(options.backend)
43
+
44
+ // Create tRPC context factory
45
+ const createContext = async () => {
46
+ const companySpecificBackend = await resolveBackend()
47
+ return createEhTrpcContext({ companySpecificBackend })
48
+ }
49
+
50
+ // Create Express router
51
+ const router = Router()
52
+ router.use(express.json())
53
+
54
+ // Build middleware context for feature registration
55
+ const middlewareContext: MiddlewareContext = {
56
+ auth,
57
+ trpcRouter,
58
+ createContext,
59
+ }
60
+
61
+ // Register tRPC middleware (if enabled)
62
+ if (normalizedOptions.features?.trpc !== false) {
63
+ router.use(
64
+ `${basePath}/trpc`,
65
+ trpcExpress.createExpressMiddleware({
66
+ router: trpcRouter,
67
+ createContext,
68
+ }),
69
+ )
70
+ }
71
+
72
+ // Register all enabled features
73
+ registerFeatures(router, normalizedOptions, middlewareContext)
74
+
75
+ // Call onRoutesRegistered hook if provided
76
+ if (options.hooks?.onRoutesRegistered) {
77
+ await options.hooks.onRoutesRegistered(router)
78
+ }
79
+
80
+ return {
81
+ router,
82
+ auth,
83
+ trpcRouter,
84
+
85
+ async connect(): Promise<void> {
86
+ await dbManager.connect()
87
+ if (options.hooks?.onDatabaseConnected) {
88
+ await options.hooks.onDatabaseConnected()
89
+ }
90
+ },
91
+
92
+ async disconnect(): Promise<void> {
93
+ if (options.hooks?.onDatabaseDisconnecting) {
94
+ await options.hooks.onDatabaseDisconnecting()
95
+ }
96
+ await dbManager.disconnect()
97
+ },
98
+
99
+ addRoutes(callback: (router: Router) => void): void {
100
+ callback(router)
101
+ },
102
+ }
103
+ }
@@ -0,0 +1,62 @@
1
+ import { PrismaClient } from '@prisma/client'
2
+ import type { EhDatabaseConfig } from './types'
3
+ import { setDbClient } from '../db/client'
4
+
5
+ /**
6
+ * Formats a database connection URL from structured config.
7
+ */
8
+ function formatConnectionUrl(config: EhDatabaseConfig): string {
9
+ if ('url' in config) {
10
+ return config.url
11
+ }
12
+
13
+ const { host, port, database, username, password, schema = 'public' } = config
14
+ return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=${schema}`
15
+ }
16
+
17
+ /**
18
+ * Internal database manager used by the middleware.
19
+ * Handles connection URL formatting and lifecycle.
20
+ */
21
+ export class EhDatabaseManager {
22
+ private client: PrismaClient | null = null
23
+ private config: EhDatabaseConfig
24
+
25
+ constructor(config: EhDatabaseConfig) {
26
+ this.config = config
27
+ }
28
+
29
+ /**
30
+ * Get or create the Prisma client instance.
31
+ * Uses lazy initialization for flexibility.
32
+ */
33
+ getClient(): PrismaClient {
34
+ if (!this.client) {
35
+ const datasourceUrl = formatConnectionUrl(this.config)
36
+
37
+ this.client = new PrismaClient({
38
+ datasourceUrl,
39
+ log:
40
+ process.env.NODE_ENV === 'development'
41
+ ? ['warn', 'error']
42
+ : ['warn', 'error'],
43
+ })
44
+
45
+ // Bridge with existing backend-core getDbClient() usage
46
+ setDbClient(this.client)
47
+ }
48
+ return this.client
49
+ }
50
+
51
+ async connect(): Promise<void> {
52
+ const client = this.getClient()
53
+ await client.$connect()
54
+ }
55
+
56
+ async disconnect(): Promise<void> {
57
+ if (this.client) {
58
+ await this.client.$disconnect()
59
+ this.client = null
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,178 @@
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
+
22
+ interface FeatureRegistration {
23
+ name: keyof EhFeatureToggles
24
+ defaultEnabled: boolean
25
+ register: (
26
+ router: Router,
27
+ options: Required<Pick<EhMiddlewareOptions, 'basePath'>> &
28
+ EhMiddlewareOptions,
29
+ context: MiddlewareContext,
30
+ ) => void
31
+ }
32
+
33
+ const FEATURES: Array<FeatureRegistration> = [
34
+ {
35
+ name: 'auth',
36
+ defaultEnabled: true,
37
+ register: (router, options, ctx) => {
38
+ const basePath = options.basePath
39
+
40
+ // Explicit session endpoint handler
41
+ router.get(`${basePath}/auth/session`, async (req, res) => {
42
+ try {
43
+ const session = await ctx.auth.api.getSession({
44
+ headers: req.headers as HeadersInit,
45
+ })
46
+ if (session) {
47
+ res.json(session)
48
+ } else {
49
+ res.status(401).json({ error: 'Not authenticated' })
50
+ }
51
+ } catch (error) {
52
+ console.error('[Auth Session Error]', error)
53
+ res.status(500).json({ error: 'Internal server error' })
54
+ }
55
+ })
56
+
57
+ // Use toNodeHandler to adapt better-auth for Express/Node.js
58
+ const authHandler = toNodeHandler(ctx.auth)
59
+ router.all(`${basePath}/auth/{*any}`, authHandler)
60
+ },
61
+ },
62
+ {
63
+ name: 'icons',
64
+ defaultEnabled: true,
65
+ register: (router, options) => {
66
+ registerIconRestController(router, {
67
+ basePath: `${options.basePath}/icons`,
68
+ })
69
+ },
70
+ },
71
+ {
72
+ name: 'assets',
73
+ defaultEnabled: true,
74
+ register: (router, options) => {
75
+ registerAssetRestController(router, {
76
+ basePath: `${options.basePath}/assets`,
77
+ })
78
+ },
79
+ },
80
+ {
81
+ name: 'screenshots',
82
+ defaultEnabled: true,
83
+ register: (router, options) => {
84
+ registerScreenshotRestController(router, {
85
+ basePath: `${options.basePath}/screenshots`,
86
+ })
87
+ },
88
+ },
89
+ {
90
+ name: 'adminChat',
91
+ defaultEnabled: false, // Only enabled if adminChat config is provided
92
+ register: (router, options) => {
93
+ if (options.adminChat) {
94
+ router.post(
95
+ `${options.basePath}/admin/chat`,
96
+ createAdminChatHandler(options.adminChat),
97
+ )
98
+ }
99
+ },
100
+ },
101
+ {
102
+ name: 'legacyIconEndpoint',
103
+ defaultEnabled: false,
104
+ register: (router) => {
105
+ // Legacy endpoint at /static/icon/:icon for backwards compatibility
106
+ router.get('/static/icon/:icon', async (req, res) => {
107
+ const { icon } = req.params
108
+
109
+ if (!icon || !/^[a-z0-9-]+$/i.test(icon)) {
110
+ res.status(400).send('Invalid icon name')
111
+ return
112
+ }
113
+
114
+ try {
115
+ const dbIcon = await getAssetByName(icon)
116
+
117
+ if (!dbIcon) {
118
+ res.status(404).send('Icon not found')
119
+ return
120
+ }
121
+
122
+ res.setHeader('Content-Type', dbIcon.mimeType)
123
+ res.setHeader('Cache-Control', 'public, max-age=86400')
124
+ res.send(dbIcon.content)
125
+ } catch (error) {
126
+ console.error('Error fetching icon:', error)
127
+ res.status(404).send('Icon not found')
128
+ }
129
+ })
130
+ },
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
+ },
153
+ ]
154
+
155
+ /**
156
+ * Registers all enabled features on the router.
157
+ */
158
+ export function registerFeatures(
159
+ router: Router,
160
+ options: Required<Pick<EhMiddlewareOptions, 'basePath'>> &
161
+ EhMiddlewareOptions,
162
+ context: MiddlewareContext,
163
+ ): void {
164
+ const toggles = options.features || {}
165
+
166
+ for (const feature of FEATURES) {
167
+ const isEnabled = toggles[feature.name] ?? feature.defaultEnabled
168
+
169
+ // Special case: adminChat is only enabled if config is provided
170
+ if (feature.name === 'adminChat' && !options.adminChat) {
171
+ continue
172
+ }
173
+
174
+ if (isEnabled) {
175
+ feature.register(router, options, context)
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,43 @@
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
+
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'