@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.
- package/dist/index.d.ts +1584 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1806 -0
- package/dist/index.js.map +1 -0
- package/package.json +26 -11
- package/prisma/migrations/20250526183023_init/migration.sql +71 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +121 -0
- package/src/db/client.ts +34 -0
- package/src/db/index.ts +17 -0
- package/src/db/syncAppCatalog.ts +67 -0
- package/src/db/tableSyncMagazine.ts +22 -0
- package/src/db/tableSyncPrismaAdapter.ts +202 -0
- package/src/index.ts +96 -3
- package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
- package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
- package/src/modules/appCatalog/service.ts +79 -0
- package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +113 -0
- package/src/modules/assets/assetRestController.ts +309 -0
- package/src/modules/assets/assetUtils.ts +81 -0
- package/src/modules/assets/screenshotRestController.ts +195 -0
- package/src/modules/assets/screenshotRouter.ts +116 -0
- package/src/modules/assets/syncAssets.ts +261 -0
- package/src/modules/auth/auth.ts +51 -0
- package/src/modules/auth/authProviders.ts +108 -0
- package/src/modules/auth/authRouter.ts +77 -0
- package/src/modules/auth/authorizationUtils.ts +114 -0
- package/src/modules/auth/registerAuthRoutes.ts +33 -0
- package/src/modules/icons/iconRestController.ts +190 -0
- package/src/modules/icons/iconRouter.ts +157 -0
- package/src/modules/icons/iconService.ts +73 -0
- package/src/server/controller.ts +102 -29
- package/src/server/ehStaticControllerContract.ts +8 -1
- package/src/server/ehTrpcContext.ts +0 -6
- package/src/types/backend/api.ts +1 -14
- package/src/types/backend/companySpecificBackend.ts +17 -0
- package/src/types/common/appCatalogTypes.ts +167 -0
- package/src/types/common/dataRootTypes.ts +72 -10
- package/src/types/index.ts +2 -0
- package/dist/esm/__tests__/dummy.test.d.ts +0 -1
- package/dist/esm/index.d.ts +0 -7
- package/dist/esm/index.js +0 -9
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/server/controller.d.ts +0 -32
- package/dist/esm/server/controller.js +0 -35
- package/dist/esm/server/controller.js.map +0 -1
- package/dist/esm/server/db.d.ts +0 -2
- package/dist/esm/server/ehStaticControllerContract.d.ts +0 -9
- package/dist/esm/server/ehStaticControllerContract.js +0 -12
- package/dist/esm/server/ehStaticControllerContract.js.map +0 -1
- package/dist/esm/server/ehTrpcContext.d.ts +0 -8
- package/dist/esm/server/ehTrpcContext.js +0 -11
- package/dist/esm/server/ehTrpcContext.js.map +0 -1
- package/dist/esm/types/backend/api.d.ts +0 -71
- package/dist/esm/types/backend/common.d.ts +0 -9
- package/dist/esm/types/backend/dataSources.d.ts +0 -20
- package/dist/esm/types/backend/deployments.d.ts +0 -34
- package/dist/esm/types/common/app/appTypes.d.ts +0 -12
- package/dist/esm/types/common/app/ui/appUiTypes.d.ts +0 -10
- package/dist/esm/types/common/appCatalogTypes.d.ts +0 -16
- package/dist/esm/types/common/dataRootTypes.d.ts +0 -32
- package/dist/esm/types/common/env/envTypes.d.ts +0 -6
- package/dist/esm/types/common/resourceTypes.d.ts +0 -8
- package/dist/esm/types/common/sharedTypes.d.ts +0 -4
- package/dist/esm/types/index.d.ts +0 -11
- 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
|
+
}
|