@env-hopper/backend-core 2.0.1-alpha → 2.0.1-alpha.2

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 (66) hide show
  1. package/dist/index.d.ts +1584 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +1806 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +26 -11
  6. package/prisma/migrations/20250526183023_init/migration.sql +71 -0
  7. package/prisma/migrations/migration_lock.toml +3 -0
  8. package/prisma/schema.prisma +121 -0
  9. package/src/db/client.ts +34 -0
  10. package/src/db/index.ts +17 -0
  11. package/src/db/syncAppCatalog.ts +67 -0
  12. package/src/db/tableSyncMagazine.ts +22 -0
  13. package/src/db/tableSyncPrismaAdapter.ts +202 -0
  14. package/src/index.ts +96 -3
  15. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  16. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  17. package/src/modules/appCatalog/service.ts +79 -0
  18. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +113 -0
  19. package/src/modules/assets/assetRestController.ts +309 -0
  20. package/src/modules/assets/assetUtils.ts +81 -0
  21. package/src/modules/assets/screenshotRestController.ts +195 -0
  22. package/src/modules/assets/screenshotRouter.ts +116 -0
  23. package/src/modules/assets/syncAssets.ts +261 -0
  24. package/src/modules/auth/auth.ts +51 -0
  25. package/src/modules/auth/authProviders.ts +108 -0
  26. package/src/modules/auth/authRouter.ts +77 -0
  27. package/src/modules/auth/authorizationUtils.ts +114 -0
  28. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  29. package/src/modules/icons/iconRestController.ts +190 -0
  30. package/src/modules/icons/iconRouter.ts +157 -0
  31. package/src/modules/icons/iconService.ts +73 -0
  32. package/src/server/controller.ts +102 -29
  33. package/src/server/ehStaticControllerContract.ts +8 -1
  34. package/src/server/ehTrpcContext.ts +0 -6
  35. package/src/types/backend/api.ts +1 -14
  36. package/src/types/backend/companySpecificBackend.ts +17 -0
  37. package/src/types/common/appCatalogTypes.ts +167 -0
  38. package/src/types/common/dataRootTypes.ts +72 -10
  39. package/src/types/index.ts +2 -0
  40. package/dist/esm/__tests__/dummy.test.d.ts +0 -1
  41. package/dist/esm/index.d.ts +0 -7
  42. package/dist/esm/index.js +0 -9
  43. package/dist/esm/index.js.map +0 -1
  44. package/dist/esm/server/controller.d.ts +0 -32
  45. package/dist/esm/server/controller.js +0 -35
  46. package/dist/esm/server/controller.js.map +0 -1
  47. package/dist/esm/server/db.d.ts +0 -2
  48. package/dist/esm/server/ehStaticControllerContract.d.ts +0 -9
  49. package/dist/esm/server/ehStaticControllerContract.js +0 -12
  50. package/dist/esm/server/ehStaticControllerContract.js.map +0 -1
  51. package/dist/esm/server/ehTrpcContext.d.ts +0 -8
  52. package/dist/esm/server/ehTrpcContext.js +0 -11
  53. package/dist/esm/server/ehTrpcContext.js.map +0 -1
  54. package/dist/esm/types/backend/api.d.ts +0 -71
  55. package/dist/esm/types/backend/common.d.ts +0 -9
  56. package/dist/esm/types/backend/dataSources.d.ts +0 -20
  57. package/dist/esm/types/backend/deployments.d.ts +0 -34
  58. package/dist/esm/types/common/app/appTypes.d.ts +0 -12
  59. package/dist/esm/types/common/app/ui/appUiTypes.d.ts +0 -10
  60. package/dist/esm/types/common/appCatalogTypes.d.ts +0 -16
  61. package/dist/esm/types/common/dataRootTypes.d.ts +0 -32
  62. package/dist/esm/types/common/env/envTypes.d.ts +0 -6
  63. package/dist/esm/types/common/resourceTypes.d.ts +0 -8
  64. package/dist/esm/types/common/sharedTypes.d.ts +0 -4
  65. package/dist/esm/types/index.d.ts +0 -11
  66. package/src/server/db.ts +0 -4
