@igstack/app-catalog-backend-core 0.0.1
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/LICENSE +21 -0
- package/dist/index.d.ts +1934 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2539 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
- package/prisma/migrations/20250526183023_init/migration.sql +71 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +149 -0
- package/src/__tests__/dummy.test.ts +7 -0
- package/src/db/client.ts +42 -0
- package/src/db/index.ts +21 -0
- package/src/db/syncAppCatalog.ts +312 -0
- package/src/db/tableSyncMagazine.ts +32 -0
- package/src/db/tableSyncPrismaAdapter.ts +203 -0
- package/src/index.ts +126 -0
- package/src/middleware/backendResolver.ts +42 -0
- package/src/middleware/createEhMiddleware.ts +171 -0
- package/src/middleware/database.ts +62 -0
- package/src/middleware/featureRegistry.ts +173 -0
- package/src/middleware/index.ts +43 -0
- package/src/middleware/types.ts +202 -0
- package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
- package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
- package/src/modules/appCatalog/service.ts +130 -0
- package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
- package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
- package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
- package/src/modules/approvalMethod/slugUtils.ts +17 -0
- package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
- package/src/modules/assets/assetRestController.ts +271 -0
- package/src/modules/assets/assetUtils.ts +114 -0
- package/src/modules/assets/screenshotRestController.ts +195 -0
- package/src/modules/assets/screenshotRouter.ts +112 -0
- package/src/modules/assets/syncAssets.ts +277 -0
- package/src/modules/assets/upsertAsset.ts +46 -0
- package/src/modules/auth/auth.ts +51 -0
- package/src/modules/auth/authProviders.ts +40 -0
- package/src/modules/auth/authRouter.ts +75 -0
- package/src/modules/auth/authorizationUtils.ts +132 -0
- package/src/modules/auth/devMockUserUtils.ts +49 -0
- package/src/modules/auth/registerAuthRoutes.ts +33 -0
- package/src/modules/icons/iconRestController.ts +171 -0
- package/src/modules/icons/iconRouter.ts +180 -0
- package/src/modules/icons/iconService.ts +73 -0
- package/src/modules/icons/iconUtils.ts +46 -0
- package/src/prisma-json-types.d.ts +34 -0
- package/src/server/controller.ts +47 -0
- package/src/server/ehStaticControllerContract.ts +19 -0
- package/src/server/ehTrpcContext.ts +26 -0
- package/src/server/trpcSetup.ts +89 -0
- package/src/types/backend/api.ts +73 -0
- package/src/types/backend/common.ts +10 -0
- package/src/types/backend/companySpecificBackend.ts +5 -0
- package/src/types/backend/dataSources.ts +25 -0
- package/src/types/backend/deployments.ts +40 -0
- package/src/types/common/app/appTypes.ts +13 -0
- package/src/types/common/app/ui/appUiTypes.ts +12 -0
- package/src/types/common/appCatalogTypes.ts +65 -0
- package/src/types/common/approvalMethodTypes.ts +149 -0
- package/src/types/common/env/envTypes.ts +7 -0
- package/src/types/common/resourceTypes.ts +8 -0
- package/src/types/common/sharedTypes.ts +5 -0
- package/src/types/index.ts +21 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { tableSync } from '@igstack/app-catalog-table-sync'
|
|
2
|
+
import type { Prisma, PrismaClient } from '@prisma/client'
|
|
3
|
+
import type * as runtime from '@prisma/client/runtime/library'
|
|
4
|
+
import { mapValues, omit, pick } from 'radashi'
|
|
5
|
+
|
|
6
|
+
export type ScalarKeys<TPrismaModelName extends Prisma.ModelName> =
|
|
7
|
+
keyof Prisma.TypeMap['model'][TPrismaModelName]['payload']['scalars']
|
|
8
|
+
export type ObjectKeys<TPrismaModelName extends Prisma.ModelName> =
|
|
9
|
+
keyof Prisma.TypeMap['model'][TPrismaModelName]['payload']['objects']
|
|
10
|
+
|
|
11
|
+
export type ScalarFilter<TPrismaModelName extends Prisma.ModelName> = Partial<
|
|
12
|
+
Prisma.TypeMap['model'][TPrismaModelName]['payload']['scalars']
|
|
13
|
+
>
|
|
14
|
+
|
|
15
|
+
export type GetOperationFns<TModel extends Prisma.ModelName> = {
|
|
16
|
+
[TOperation in keyof Prisma.TypeMap['model']['DbAppForCatalog']['operations']]: (
|
|
17
|
+
args: Prisma.TypeMap['model'][TModel]['operations'][TOperation]['args'],
|
|
18
|
+
) => Promise<
|
|
19
|
+
Prisma.TypeMap['model'][TModel]['operations'][TOperation]['result']
|
|
20
|
+
>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TableSyncParamsPrisma<
|
|
24
|
+
TPrismaClient extends PrismaClient,
|
|
25
|
+
TPrismaModelName extends Prisma.ModelName,
|
|
26
|
+
TUniqColumns extends ReadonlyArray<ScalarKeys<TPrismaModelName>>,
|
|
27
|
+
TRelationColumns extends ReadonlyArray<ObjectKeys<TPrismaModelName>>,
|
|
28
|
+
> {
|
|
29
|
+
id?: ScalarKeys<TPrismaModelName>
|
|
30
|
+
prisma: TPrismaClient
|
|
31
|
+
prismaModelName: TPrismaModelName
|
|
32
|
+
uniqColumns: TUniqColumns
|
|
33
|
+
relationColumns?: TRelationColumns
|
|
34
|
+
where?: ScalarFilter<TPrismaModelName>
|
|
35
|
+
upsertOnly?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getPrismaModelOperations<
|
|
39
|
+
TPrismaClient extends Omit<PrismaClient, runtime.ITXClientDenyList>,
|
|
40
|
+
TPrismaModelName extends Prisma.ModelName,
|
|
41
|
+
>(prisma: TPrismaClient, prismaModelName: TPrismaModelName) {
|
|
42
|
+
const key = (prismaModelName.slice(0, 1).toLowerCase() +
|
|
43
|
+
prismaModelName.slice(1)) as keyof TPrismaClient
|
|
44
|
+
return prisma[key] as GetOperationFns<TPrismaModelName>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type MakeTFromPrismaModel<TPrismaModelName extends Prisma.ModelName> =
|
|
48
|
+
NonNullable<
|
|
49
|
+
Prisma.TypeMap['model'][TPrismaModelName]['operations']['findUnique']['result']
|
|
50
|
+
>
|
|
51
|
+
|
|
52
|
+
export function tableSyncPrisma<
|
|
53
|
+
TPrismaClient extends PrismaClient,
|
|
54
|
+
TPrismaModelName extends Prisma.ModelName,
|
|
55
|
+
TUniqColumns extends ReadonlyArray<ScalarKeys<TPrismaModelName>>,
|
|
56
|
+
TRelationColumns extends ReadonlyArray<ObjectKeys<TPrismaModelName>>,
|
|
57
|
+
TId extends ScalarKeys<TPrismaModelName> = ScalarKeys<TPrismaModelName>,
|
|
58
|
+
>(
|
|
59
|
+
params: TableSyncParamsPrisma<
|
|
60
|
+
TPrismaClient,
|
|
61
|
+
TPrismaModelName,
|
|
62
|
+
TUniqColumns,
|
|
63
|
+
TRelationColumns
|
|
64
|
+
>,
|
|
65
|
+
) {
|
|
66
|
+
const {
|
|
67
|
+
prisma,
|
|
68
|
+
prismaModelName,
|
|
69
|
+
uniqColumns,
|
|
70
|
+
where: whereGlobal,
|
|
71
|
+
upsertOnly,
|
|
72
|
+
} = params
|
|
73
|
+
const prismOperations = getPrismaModelOperations(prisma, prismaModelName)
|
|
74
|
+
|
|
75
|
+
const idColumn = (params.id ?? 'id') as TId
|
|
76
|
+
// @ts-ignore maybe someday later (never)
|
|
77
|
+
return tableSync<MakeTFromPrismaModel<TPrismaModelName>, TUniqColumns, TId>({
|
|
78
|
+
id: idColumn,
|
|
79
|
+
uniqColumns,
|
|
80
|
+
readAll: async () => {
|
|
81
|
+
const findManyArgs = whereGlobal
|
|
82
|
+
? {
|
|
83
|
+
where: whereGlobal,
|
|
84
|
+
}
|
|
85
|
+
: {}
|
|
86
|
+
return (await prismOperations.findMany(findManyArgs)) as Array<
|
|
87
|
+
MakeTFromPrismaModel<TPrismaModelName>
|
|
88
|
+
>
|
|
89
|
+
},
|
|
90
|
+
writeAll: async (createData, update, deleteIds) => {
|
|
91
|
+
const prismaUniqKey = params.uniqColumns.join('_')
|
|
92
|
+
const relationColumnList =
|
|
93
|
+
params.relationColumns ?? ([] as Array<ObjectKeys<TPrismaModelName>>)
|
|
94
|
+
|
|
95
|
+
return prisma.$transaction(async (tx) => {
|
|
96
|
+
const txOps = getPrismaModelOperations(tx, prismaModelName)
|
|
97
|
+
for (const { data, where } of update) {
|
|
98
|
+
const uniqKeyWhere =
|
|
99
|
+
Object.keys(where).length > 1
|
|
100
|
+
? {
|
|
101
|
+
[prismaUniqKey]: where,
|
|
102
|
+
}
|
|
103
|
+
: where
|
|
104
|
+
|
|
105
|
+
const dataScalar = omit(data, relationColumnList)
|
|
106
|
+
const dataRelations = mapValues(
|
|
107
|
+
pick(data, relationColumnList),
|
|
108
|
+
(value) => {
|
|
109
|
+
return {
|
|
110
|
+
set: value,
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// @ts-expect-error This is too difficult for me to come up with right types
|
|
116
|
+
await txOps.update({
|
|
117
|
+
data: { ...dataScalar, ...dataRelations },
|
|
118
|
+
where: { ...uniqKeyWhere },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (upsertOnly !== true) {
|
|
123
|
+
await txOps.deleteMany({
|
|
124
|
+
where: {
|
|
125
|
+
[idColumn]: {
|
|
126
|
+
in: deleteIds,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate uniqueness of uniqColumns before creating records
|
|
133
|
+
const createDataMapped = createData.map((data) => {
|
|
134
|
+
// @ts-expect-error This is too difficult for me to come up with right types
|
|
135
|
+
const dataScalar = omit(data, relationColumnList)
|
|
136
|
+
|
|
137
|
+
// @ts-expect-error This is too difficult for me to come up with right types
|
|
138
|
+
const onlyRelationColumns = pick(data, relationColumnList)
|
|
139
|
+
const dataRelations = mapValues(onlyRelationColumns, (value) => {
|
|
140
|
+
return {
|
|
141
|
+
connect: value,
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return { ...dataScalar, ...dataRelations }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Check for duplicates in the data to be created
|
|
149
|
+
if (createDataMapped.length > 0) {
|
|
150
|
+
const uniqKeysInCreate = new Set<string>()
|
|
151
|
+
const duplicateKeys: Array<string> = []
|
|
152
|
+
|
|
153
|
+
for (const data of createDataMapped) {
|
|
154
|
+
const keyParts = params.uniqColumns.map((col) => {
|
|
155
|
+
const value = data[col as keyof typeof data]
|
|
156
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive: unique columns may be nullable in schemas
|
|
157
|
+
return value === null || value === undefined
|
|
158
|
+
? 'null'
|
|
159
|
+
: String(value)
|
|
160
|
+
})
|
|
161
|
+
const key = keyParts.join(':')
|
|
162
|
+
|
|
163
|
+
if (uniqKeysInCreate.has(key)) {
|
|
164
|
+
duplicateKeys.push(key)
|
|
165
|
+
} else {
|
|
166
|
+
uniqKeysInCreate.add(key)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (duplicateKeys.length > 0) {
|
|
171
|
+
const uniqColumnsStr = params.uniqColumns.join(', ')
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Duplicate unique key values found in data to be created. ` +
|
|
174
|
+
`Model: ${prismaModelName}, Unique columns: [${uniqColumnsStr}], ` +
|
|
175
|
+
`Duplicate keys: [${duplicateKeys.join(', ')}]`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const results: Array<MakeTFromPrismaModel<TPrismaModelName>> = []
|
|
181
|
+
|
|
182
|
+
if (relationColumnList.length === 0) {
|
|
183
|
+
// @ts-expect-error This is too difficult for me to come up with right types
|
|
184
|
+
const batchResult = await txOps.createManyAndReturn({
|
|
185
|
+
data: createDataMapped,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
results.push(...batchResult)
|
|
189
|
+
} else {
|
|
190
|
+
for (const dataMappedElement of createDataMapped) {
|
|
191
|
+
// @ts-expect-error too difficult for me
|
|
192
|
+
const newVar = await txOps.create({
|
|
193
|
+
data: dataMappedElement,
|
|
194
|
+
})
|
|
195
|
+
results.push(newVar as MakeTFromPrismaModel<TPrismaModelName>)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return results
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// common
|
|
2
|
+
|
|
3
|
+
export { createTrpcRouter } from './server/controller'
|
|
4
|
+
export type { TRPCRouter } from './server/controller'
|
|
5
|
+
export { createEhTrpcContext } from './server/ehTrpcContext'
|
|
6
|
+
export type {
|
|
7
|
+
EhTrpcContext,
|
|
8
|
+
EhTrpcContextOptions,
|
|
9
|
+
} from './server/ehTrpcContext'
|
|
10
|
+
|
|
11
|
+
export { staticControllerContract } from './server/ehStaticControllerContract'
|
|
12
|
+
export type { EhStaticControllerContract } from './server/ehStaticControllerContract'
|
|
13
|
+
|
|
14
|
+
// ui-only
|
|
15
|
+
|
|
16
|
+
// backend-only
|
|
17
|
+
|
|
18
|
+
export type { AppForCatalog } from './types/common/appCatalogTypes'
|
|
19
|
+
export * from './types/index'
|
|
20
|
+
|
|
21
|
+
// Auth
|
|
22
|
+
export {
|
|
23
|
+
createAuth,
|
|
24
|
+
type AuthConfig,
|
|
25
|
+
type BetterAuth,
|
|
26
|
+
} from './modules/auth/auth'
|
|
27
|
+
|
|
28
|
+
export { registerAuthRoutes } from './modules/auth/registerAuthRoutes'
|
|
29
|
+
|
|
30
|
+
export { createAuthRouter, type AuthRouter } from './modules/auth/authRouter'
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
getUserGroups,
|
|
34
|
+
isMemberOfAnyGroup,
|
|
35
|
+
isMemberOfAllGroups,
|
|
36
|
+
isAdmin,
|
|
37
|
+
requireAdmin,
|
|
38
|
+
requireGroups,
|
|
39
|
+
type UserWithGroups,
|
|
40
|
+
} from './modules/auth/authorizationUtils'
|
|
41
|
+
|
|
42
|
+
// Admin
|
|
43
|
+
export {
|
|
44
|
+
createAdminChatHandler,
|
|
45
|
+
tool,
|
|
46
|
+
type AdminChatHandlerOptions,
|
|
47
|
+
} from './modules/admin/chat/createAdminChatHandler'
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
createDatabaseTools,
|
|
51
|
+
createPrismaDatabaseClient,
|
|
52
|
+
DEFAULT_ADMIN_SYSTEM_PROMPT,
|
|
53
|
+
type DatabaseClient,
|
|
54
|
+
} from './modules/admin/chat/createDatabaseTools'
|
|
55
|
+
|
|
56
|
+
// Icon management
|
|
57
|
+
export {
|
|
58
|
+
registerIconRestController,
|
|
59
|
+
type IconRestControllerConfig,
|
|
60
|
+
} from './modules/icons/iconRestController'
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
getAssetByName,
|
|
64
|
+
upsertIcon,
|
|
65
|
+
upsertIcons,
|
|
66
|
+
type UpsertIconInput,
|
|
67
|
+
} from './modules/icons/iconService'
|
|
68
|
+
|
|
69
|
+
// Asset management (universal for icons, screenshots, etc.)
|
|
70
|
+
export {
|
|
71
|
+
registerAssetRestController,
|
|
72
|
+
type AssetRestControllerConfig,
|
|
73
|
+
} from './modules/assets/assetRestController'
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
registerScreenshotRestController,
|
|
77
|
+
type ScreenshotRestControllerConfig,
|
|
78
|
+
} from './modules/assets/screenshotRestController'
|
|
79
|
+
|
|
80
|
+
export { createScreenshotRouter } from './modules/assets/screenshotRouter'
|
|
81
|
+
|
|
82
|
+
export { syncAssets, type SyncAssetsConfig } from './modules/assets/syncAssets'
|
|
83
|
+
|
|
84
|
+
// App Catalog Admin
|
|
85
|
+
export { createAppCatalogAdminRouter } from './modules/appCatalogAdmin/appCatalogAdminRouter'
|
|
86
|
+
|
|
87
|
+
// Approval Methods
|
|
88
|
+
export { createApprovalMethodRouter } from './modules/approvalMethod/approvalMethodRouter'
|
|
89
|
+
export {
|
|
90
|
+
syncApprovalMethods,
|
|
91
|
+
type ApprovalMethodSyncInput,
|
|
92
|
+
} from './modules/approvalMethod/syncApprovalMethods'
|
|
93
|
+
|
|
94
|
+
// Database utilities
|
|
95
|
+
export {
|
|
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,
|
|
111
|
+
} from './db'
|
|
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,42 @@
|
|
|
1
|
+
import type { AppCatalogCompanySpecificBackend } from '../types/backend/companySpecificBackend'
|
|
2
|
+
import type { EhBackendProvider } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type guard to check if an object implements AppCatalogCompanySpecificBackend.
|
|
6
|
+
*/
|
|
7
|
+
function isBackendInstance(obj: unknown): obj is AppCatalogCompanySpecificBackend {
|
|
8
|
+
return (
|
|
9
|
+
typeof obj === 'object' &&
|
|
10
|
+
obj !== null &&
|
|
11
|
+
(typeof (obj as AppCatalogCompanySpecificBackend).getApps === 'function' ||
|
|
12
|
+
typeof (obj as AppCatalogCompanySpecificBackend).getApps === 'undefined')
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes different backend provider types into a consistent async factory function.
|
|
18
|
+
* Supports:
|
|
19
|
+
* - Direct object implementing AppCatalogCompanySpecificBackend
|
|
20
|
+
* - Sync factory function that returns the backend
|
|
21
|
+
* - Async factory function that returns the backend
|
|
22
|
+
*/
|
|
23
|
+
export function createBackendResolver(
|
|
24
|
+
provider: EhBackendProvider,
|
|
25
|
+
): () => Promise<AppCatalogCompanySpecificBackend> {
|
|
26
|
+
// If it's already an object with the required methods, wrap it
|
|
27
|
+
if (isBackendInstance(provider)) {
|
|
28
|
+
return async () => provider
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// If it's a function, call it and handle both sync and async results
|
|
32
|
+
if (typeof provider === 'function') {
|
|
33
|
+
return async () => {
|
|
34
|
+
const result = provider()
|
|
35
|
+
return result instanceof Promise ? result : result
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(
|
|
40
|
+
'Invalid backend provider: must be an object implementing AppCatalogCompanySpecificBackend or a factory function',
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
import { createMockUserFromDevConfig } from '../modules/auth/devMockUserUtils'
|
|
15
|
+
|
|
16
|
+
export async function createEhMiddleware(
|
|
17
|
+
options: EhMiddlewareOptions,
|
|
18
|
+
): Promise<EhMiddlewareResult> {
|
|
19
|
+
// Normalize options with defaults
|
|
20
|
+
const basePath = options.basePath ?? '/api'
|
|
21
|
+
const normalizedOptions = { ...options, basePath }
|
|
22
|
+
|
|
23
|
+
// Initialize database manager
|
|
24
|
+
const dbManager = new EhDatabaseManager(options.database)
|
|
25
|
+
// Initialize the client (which also sets the global singleton)
|
|
26
|
+
dbManager.getClient()
|
|
27
|
+
|
|
28
|
+
// Create auth instance
|
|
29
|
+
const auth = createAuth({
|
|
30
|
+
appName: options.auth.appName,
|
|
31
|
+
baseURL: options.auth.baseURL,
|
|
32
|
+
secret: options.auth.secret,
|
|
33
|
+
providers: options.auth.providers,
|
|
34
|
+
plugins: options.auth.plugins,
|
|
35
|
+
sessionExpiresIn: options.auth.sessionExpiresIn,
|
|
36
|
+
sessionUpdateAge: options.auth.sessionUpdateAge,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Create tRPC router
|
|
40
|
+
const trpcRouter = createTrpcRouter(auth)
|
|
41
|
+
|
|
42
|
+
// Normalize backend provider to async factory function
|
|
43
|
+
const resolveBackend = createBackendResolver(options.backend)
|
|
44
|
+
|
|
45
|
+
// Get admin groups from config with default
|
|
46
|
+
const adminGroups = options.auth.adminGroups ?? ['env_hopper_ui_super_admins']
|
|
47
|
+
|
|
48
|
+
// Create tRPC context factory
|
|
49
|
+
const createContext = async ({
|
|
50
|
+
req,
|
|
51
|
+
}: trpcExpress.CreateExpressContextOptions) => {
|
|
52
|
+
const companySpecificBackend = await resolveBackend()
|
|
53
|
+
|
|
54
|
+
let user = null
|
|
55
|
+
let userGroups: Array<string> = []
|
|
56
|
+
|
|
57
|
+
// Check if dev mock user is configured
|
|
58
|
+
if (options.auth.devMockUser) {
|
|
59
|
+
user = createMockUserFromDevConfig(options.auth.devMockUser)
|
|
60
|
+
userGroups = options.auth.devMockUser.groups
|
|
61
|
+
} else {
|
|
62
|
+
// Extract user from session
|
|
63
|
+
try {
|
|
64
|
+
const session = await auth.api.getSession({
|
|
65
|
+
headers: req.headers as HeadersInit,
|
|
66
|
+
})
|
|
67
|
+
user = session?.user ?? null
|
|
68
|
+
|
|
69
|
+
// If user is authenticated and Okta is configured, decode groups from access token
|
|
70
|
+
if (user && options.auth.oktaGroupsClaim) {
|
|
71
|
+
try {
|
|
72
|
+
// Get the current access token (auto-refreshes if expired)
|
|
73
|
+
// Note: better-auth requires providerId, but we use 'okta' as default
|
|
74
|
+
const tokenResult = await auth.api.getAccessToken({
|
|
75
|
+
body: {
|
|
76
|
+
providerId: 'okta',
|
|
77
|
+
},
|
|
78
|
+
headers: req.headers as HeadersInit,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (tokenResult.accessToken) {
|
|
82
|
+
// Decode JWT to extract groups claim
|
|
83
|
+
const parts = tokenResult.accessToken.split('.')
|
|
84
|
+
if (parts.length === 3 && parts[1]) {
|
|
85
|
+
const payload = JSON.parse(
|
|
86
|
+
Buffer.from(parts[1], 'base64').toString(),
|
|
87
|
+
)
|
|
88
|
+
const groups = payload[options.auth.oktaGroupsClaim]
|
|
89
|
+
userGroups = Array.isArray(groups) ? groups : []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('[tRPC Context] Failed to get access token:', error)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('[tRPC Context] Failed to get session:', error)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Attach groups to user object for authorization checks
|
|
102
|
+
const userWithGroups = user ? { ...user, groups: userGroups } : null
|
|
103
|
+
|
|
104
|
+
return createEhTrpcContext({
|
|
105
|
+
companySpecificBackend,
|
|
106
|
+
user: userWithGroups,
|
|
107
|
+
adminGroups,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Create Express router
|
|
112
|
+
const router = Router()
|
|
113
|
+
router.use(express.json())
|
|
114
|
+
|
|
115
|
+
// Build middleware context for feature registration
|
|
116
|
+
const middlewareContext: MiddlewareContext = {
|
|
117
|
+
auth,
|
|
118
|
+
trpcRouter,
|
|
119
|
+
createContext: async () => {
|
|
120
|
+
const companySpecificBackend = await resolveBackend()
|
|
121
|
+
return createEhTrpcContext({
|
|
122
|
+
companySpecificBackend,
|
|
123
|
+
adminGroups,
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
authConfig: options.auth,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Register tRPC middleware (if enabled)
|
|
130
|
+
if (normalizedOptions.features?.trpc !== false) {
|
|
131
|
+
router.use(
|
|
132
|
+
`${basePath}/trpc`,
|
|
133
|
+
trpcExpress.createExpressMiddleware({
|
|
134
|
+
router: trpcRouter,
|
|
135
|
+
createContext,
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Register all enabled features
|
|
141
|
+
registerFeatures(router, normalizedOptions, middlewareContext)
|
|
142
|
+
|
|
143
|
+
// Call onRoutesRegistered hook if provided
|
|
144
|
+
if (options.hooks?.onRoutesRegistered) {
|
|
145
|
+
await options.hooks.onRoutesRegistered(router)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
router,
|
|
150
|
+
auth,
|
|
151
|
+
trpcRouter,
|
|
152
|
+
|
|
153
|
+
async connect(): Promise<void> {
|
|
154
|
+
await dbManager.connect()
|
|
155
|
+
if (options.hooks?.onDatabaseConnected) {
|
|
156
|
+
await options.hooks.onDatabaseConnected()
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async disconnect(): Promise<void> {
|
|
161
|
+
if (options.hooks?.onDatabaseDisconnecting) {
|
|
162
|
+
await options.hooks.onDatabaseDisconnecting()
|
|
163
|
+
}
|
|
164
|
+
await dbManager.disconnect()
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
addRoutes(callback: (router: Router) => void): void {
|
|
168
|
+
callback(router)
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -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
|
+
}
|