@@ -0,0 +1,261 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { extname, join } from 'node:path'
3
+ import { getDbClient } from '../../db'
4
+ import { generateChecksum, getImageDimensions } from './assetUtils'
5
+
6
+ export interface SyncAssetsConfig {
7
+ /**
8
+ * Directory containing icon files to sync
9
+ */
10
+ iconsDir?: string
11
+
12
+ /**
13
+ * Directory containing screenshot files to sync
14
+ */
15
+ screenshotsDir?: string
16
+ }
17
+
18
+ /**
19
+ * Sync local asset files (icons and screenshots) from directories into the database.
20
+ *
21
+ * This function allows consuming applications to sync asset files without directly
22
+ * exposing the Prisma client. It handles:
23
+ * - Icon files: Assigned to apps by matching filename to icon name patterns
24
+ * - Screenshot files: Assigned to apps by matching filename to app ID (format: <app-id>_screenshot_<no>.<ext>)
25
+ *
26
+ * @param config Configuration with paths to icon and screenshot directories
27
+ */
28
+ export async function syncAssets(config: SyncAssetsConfig): Promise<{
29
+ iconsUpserted: number
30
+ screenshotsUpserted: number
31
+ }> {
32
+ const prisma = getDbClient()
33
+ let iconsUpserted = 0
34
+ let screenshotsUpserted = 0
35
+
36
+ // Sync icons from local/icons directory
37
+ if (config.iconsDir) {
38
+ console.log(`📁 Syncing icons from ${config.iconsDir}...`)
39
+ iconsUpserted = await syncIconsFromDirectory(prisma, config.iconsDir)
40
+ console.log(` ✓ Upserted ${iconsUpserted} icons`)
41
+ }
42
+
43
+ // Sync screenshots from local/screenshots directory
44
+ if (config.screenshotsDir) {
45
+ console.log(`📷 Syncing screenshots from ${config.screenshotsDir}...`)
46
+ screenshotsUpserted = await syncScreenshotsFromDirectory(prisma, config.screenshotsDir)
47
+ console.log(` ✓ Upserted ${screenshotsUpserted} screenshots and assigned to apps`)
48
+ }
49
+
50
+ return {
51
+ iconsUpserted,
52
+ screenshotsUpserted,
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Sync icon files from a directory
58
+ */
59
+ async function syncIconsFromDirectory(
60
+ prisma: ReturnType<typeof getDbClient>,
61
+ iconsDir: string,
62
+ ): Promise<number> {
63
+ let count = 0
64
+
65
+ try {
66
+ const files = readdirSync(iconsDir)
67
+
68
+ for (const file of files) {
69
+ const filePath = join(iconsDir, file)
70
+ const ext = extname(file).toLowerCase().slice(1) // Remove leading dot
71
+
72
+ // Skip non-image files
73
+ if (!['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
74
+ continue
75
+ }
76
+
77
+ try {
78
+ const content = readFileSync(filePath)
79
+ const buffer = Buffer.from(content)
80
+ const checksum = generateChecksum(buffer)
81
+ const iconName = file.replace(/\.[^/.]+$/, '') // Remove extension
82
+
83
+ // Check if asset with same checksum already exists
84
+ const existing = await prisma.dbAsset.findFirst({
85
+ where: { checksum, assetType: 'icon' },
86
+ })
87
+
88
+ if (existing) {
89
+ continue // Already synced
90
+ }
91
+
92
+ // Extract dimensions for raster images
93
+ let width: number | null = null
94
+ let height: number | null = null
95
+ if (!ext.includes('svg')) {
96
+ const { width: w, height: h } = await getImageDimensions(buffer)
97
+ width = w
98
+ height = h
99
+ }
100
+
101
+ // Determine MIME type
102
+ const mimeType = {
103
+ png: 'image/png',
104
+ jpg: 'image/jpeg',
105
+ jpeg: 'image/jpeg',
106
+ gif: 'image/gif',
107
+ webp: 'image/webp',
108
+ svg: 'image/svg+xml',
109
+ }[ext] || 'application/octet-stream'
110
+
111
+ await prisma.dbAsset.create({
112
+ data: {
113
+ name: iconName,
114
+ assetType: 'icon',
115
+ content: new Uint8Array(buffer),
116
+ checksum,
117
+ mimeType,
118
+ fileSize: buffer.length,
119
+ width,
120
+ height,
121
+ },
122
+ })
123
+
124
+ count++
125
+ } catch (error) {
126
+ console.warn(` ⚠ Failed to sync icon ${file}:`, error)
127
+ }
128
+ }
129
+ } catch (error) {
130
+ console.error(` ❌ Error reading icons directory:`, error)
131
+ }
132
+
133
+ return count
134
+ }
135
+
136
+ /**
137
+ * Sync screenshot files from a directory and assign to apps
138
+ */
139
+ async function syncScreenshotsFromDirectory(
140
+ prisma: ReturnType<typeof getDbClient>,
141
+ screenshotsDir: string,
142
+ ): Promise<number> {
143
+ let count = 0
144
+
145
+ try {
146
+ const files = readdirSync(screenshotsDir)
147
+
148
+ // Group screenshots by app ID
149
+ const screenshotsByApp = new Map<string, Array<{ path: string; ext: string }>>()
150
+
151
+ for (const file of files) {
152
+ // Parse filename: <app-id>_screenshot_<no>.<ext>
153
+ const match = file.match(/^(.+?)_screenshot_(\d+)\.([^.]+)$/)
154
+ if (!match || !match[1] || !match[3]) {
155
+ continue
156
+ }
157
+
158
+ const appId = match[1]
159
+ const ext = match[3]
160
+ if (!screenshotsByApp.has(appId)) {
161
+ screenshotsByApp.set(appId, [])
162
+ }
163
+ screenshotsByApp.get(appId)!.push({
164
+ path: join(screenshotsDir, file),
165
+ ext,
166
+ })
167
+ }
168
+
169
+ // Process each app's screenshots
170
+ for (const [appId, screenshots] of screenshotsByApp) {
171
+ try {
172
+ // Check if app exists
173
+ const app = await prisma.dbAppForCatalog.findUnique({
174
+ where: { slug: appId },
175
+ select: { id: true },
176
+ })
177
+
178
+ if (!app) {
179
+ console.warn(` ⚠ App not found: ${appId}`)
180
+ continue
181
+ }
182
+
183
+ // Sync screenshots for this app
184
+ for (const screenshot of screenshots) {
185
+ try {
186
+ const content = readFileSync(screenshot.path)
187
+ const buffer = Buffer.from(content)
188
+ const checksum = generateChecksum(buffer)
189
+
190
+ // Check if screenshot with same checksum already exists
191
+ const existing = await prisma.dbAsset.findFirst({
192
+ where: { checksum, assetType: 'screenshot' },
193
+ })
194
+
195
+ if (existing) {
196
+ // Link to app via screenshotIds array if not already linked
197
+ const existingApp = await prisma.dbAppForCatalog.findUnique({
198
+ where: { slug: appId },
199
+ })
200
+ if (existingApp && !existingApp.screenshotIds.includes(existing.id)) {
201
+ await prisma.dbAppForCatalog.update({
202
+ where: { slug: appId },
203
+ data: {
204
+ screenshotIds: [...existingApp.screenshotIds, existing.id],
205
+ },
206
+ })
207
+ }
208
+ continue
209
+ }
210
+
211
+ // Extract dimensions
212
+ const { width, height } = await getImageDimensions(buffer)
213
+
214
+ // Determine MIME type
215
+ const mimeType = {
216
+ png: 'image/png',
217
+ jpg: 'image/jpeg',
218
+ jpeg: 'image/jpeg',
219
+ gif: 'image/gif',
220
+ webp: 'image/webp',
221
+ }[screenshot.ext.toLowerCase()] || 'application/octet-stream'
222
+
223
+ // Create screenshot asset
224
+ const asset = await prisma.dbAsset.create({
225
+ data: {
226
+ name: `${appId}-screenshot-${Date.now()}`,
227
+ assetType: 'screenshot',
228
+ content: new Uint8Array(buffer),
229
+ checksum,
230
+ mimeType,
231
+ fileSize: buffer.length,
232
+ width,
233
+ height,
234
+ },
235
+ })
236
+
237
+ // Link screenshot to app via screenshotIds array
238
+ await prisma.dbAppForCatalog.update({
239
+ where: { slug: appId },
240
+ data: {
241
+ screenshotIds: {
242
+ push: asset.id,
243
+ },
244
+ },
245
+ })
246
+
247
+ count++
248
+ } catch (error) {
249
+ console.warn(` ⚠ Failed to sync screenshot ${screenshot.path}:`, error)
250
+ }
251
+ }
252
+ } catch (error) {
253
+ console.warn(` ⚠ Failed to process app ${appId}:`, error)
254
+ }
255
+ }
256
+ } catch (error) {
257
+ console.error(` ❌ Error reading screenshots directory:`, error)
258
+ }
259
+
260
+ return count
261
+ }
@@ -0,0 +1,51 @@
1
+ import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
2
+ import { betterAuth } from 'better-auth'
3
+ import { prismaAdapter } from 'better-auth/adapters/prisma'
4
+ import { getDbClient } from '../../db'
5
+
6
+ export interface AuthConfig {
7
+ appName?: string
8
+ baseURL: string
9
+ secret: string
10
+ providers?: BetterAuthOptions['socialProviders']
11
+ plugins?: Array<BetterAuthPlugin>
12
+ /** Session expiration in seconds. Default: 7 days (604800) */
13
+ sessionExpiresIn?: number
14
+ /** Session update age in seconds. Default: 1 day (86400) */
15
+ sessionUpdateAge?: number
16
+ }
17
+
18
+ export function createAuth(config: AuthConfig) {
19
+ const prisma = getDbClient()
20
+ const isProduction = process.env.NODE_ENV === 'production'
21
+
22
+ const auth = betterAuth({
23
+ appName: config.appName || 'EnvHopper',
24
+ baseURL: config.baseURL,
25
+ basePath: '/api/auth',
26
+ secret: config.secret,
27
+ database: prismaAdapter(prisma, {
28
+ provider: 'postgresql',
29
+ }),
30
+ socialProviders: config.providers || {},
31
+ plugins: config.plugins || [],
32
+ emailAndPassword: {
33
+ enabled: true,
34
+ },
35
+ session: {
36
+ expiresIn: config.sessionExpiresIn ?? 60 * 60 * 24 * 30,
37
+ updateAge: config.sessionUpdateAge ?? 60 * 60 * 24,
38
+ cookieCache: {
39
+ enabled: true,
40
+ maxAge: 300,
41
+ },
42
+ },
43
+ advanced: {
44
+ useSecureCookies: isProduction,
45
+ },
46
+ })
47
+
48
+ return auth
49
+ }
50
+
51
+ export type BetterAuth = ReturnType<typeof createAuth>
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Auth provider configuration from environment variables
3
+ * This is the recommended way to configure auth providers.
4
+ *
5
+ * Supports: GitHub, Google via environment variables
6
+ * For Okta and other custom providers, use getAuthPluginsFromEnv()
7
+ *
8
+ * Example .env:
9
+ * AUTH_GITHUB_CLIENT_ID=your_github_client_id
10
+ * AUTH_GITHUB_CLIENT_SECRET=your_github_client_secret
11
+ * AUTH_GOOGLE_CLIENT_ID=your_google_client_id
12
+ * AUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret
13
+ */
14
+
15
+ import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
16
+ import { genericOAuth, okta } from 'better-auth/plugins'
17
+
18
+ export function getAuthProvidersFromEnv(): BetterAuthOptions['socialProviders'] {
19
+ const providers: BetterAuthOptions['socialProviders'] = {}
20
+
21
+ // GitHub OAuth
22
+ if (
23
+ process.env.AUTH_GITHUB_CLIENT_ID &&
24
+ process.env.AUTH_GITHUB_CLIENT_SECRET
25
+ ) {
26
+ providers.github = {
27
+ clientId: process.env.AUTH_GITHUB_CLIENT_ID,
28
+ clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET,
29
+ }
30
+ }
31
+
32
+ // Google OAuth
33
+ if (
34
+ process.env.AUTH_GOOGLE_CLIENT_ID &&
35
+ process.env.AUTH_GOOGLE_CLIENT_SECRET
36
+ ) {
37
+ providers.google = {
38
+ clientId: process.env.AUTH_GOOGLE_CLIENT_ID,
39
+ clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET,
40
+ }
41
+ }
42
+
43
+ return providers
44
+ }
45
+
46
+ /**
47
+ * Get auth plugins from environment variables
48
+ * Currently supports: Okta
49
+ *
50
+ * Example .env:
51
+ * AUTH_OKTA_CLIENT_ID=your_okta_client_id
52
+ * AUTH_OKTA_CLIENT_SECRET=your_okta_client_secret
53
+ * AUTH_OKTA_ISSUER=https://your-org.okta.com/oauth2/ausxb83g4wY1x09ec0h7
54
+ *
55
+ * Note: If you get "User is not assigned to the client application" errors,
56
+ * you need to configure your Okta application to allow all users:
57
+ * 1. In Okta Admin Console, go to Applications → Your App
58
+ * 2. Assignments tab → Assign to Groups → Add "Everyone" group
59
+ * OR
60
+ * 3. Edit the application → In "User consent" section, enable appropriate settings
61
+ *
62
+ * For group-based authorization:
63
+ * 1. Add "groups" scope to your auth server policy rule
64
+ * 2. Create a groups claim in your auth server
65
+ * 3. Groups will be available in the user object after authentication
66
+ */
67
+ export function getAuthPluginsFromEnv(): Array<BetterAuthPlugin> {
68
+ const plugins: Array<BetterAuthPlugin> = []
69
+ const oktaConfig: Array<ReturnType<typeof okta>> = []
70
+
71
+ if (
72
+ process.env.AUTH_OKTA_CLIENT_ID &&
73
+ process.env.AUTH_OKTA_CLIENT_SECRET &&
74
+ process.env.AUTH_OKTA_ISSUER
75
+ ) {
76
+ oktaConfig.push(
77
+ okta({
78
+ clientId: process.env.AUTH_OKTA_CLIENT_ID,
79
+ clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET,
80
+ issuer: process.env.AUTH_OKTA_ISSUER,
81
+ }),
82
+ )
83
+ }
84
+
85
+ if (oktaConfig.length > 0) {
86
+ plugins.push(genericOAuth({ config: oktaConfig }))
87
+ }
88
+
89
+ return plugins
90
+ }
91
+
92
+ /**
93
+ * Validate required auth environment variables
94
+ */
95
+ export function validateAuthConfig(): void {
96
+ const secret = process.env.BETTER_AUTH_SECRET
97
+ const baseUrl = process.env.BETTER_AUTH_URL
98
+
99
+ if (!secret) {
100
+ console.warn(
101
+ 'BETTER_AUTH_SECRET not set. Using development fallback. Set this in production!',
102
+ )
103
+ }
104
+
105
+ if (!baseUrl) {
106
+ console.info('BETTER_AUTH_URL not set. Using default http://localhost:3000')
107
+ }
108
+ }
@@ -0,0 +1,77 @@
1
+ import type { BetterAuthPlugin } from 'better-auth'
2
+ import type { TRPCRootObject } from '@trpc/server'
3
+ import type { EhTrpcContext } from '../../server/ehTrpcContext'
4
+ import type { BetterAuth } from './auth'
5
+
6
+ /**
7
+ * Create auth tRPC procedures
8
+ * @param t - tRPC instance
9
+ * @param auth - Better Auth instance (optional, for future extensions)
10
+ * @returns tRPC router with auth procedures
11
+ */
12
+ export function createAuthRouter(
13
+ t: TRPCRootObject<EhTrpcContext, {}, {}>,
14
+ auth?: BetterAuth,
15
+ ) {
16
+ const router = t.router
17
+ const publicProcedure = t.procedure
18
+
19
+ return router({
20
+ getSession: publicProcedure.query(async ({ ctx }) => {
21
+ // Session will be extracted from cookies by better-auth middleware
22
+ // For now, return user info if available in context
23
+ const contextWithUser = ctx as EhTrpcContext & { user?: unknown }
24
+ return {
25
+ user: contextWithUser.user ?? null,
26
+ isAuthenticated: !!contextWithUser.user,
27
+ }
28
+ }),
29
+ getProviders: publicProcedure.query(() => {
30
+ // Return configured social providers and OAuth providers from plugins
31
+ const providers: Array<string> = []
32
+ const authOptions = auth?.options
33
+
34
+ // Add built-in social providers (github, google, etc.)
35
+ if (authOptions?.socialProviders) {
36
+ const socialProviders = authOptions.socialProviders as Record<
37
+ string,
38
+ unknown
39
+ >
40
+ Object.keys(socialProviders).forEach((key) => {
41
+ if (socialProviders[key]) {
42
+ providers.push(key)
43
+ }
44
+ })
45
+ }
46
+
47
+ // Add OAuth providers from plugins (like Okta via genericOAuth)
48
+ if (authOptions?.plugins) {
49
+ const plugins = authOptions.plugins
50
+ plugins.forEach((plugin) => {
51
+ const pluginWithConfig = plugin as BetterAuthPlugin & {
52
+ options?: {
53
+ config?: Array<{ providerId?: string }>
54
+ }
55
+ }
56
+ if (
57
+ pluginWithConfig.id === 'generic-oauth' &&
58
+ pluginWithConfig.options?.config
59
+ ) {
60
+ const configs = Array.isArray(pluginWithConfig.options.config)
61
+ ? pluginWithConfig.options.config
62
+ : [pluginWithConfig.options.config]
63
+ configs.forEach((config) => {
64
+ if (config.providerId) {
65
+ providers.push(config.providerId)
66
+ }
67
+ })
68
+ }
69
+ })
70
+ }
71
+
72
+ return { providers }
73
+ }),
74
+ })
75
+ }
76
+
77
+ export type AuthRouter = ReturnType<typeof createAuthRouter>
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Authorization utilities for checking user permissions based on groups
3
+ *
4
+ * Groups are automatically included in the user session when:
5
+ * 1. Okta auth server has a "groups" claim configured
6
+ * 2. The auth policy rule includes "groups" in scope_whitelist
7
+ *
8
+ * Example usage in tRPC procedures:
9
+ * ```typescript
10
+ * myProcedure: protectedProcedure.query(async ({ ctx }) => {
11
+ * if (requireAdmin(ctx.user)) {
12
+ * // Admin-only logic
13
+ * }
14
+ * // Regular user logic
15
+ * })
16
+ * ```
17
+ */
18
+
19
+ export interface UserWithGroups {
20
+ id: string
21
+ email: string
22
+ name?: string
23
+ // Groups from Okta (or other identity provider)
24
+ // This will be populated if groups claim is configured
25
+ [key: string]: any
26
+ }
27
+
28
+ /**
29
+ * Extract groups from user object
30
+ * Groups can be stored in different locations depending on the OAuth provider
31
+ */
32
+ export function getUserGroups(
33
+ user: UserWithGroups | null | undefined,
34
+ ): Array<string> {
35
+ if (!user) {
36
+ return []
37
+ }
38
+
39
+ // Check common locations for group information
40
+ const groups =
41
+ user.groups || // Standard "groups" claim
42
+ (user as any).env_hopper_groups || // Custom env_hopper_groups claim
43
+ (user as any).oktaGroups || // Okta-specific
44
+ (user as any).roles || // Some providers use "roles"
45
+ []
46
+
47
+ return Array.isArray(groups) ? groups : []
48
+ }
49
+
50
+ /**
51
+ * Check if user is a member of any of the specified groups
52
+ */
53
+ export function isMemberOfAnyGroup(
54
+ user: UserWithGroups | null | undefined,
55
+ allowedGroups: Array<string>,
56
+ ): boolean {
57
+ const userGroups = getUserGroups(user)
58
+ return allowedGroups.some((group) => userGroups.includes(group))
59
+ }
60
+
61
+ /**
62
+ * Check if user is a member of all specified groups
63
+ */
64
+ export function isMemberOfAllGroups(
65
+ user: UserWithGroups | null | undefined,
66
+ requiredGroups: Array<string>,
67
+ ): boolean {
68
+ const userGroups = getUserGroups(user)
69
+ return requiredGroups.every((group) => userGroups.includes(group))
70
+ }
71
+
72
+ /**
73
+ * Get admin group names from environment variables
74
+ * Default: env_hopper_ui_super_admins
75
+ */
76
+ export function getAdminGroupsFromEnv(): Array<string> {
77
+ const adminGroups =
78
+ process.env.AUTH_ADMIN_GROUPS || 'env_hopper_ui_super_admins'
79
+ return adminGroups
80
+ .split(',')
81
+ .map((g) => g.trim())
82
+ .filter(Boolean)
83
+ }
84
+
85
+ /**
86
+ * Check if user has admin permissions
87
+ */
88
+ export function isAdmin(user: UserWithGroups | null | undefined): boolean {
89
+ const adminGroups = getAdminGroupsFromEnv()
90
+ return isMemberOfAnyGroup(user, adminGroups)
91
+ }
92
+
93
+ /**
94
+ * Require admin permissions - throws error if not admin
95
+ */
96
+ export function requireAdmin(user: UserWithGroups | null | undefined): void {
97
+ if (!isAdmin(user)) {
98
+ throw new Error('Forbidden: Admin access required')
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Require membership in specific groups - throws error if not member
104
+ */
105
+ export function requireGroups(
106
+ user: UserWithGroups | null | undefined,
107
+ groups: Array<string>,
108
+ ): void {
109
+ if (!isMemberOfAnyGroup(user, groups)) {
110
+ throw new Error(
111
+ `Forbidden: Membership in one of these groups required: ${groups.join(', ')}`,
112
+ )
113
+ }
114
+ }
@@ -0,0 +1,33 @@
1
+ import { toNodeHandler } from 'better-auth/node'
2
+ import type { Express, Request, Response } from 'express'
3
+ import type { BetterAuth } from './auth'
4
+
5
+ /**
6
+ * Register Better Auth routes with Express
7
+ * @param app - Express application instance
8
+ * @param auth - Better Auth instance
9
+ */
10
+ export function registerAuthRoutes(app: Express, auth: BetterAuth) {
11
+ // Explicit session endpoint handler
12
+ // Better Auth's toNodeHandler doesn't expose a direct /session endpoint
13
+ app.get('/api/auth/session', async (req: Request, res: Response) => {
14
+ try {
15
+ const session = await auth.api.getSession({
16
+ headers: req.headers as HeadersInit,
17
+ })
18
+ if (session) {
19
+ res.json(session)
20
+ } else {
21
+ res.status(401).json({ error: 'Not authenticated' })
22
+ }
23
+ } catch (error) {
24
+ console.error('[Auth Session Error]', error)
25
+ res.status(500).json({ error: 'Internal server error' })
26
+ }
27
+ })
28
+
29
+ // Use toNodeHandler to adapt better-auth for Express/Node.js
30
+ // Express v5 wildcard syntax: /{*any} (also works with Express v4)
31
+ const authHandler = toNodeHandler(auth)
32
+ app.all('/api/auth/{*any}', authHandler)
33
+ }