@env-hopper/backend-core 2.0.1-alpha → 2.0.1-alpha-20260224145405

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 (109) hide show
  1. package/dist/index.d.ts +2059 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +2571 -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 +149 -0
  9. package/src/db/client.ts +42 -0
  10. package/src/db/index.ts +21 -0
  11. package/src/db/syncAppCatalog.ts +310 -0
  12. package/src/db/tableSyncMagazine.ts +32 -0
  13. package/src/db/tableSyncPrismaAdapter.ts +203 -0
  14. package/src/generated/prisma/client.d.ts +1 -0
  15. package/src/generated/prisma/client.js +4 -0
  16. package/src/generated/prisma/default.d.ts +1 -0
  17. package/src/generated/prisma/default.js +4 -0
  18. package/src/generated/prisma/edge.d.ts +1 -0
  19. package/src/generated/prisma/edge.js +227 -0
  20. package/src/generated/prisma/index-browser.js +214 -0
  21. package/src/generated/prisma/index.d.ts +4212 -0
  22. package/src/generated/prisma/index.js +248 -0
  23. package/src/generated/prisma/libquery_engine-darwin-arm64.dylib.node +0 -0
  24. package/src/generated/prisma/package.json +183 -0
  25. package/src/generated/prisma/query_engine_bg.js +2 -0
  26. package/src/generated/prisma/query_engine_bg.wasm +0 -0
  27. package/src/generated/prisma/runtime/edge-esm.js +34 -0
  28. package/src/generated/prisma/runtime/edge.js +34 -0
  29. package/src/generated/prisma/runtime/index-browser.d.ts +370 -0
  30. package/src/generated/prisma/runtime/index-browser.js +16 -0
  31. package/src/generated/prisma/runtime/library.d.ts +3982 -0
  32. package/src/generated/prisma/runtime/library.js +146 -0
  33. package/src/generated/prisma/runtime/react-native.js +83 -0
  34. package/src/generated/prisma/runtime/wasm-compiler-edge.js +84 -0
  35. package/src/generated/prisma/runtime/wasm-engine-edge.js +36 -0
  36. package/src/generated/prisma/schema.prisma +49 -0
  37. package/src/generated/prisma/wasm-edge-light-loader.mjs +4 -0
  38. package/src/generated/prisma/wasm-worker-loader.mjs +4 -0
  39. package/src/generated/prisma/wasm.d.ts +1 -0
  40. package/src/generated/prisma/wasm.js +234 -0
  41. package/src/index.ts +109 -1
  42. package/src/middleware/backendResolver.ts +49 -0
  43. package/src/middleware/createEhMiddleware.ts +171 -0
  44. package/src/middleware/database.ts +62 -0
  45. package/src/middleware/featureRegistry.ts +173 -0
  46. package/src/middleware/index.ts +43 -0
  47. package/src/middleware/types.ts +202 -0
  48. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  49. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  50. package/src/modules/appCatalog/service.ts +130 -0
  51. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
  52. package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
  53. package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
  54. package/src/modules/approvalMethod/slugUtils.ts +17 -0
  55. package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
  56. package/src/modules/assets/assetRestController.ts +271 -0
  57. package/src/modules/assets/assetUtils.ts +114 -0
  58. package/src/modules/assets/screenshotRestController.ts +195 -0
  59. package/src/modules/assets/screenshotRouter.ts +112 -0
  60. package/src/modules/assets/syncAssets.ts +277 -0
  61. package/src/modules/assets/upsertAsset.ts +46 -0
  62. package/src/modules/auth/auth.ts +51 -0
  63. package/src/modules/auth/authProviders.ts +40 -0
  64. package/src/modules/auth/authRouter.ts +75 -0
  65. package/src/modules/auth/authorizationUtils.ts +132 -0
  66. package/src/modules/auth/devMockUserUtils.ts +49 -0
  67. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  68. package/src/modules/icons/iconRestController.ts +171 -0
  69. package/src/modules/icons/iconRouter.ts +180 -0
  70. package/src/modules/icons/iconService.ts +73 -0
  71. package/src/modules/icons/iconUtils.ts +46 -0
  72. package/src/prisma-json-types.d.ts +34 -0
  73. package/src/server/controller.ts +103 -44
  74. package/src/server/ehStaticControllerContract.ts +8 -1
  75. package/src/server/ehTrpcContext.ts +9 -6
  76. package/src/server/trpcSetup.ts +89 -0
  77. package/src/types/backend/api.ts +1 -14
  78. package/src/types/backend/companySpecificBackend.ts +17 -0
  79. package/src/types/common/appCatalogTypes.ts +56 -10
  80. package/src/types/common/approvalMethodTypes.ts +149 -0
  81. package/src/types/common/dataRootTypes.ts +72 -10
  82. package/src/types/index.ts +3 -0
  83. package/dist/esm/__tests__/dummy.test.d.ts +0 -1
  84. package/dist/esm/index.d.ts +0 -7
  85. package/dist/esm/index.js +0 -9
  86. package/dist/esm/index.js.map +0 -1
  87. package/dist/esm/server/controller.d.ts +0 -32
  88. package/dist/esm/server/controller.js +0 -35
  89. package/dist/esm/server/controller.js.map +0 -1
  90. package/dist/esm/server/db.d.ts +0 -2
  91. package/dist/esm/server/ehStaticControllerContract.d.ts +0 -9
  92. package/dist/esm/server/ehStaticControllerContract.js +0 -12
  93. package/dist/esm/server/ehStaticControllerContract.js.map +0 -1
  94. package/dist/esm/server/ehTrpcContext.d.ts +0 -8
  95. package/dist/esm/server/ehTrpcContext.js +0 -11
  96. package/dist/esm/server/ehTrpcContext.js.map +0 -1
  97. package/dist/esm/types/backend/api.d.ts +0 -71
  98. package/dist/esm/types/backend/common.d.ts +0 -9
  99. package/dist/esm/types/backend/dataSources.d.ts +0 -20
  100. package/dist/esm/types/backend/deployments.d.ts +0 -34
  101. package/dist/esm/types/common/app/appTypes.d.ts +0 -12
  102. package/dist/esm/types/common/app/ui/appUiTypes.d.ts +0 -10
  103. package/dist/esm/types/common/appCatalogTypes.d.ts +0 -16
  104. package/dist/esm/types/common/dataRootTypes.d.ts +0 -32
  105. package/dist/esm/types/common/env/envTypes.d.ts +0 -6
  106. package/dist/esm/types/common/resourceTypes.d.ts +0 -8
  107. package/dist/esm/types/common/sharedTypes.d.ts +0 -4
  108. package/dist/esm/types/index.d.ts +0 -11
  109. package/src/server/db.ts +0 -4
package/dist/index.js ADDED
@@ -0,0 +1,2571 @@
1
+ import z$1, { z } from "zod";
2
+ import { Prisma, PrismaClient } from "@prisma/client";
3
+ import { group, mapValues, omit, pick } from "radashi";
4
+ import { tableSync } from "@env-hopper/table-sync";
5
+ import { readFile, readdir, stat } from "node:fs/promises";
6
+ import { createHash } from "node:crypto";
7
+ import sharp from "sharp";
8
+ import { TRPCError, initTRPC } from "@trpc/server";
9
+ import { betterAuth } from "better-auth";
10
+ import { prismaAdapter } from "better-auth/adapters/prisma";
11
+ import { toNodeHandler } from "better-auth/node";
12
+ import { stepCountIs, streamText, tool } from "ai";
13
+ import multer from "multer";
14
+ import { readFileSync, readdirSync } from "node:fs";
15
+ import { extname, join } from "node:path";
16
+ import express, { Router } from "express";
17
+ import * as trpcExpress from "@trpc/server/adapters/express";
18
+
19
+ //#region src/db/client.ts
20
+ let prismaClient = null;
21
+ /**
22
+ * Gets the internal Prisma client instance.
23
+ * Creates one if it doesn't exist.
24
+ */
25
+ function getDbClient() {
26
+ if (!prismaClient) prismaClient = new PrismaClient();
27
+ return prismaClient;
28
+ }
29
+ /**
30
+ * Sets the internal Prisma client instance.
31
+ * Used by middleware to bridge with existing getDbClient() usage.
32
+ */
33
+ function setDbClient(client) {
34
+ prismaClient = client;
35
+ }
36
+ /**
37
+ * Connects to the database.
38
+ * Call this before performing database operations.
39
+ */
40
+ async function connectDb() {
41
+ await getDbClient().$connect();
42
+ }
43
+ /**
44
+ * Disconnects from the database.
45
+ * Call this when done with database operations (e.g., in scripts).
46
+ */
47
+ async function disconnectDb() {
48
+ if (prismaClient) {
49
+ await prismaClient.$disconnect();
50
+ prismaClient = null;
51
+ }
52
+ }
53
+
54
+ //#endregion
55
+ //#region src/modules/appCatalog/service.ts
56
+ async function getGroupingTagDefinitionsFromPrisma() {
57
+ return (await getDbClient().dbAppTagDefinition.findMany()).map((row) => omit(row, [
58
+ "id",
59
+ "updatedAt",
60
+ "createdAt"
61
+ ]));
62
+ }
63
+ async function getApprovalMethodsFromPrisma() {
64
+ return (await getDbClient().dbApprovalMethod.findMany()).map((row) => {
65
+ const baseFields = {
66
+ slug: row.slug,
67
+ displayName: row.displayName
68
+ };
69
+ const config = row.config ?? {};
70
+ switch (row.type) {
71
+ case "service": return {
72
+ ...baseFields,
73
+ type: "service",
74
+ config
75
+ };
76
+ case "personTeam": return {
77
+ ...baseFields,
78
+ type: "personTeam",
79
+ config
80
+ };
81
+ case "custom": return {
82
+ ...baseFields,
83
+ type: "custom",
84
+ config
85
+ };
86
+ }
87
+ });
88
+ }
89
+ async function getAppsFromPrisma() {
90
+ return (await getDbClient().dbAppForCatalog.findMany()).map((row) => {
91
+ const accessRequest = row.accessRequest;
92
+ const teams = row.teams ?? [];
93
+ const tags = row.tags ?? [];
94
+ const screenshotIds = row.screenshotIds ?? [];
95
+ const notes = row.notes == null ? void 0 : row.notes;
96
+ const appUrl = row.appUrl == null ? void 0 : row.appUrl;
97
+ const iconName = row.iconName == null ? void 0 : row.iconName;
98
+ return {
99
+ id: row.id,
100
+ slug: row.slug,
101
+ displayName: row.displayName,
102
+ description: row.description,
103
+ accessRequest,
104
+ teams,
105
+ notes,
106
+ tags,
107
+ appUrl,
108
+ iconName,
109
+ screenshotIds
110
+ };
111
+ });
112
+ }
113
+ async function getAppCatalogData(getAppsOptional) {
114
+ return {
115
+ apps: getAppsOptional ? await getAppsOptional() : await getAppsFromPrisma(),
116
+ tagsDefinitions: await getGroupingTagDefinitionsFromPrisma(),
117
+ approvalMethods: await getApprovalMethodsFromPrisma()
118
+ };
119
+ }
120
+
121
+ //#endregion
122
+ //#region src/db/tableSyncPrismaAdapter.ts
123
+ function getPrismaModelOperations(prisma, prismaModelName) {
124
+ return prisma[prismaModelName.slice(0, 1).toLowerCase() + prismaModelName.slice(1)];
125
+ }
126
+ function tableSyncPrisma(params) {
127
+ const { prisma, prismaModelName, uniqColumns, where: whereGlobal, upsertOnly } = params;
128
+ const prismOperations = getPrismaModelOperations(prisma, prismaModelName);
129
+ const idColumn = params.id ?? "id";
130
+ return tableSync({
131
+ id: idColumn,
132
+ uniqColumns,
133
+ readAll: async () => {
134
+ const findManyArgs = whereGlobal ? { where: whereGlobal } : {};
135
+ return await prismOperations.findMany(findManyArgs);
136
+ },
137
+ writeAll: async (createData, update, deleteIds) => {
138
+ const prismaUniqKey = params.uniqColumns.join("_");
139
+ const relationColumnList = params.relationColumns ?? [];
140
+ return prisma.$transaction(async (tx) => {
141
+ const txOps = getPrismaModelOperations(tx, prismaModelName);
142
+ for (const { data, where } of update) {
143
+ const uniqKeyWhere = Object.keys(where).length > 1 ? { [prismaUniqKey]: where } : where;
144
+ const dataScalar = omit(data, relationColumnList);
145
+ const dataRelations = mapValues(pick(data, relationColumnList), (value) => {
146
+ return { set: value };
147
+ });
148
+ await txOps.update({
149
+ data: {
150
+ ...dataScalar,
151
+ ...dataRelations
152
+ },
153
+ where: { ...uniqKeyWhere }
154
+ });
155
+ }
156
+ if (upsertOnly !== true) await txOps.deleteMany({ where: { [idColumn]: { in: deleteIds } } });
157
+ const createDataMapped = createData.map((data) => {
158
+ const dataScalar = omit(data, relationColumnList);
159
+ const dataRelations = mapValues(pick(data, relationColumnList), (value) => {
160
+ return { connect: value };
161
+ });
162
+ return {
163
+ ...dataScalar,
164
+ ...dataRelations
165
+ };
166
+ });
167
+ if (createDataMapped.length > 0) {
168
+ const uniqKeysInCreate = /* @__PURE__ */ new Set();
169
+ const duplicateKeys = [];
170
+ for (const data of createDataMapped) {
171
+ const key = params.uniqColumns.map((col) => {
172
+ const value = data[col];
173
+ return value === null || value === void 0 ? "null" : String(value);
174
+ }).join(":");
175
+ if (uniqKeysInCreate.has(key)) duplicateKeys.push(key);
176
+ else uniqKeysInCreate.add(key);
177
+ }
178
+ if (duplicateKeys.length > 0) {
179
+ const uniqColumnsStr = params.uniqColumns.join(", ");
180
+ throw new Error(`Duplicate unique key values found in data to be created. Model: ${prismaModelName}, Unique columns: [${uniqColumnsStr}], Duplicate keys: [${duplicateKeys.join(", ")}]`);
181
+ }
182
+ }
183
+ const results = [];
184
+ if (relationColumnList.length === 0) {
185
+ const batchResult = await txOps.createManyAndReturn({ data: createDataMapped });
186
+ results.push(...batchResult);
187
+ } else for (const dataMappedElement of createDataMapped) {
188
+ const newVar = await txOps.create({ data: dataMappedElement });
189
+ results.push(newVar);
190
+ }
191
+ return results;
192
+ });
193
+ }
194
+ });
195
+ }
196
+
197
+ //#endregion
198
+ //#region src/db/tableSyncMagazine.ts
199
+ const TABLE_SYNC_MAGAZINE = {
200
+ DbAppForCatalog: {
201
+ prismaModelName: "DbAppForCatalog",
202
+ uniqColumns: ["slug"]
203
+ },
204
+ DbAppTagDefinition: {
205
+ prismaModelName: "DbAppTagDefinition",
206
+ uniqColumns: ["prefix"]
207
+ },
208
+ DbApprovalMethod: {
209
+ id: "slug",
210
+ prismaModelName: "DbApprovalMethod",
211
+ uniqColumns: ["slug"]
212
+ }
213
+ };
214
+
215
+ //#endregion
216
+ //#region src/modules/assets/assetUtils.ts
217
+ /**
218
+ * Extract image dimensions from a buffer using sharp
219
+ */
220
+ async function getImageDimensions(buffer) {
221
+ try {
222
+ const metadata = await sharp(buffer).metadata();
223
+ return {
224
+ width: metadata.width,
225
+ height: metadata.height
226
+ };
227
+ } catch (error) {
228
+ console.error("Error extracting image dimensions:", error);
229
+ return {
230
+ width: void 0,
231
+ height: void 0
232
+ };
233
+ }
234
+ }
235
+ /**
236
+ * Resize an image buffer to the specified dimensions
237
+ * @param buffer - The image buffer to resize
238
+ * @param width - Target width (optional)
239
+ * @param height - Target height (optional)
240
+ * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided
241
+ */
242
+ async function resizeImage(buffer, width, height, format) {
243
+ let pipeline = sharp(buffer);
244
+ if (width || height) pipeline = pipeline.resize({
245
+ width,
246
+ height,
247
+ fit: "inside",
248
+ withoutEnlargement: true
249
+ });
250
+ if (format === "png") pipeline = pipeline.png();
251
+ else if (format === "webp") pipeline = pipeline.webp();
252
+ else if (format === "jpeg") pipeline = pipeline.jpeg();
253
+ return pipeline.toBuffer();
254
+ }
255
+ /**
256
+ * Generate SHA-256 checksum for a buffer
257
+ */
258
+ function generateChecksum(buffer) {
259
+ return createHash("sha256").update(buffer).digest("hex");
260
+ }
261
+ /**
262
+ * Detect image format from mime type
263
+ */
264
+ function getImageFormat(mimeType) {
265
+ if (mimeType.includes("png")) return "png";
266
+ if (mimeType.includes("webp")) return "webp";
267
+ if (mimeType.includes("jpeg") || mimeType.includes("jpg")) return "jpeg";
268
+ return null;
269
+ }
270
+ /**
271
+ * Check if a mime type represents a raster image (not SVG)
272
+ */
273
+ function isRasterImage(mimeType) {
274
+ return mimeType.startsWith("image/") && !mimeType.includes("svg");
275
+ }
276
+ async function parseAssetMeta(p) {
277
+ const { width, height, format, size } = await sharp(p.buffer).metadata();
278
+ return {
279
+ checksum: generateChecksum(p.buffer),
280
+ width,
281
+ height,
282
+ mimeType: format ? {
283
+ jpeg: "image/jpeg",
284
+ jpg: "image/jpeg",
285
+ png: "image/png",
286
+ webp: "image/webp",
287
+ avif: "image/avif",
288
+ tiff: "image/tiff",
289
+ gif: "image/gif",
290
+ heif: "image/heif",
291
+ raw: "application/octet-stream"
292
+ }[format] ?? `image/${format}` : "application/octet-stream",
293
+ fileSize: size || 0
294
+ };
295
+ }
296
+
297
+ //#endregion
298
+ //#region src/modules/assets/upsertAsset.ts
299
+ async function upsertAsset({ prisma, buffer, name, originalFilename, assetType }) {
300
+ const { checksum, fileSize, width, height, mimeType } = await parseAssetMeta({
301
+ buffer,
302
+ originalFilename
303
+ });
304
+ const existing = await prisma.dbAsset.findUnique({ where: { name } });
305
+ if (existing) return existing.id;
306
+ return (await prisma.dbAsset.create({ data: {
307
+ name,
308
+ checksum,
309
+ assetType,
310
+ content: new Uint8Array(buffer),
311
+ mimeType,
312
+ fileSize,
313
+ width,
314
+ height
315
+ } })).id;
316
+ }
317
+
318
+ //#endregion
319
+ //#region src/db/syncAppCatalog.ts
320
+ function isFileNotFoundError(error) {
321
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
322
+ }
323
+ async function processAssetDirectory(dirPath, appSlug, assetType, prisma) {
324
+ try {
325
+ const files = await readdir(dirPath);
326
+ const assetIds = [];
327
+ for (let i = 0; i < files.length; i++) {
328
+ const fileName = files[i];
329
+ if (!fileName) continue;
330
+ const assetName = assetType === "screenshot" ? `${appSlug}-screenshot-${i + 1}` : `${appSlug}-icon`;
331
+ const id = await upsertAsset({
332
+ prisma,
333
+ buffer: await readFile(`${dirPath}/${fileName}`),
334
+ originalFilename: fileName,
335
+ name: assetName,
336
+ assetType
337
+ });
338
+ assetIds.push(id);
339
+ if (assetType === "icon") break;
340
+ }
341
+ return assetIds;
342
+ } catch (error) {
343
+ if (isFileNotFoundError(error)) return [];
344
+ throw error;
345
+ }
346
+ }
347
+ async function syncAppAssets(appSlug, appPath, prisma) {
348
+ return {
349
+ screenshotIds: await processAssetDirectory(`${appPath}/screenshots`, appSlug, "screenshot", prisma),
350
+ iconName: (await processAssetDirectory(`${appPath}/icons`, appSlug, "icon", prisma)).length > 0 ? `${appSlug}-icon` : null
351
+ };
352
+ }
353
+ async function syncAssetsFromFileSystem(apps, allAppsAssetsPath) {
354
+ const appDirectories = await readdir(allAppsAssetsPath);
355
+ const prisma = getDbClient();
356
+ const bySlug = group(apps, (a) => a.slug);
357
+ for (const appDirName of appDirectories) {
358
+ try {
359
+ if (!(await stat(`${allAppsAssetsPath}/${appDirName}`)).isDirectory()) continue;
360
+ } catch (error) {
361
+ if (isFileNotFoundError(error)) continue;
362
+ throw error;
363
+ }
364
+ const appSlug = appDirName;
365
+ if (!bySlug[appSlug]) throw new Error(`App '${appSlug}' does not exist in the app catalog. Existing apps: ${Object.keys(bySlug).join(", ")}`);
366
+ try {
367
+ const { screenshotIds, iconName } = await syncAppAssets(appSlug, `${allAppsAssetsPath}/${appDirName}`, prisma);
368
+ const updateData = {};
369
+ if (screenshotIds.length > 0) updateData.screenshotIds = screenshotIds;
370
+ if (iconName !== null) updateData.iconName = iconName;
371
+ if (Object.keys(updateData).length > 0) await prisma.dbAppForCatalog.update({
372
+ where: { slug: appSlug },
373
+ data: updateData
374
+ });
375
+ } catch (error) {
376
+ const errorMessage = error instanceof Error ? error.message : String(error);
377
+ throw new Error(`Error while upserting assets for app '${appSlug}': ${errorMessage}`);
378
+ }
379
+ }
380
+ }
381
+ /**
382
+ * Syncs app catalog data to the database using table sync.
383
+ * This will create new apps, update existing ones, and delete any that are no longer in the input.
384
+ *
385
+ * Note: Call connectDb() before and disconnectDb() after if running in a script.
386
+ */
387
+ async function syncAppCatalog(apps, tagsDefinitions, approvalMethods, sreenshotsPath) {
388
+ try {
389
+ const prisma = getDbClient();
390
+ await tableSyncPrisma({
391
+ prisma,
392
+ ...TABLE_SYNC_MAGAZINE.DbApprovalMethod
393
+ }).sync(approvalMethods);
394
+ const sync = tableSyncPrisma({
395
+ prisma,
396
+ ...TABLE_SYNC_MAGAZINE.DbAppForCatalog
397
+ });
398
+ await tableSyncPrisma({
399
+ prisma,
400
+ ...TABLE_SYNC_MAGAZINE.DbAppTagDefinition
401
+ }).sync(tagsDefinitions);
402
+ const dbApps = apps.map((app) => {
403
+ return {
404
+ slug: app.slug || app.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
405
+ displayName: app.displayName,
406
+ description: app.description,
407
+ teams: app.teams ?? [],
408
+ accessRequest: app.accessRequest ?? null,
409
+ notes: app.notes ?? null,
410
+ tags: app.tags ?? [],
411
+ appUrl: app.appUrl ?? null,
412
+ links: app.links ?? null,
413
+ iconName: app.iconName ?? null,
414
+ screenshotIds: app.screenshotIds ?? []
415
+ };
416
+ });
417
+ const actual = (await sync.sync(dbApps)).getActual();
418
+ if (sreenshotsPath) await syncAssetsFromFileSystem(apps, sreenshotsPath);
419
+ return {
420
+ created: actual.length - apps.length + (apps.length - actual.length),
421
+ updated: 0,
422
+ deleted: 0,
423
+ total: actual.length
424
+ };
425
+ } catch (error) {
426
+ const errorMessage = error instanceof Error ? error.message : String(error);
427
+ const errorStack = error instanceof Error ? error.stack : void 0;
428
+ throw new Error(`Error syncing app catalog: ${errorMessage}\n\nDetails:\n${errorStack || "No stack trace available"}`);
429
+ }
430
+ }
431
+
432
+ //#endregion
433
+ //#region src/modules/auth/authorizationUtils.ts
434
+ /**
435
+ * Extract groups from user object
436
+ * Groups can be stored in different locations depending on the OAuth provider
437
+ */
438
+ function getUserGroups(user) {
439
+ if (!user) {
440
+ console.log("[getUserGroups] No user provided");
441
+ return [];
442
+ }
443
+ console.log("[getUserGroups] === USER OBJECT DEBUG ===");
444
+ console.log("[getUserGroups] User ID:", user.id);
445
+ console.log("[getUserGroups] User email:", user.email);
446
+ console.log("[getUserGroups] User.env_hopper_groups:", user.env_hopper_groups);
447
+ console.log("[getUserGroups] User.groups:", user.groups);
448
+ console.log("[getUserGroups] User.oktaGroups:", user.oktaGroups);
449
+ console.log("[getUserGroups] User.roles:", user.roles);
450
+ console.log("[getUserGroups] All user keys:", Object.keys(user));
451
+ console.log("[getUserGroups] Full user object:", JSON.stringify(user, null, 2));
452
+ const groups = user.env_hopper_groups || user.groups || user.oktaGroups || user.roles || [];
453
+ const result = Array.isArray(groups) ? groups : [];
454
+ console.log("[getUserGroups] Final groups result:", result);
455
+ return result;
456
+ }
457
+ /**
458
+ * Check if user is a member of any of the specified groups
459
+ */
460
+ function isMemberOfAnyGroup(user, allowedGroups) {
461
+ const userGroups = getUserGroups(user);
462
+ return allowedGroups.some((group$1) => userGroups.includes(group$1));
463
+ }
464
+ /**
465
+ * Check if user is a member of all specified groups
466
+ */
467
+ function isMemberOfAllGroups(user, requiredGroups) {
468
+ const userGroups = getUserGroups(user);
469
+ return requiredGroups.every((group$1) => userGroups.includes(group$1));
470
+ }
471
+ /**
472
+ * Check if user has admin permissions
473
+ * @param user User object with groups
474
+ * @param adminGroups List of admin group names (default: ['env_hopper_ui_super_admins'])
475
+ */
476
+ function isAdmin(user, adminGroups = ["env_hopper_ui_super_admins"]) {
477
+ return isMemberOfAnyGroup(user, adminGroups);
478
+ }
479
+ /**
480
+ * Require admin permissions - throws error if not admin
481
+ * @param user User object with groups
482
+ * @param adminGroups List of admin group names (default: ['env_hopper_ui_super_admins'])
483
+ */
484
+ function requireAdmin(user, adminGroups = ["env_hopper_ui_super_admins"]) {
485
+ if (!isAdmin(user, adminGroups)) throw new Error("Forbidden: Admin access required");
486
+ }
487
+ /**
488
+ * Require membership in specific groups - throws error if not member
489
+ */
490
+ function requireGroups(user, groups) {
491
+ if (!isMemberOfAnyGroup(user, groups)) throw new Error(`Forbidden: Membership in one of these groups required: ${groups.join(", ")}`);
492
+ }
493
+
494
+ //#endregion
495
+ //#region src/server/trpcSetup.ts
496
+ /**
497
+ * Initialization of tRPC backend
498
+ * Should be done only once per backend!
499
+ */
500
+ const t = initTRPC.context().create({ errorFormatter({ error, shape }) {
501
+ var _data;
502
+ console.error("[tRPC Error]", {
503
+ path: (_data = shape.data) === null || _data === void 0 ? void 0 : _data.path,
504
+ code: error.code,
505
+ message: error.message,
506
+ cause: error.cause,
507
+ stack: error.stack
508
+ });
509
+ return shape;
510
+ } });
511
+ /**
512
+ * Export reusable router and procedure helpers
513
+ */
514
+ const router = t.router;
515
+ const publicProcedure = t.procedure;
516
+ /**
517
+ * Middleware to check if user is authenticated
518
+ */
519
+ const isAuthenticated = t.middleware(({ ctx, next }) => {
520
+ if (!ctx.user) throw new TRPCError({
521
+ code: "UNAUTHORIZED",
522
+ message: "You must be logged in to access this resource"
523
+ });
524
+ return next({ ctx: {
525
+ ...ctx,
526
+ user: ctx.user
527
+ } });
528
+ });
529
+ /**
530
+ * Middleware to check if user is an admin
531
+ */
532
+ const isAdminMiddleware = t.middleware(({ ctx, next }) => {
533
+ if (!ctx.user) throw new TRPCError({
534
+ code: "UNAUTHORIZED",
535
+ message: "You must be logged in to access this resource"
536
+ });
537
+ console.log("[isAdminMiddleware] === ADMIN CHECK DEBUG ===");
538
+ console.log("[isAdminMiddleware] User:", ctx.user.email);
539
+ console.log("[isAdminMiddleware] Required admin groups:", ctx.adminGroups);
540
+ console.log("[isAdminMiddleware] Calling isAdmin()...");
541
+ const hasAdminAccess = isAdmin(ctx.user, ctx.adminGroups);
542
+ console.log("[isAdminMiddleware] Has admin access:", hasAdminAccess);
543
+ if (!hasAdminAccess) throw new TRPCError({
544
+ code: "FORBIDDEN",
545
+ message: `You must be an admin to access this resource. Required groups: ${ctx.adminGroups.join(", ") || "env_hopper_ui_super_admins"}`
546
+ });
547
+ return next({ ctx: {
548
+ ...ctx,
549
+ user: ctx.user
550
+ } });
551
+ });
552
+ /**
553
+ * Admin procedure that requires admin permissions
554
+ */
555
+ const adminProcedure = t.procedure.use(isAdminMiddleware);
556
+ /**
557
+ * Protected procedure that requires authentication (but not admin)
558
+ */
559
+ const protectedProcedure = t.procedure.use(isAuthenticated);
560
+
561
+ //#endregion
562
+ //#region src/modules/appCatalogAdmin/appCatalogAdminRouter.ts
563
+ const AccessMethodSchema = z.object({ type: z.enum([
564
+ "bot",
565
+ "ticketing",
566
+ "email",
567
+ "self-service",
568
+ "documentation",
569
+ "manual"
570
+ ]) }).loose();
571
+ const AppLinkSchema = z.object({
572
+ displayName: z.string().optional(),
573
+ url: z.url()
574
+ });
575
+ const AppRoleSchema = z.object({
576
+ name: z.string(),
577
+ description: z.string().optional()
578
+ });
579
+ const ApproverContactSchema = z.object({
580
+ displayName: z.string(),
581
+ contact: z.string().optional()
582
+ });
583
+ const ApprovalUrlSchema = z.object({
584
+ label: z.string().optional(),
585
+ url: z.url()
586
+ });
587
+ const AppAccessRequestSchema = z.object({
588
+ approvalMethodId: z.string(),
589
+ comments: z.string().optional(),
590
+ requestPrompt: z.string().optional(),
591
+ postApprovalInstructions: z.string().optional(),
592
+ roles: z.array(AppRoleSchema).optional(),
593
+ approvers: z.array(ApproverContactSchema).optional(),
594
+ urls: z.array(ApprovalUrlSchema).optional(),
595
+ whoToReachOut: z.string().optional()
596
+ });
597
+ const CreateAppForCatalogSchema = z.object({
598
+ slug: z.string().min(1).regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
599
+ displayName: z.string().min(1),
600
+ description: z.string(),
601
+ access: AccessMethodSchema.optional(),
602
+ teams: z.array(z.string()).optional(),
603
+ accessRequest: AppAccessRequestSchema.optional(),
604
+ notes: z.string().optional(),
605
+ tags: z.array(z.string()).optional(),
606
+ appUrl: z.url().optional(),
607
+ links: z.array(AppLinkSchema).optional(),
608
+ iconName: z.string().optional(),
609
+ screenshotIds: z.array(z.string()).optional()
610
+ });
611
+ const UpdateAppForCatalogSchema = CreateAppForCatalogSchema.partial().extend({ id: z.string() });
612
+ function createAppCatalogAdminRouter() {
613
+ const prisma = getDbClient();
614
+ return router({
615
+ list: adminProcedure.query(async () => {
616
+ return prisma.dbAppForCatalog.findMany({ orderBy: { displayName: "asc" } });
617
+ }),
618
+ getById: adminProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
619
+ return prisma.dbAppForCatalog.findUnique({ where: { id: input.id } });
620
+ }),
621
+ getBySlug: adminProcedure.input(z.object({ slug: z.string() })).query(async ({ input }) => {
622
+ return prisma.dbAppForCatalog.findUnique({ where: { slug: input.slug } });
623
+ }),
624
+ create: adminProcedure.input(CreateAppForCatalogSchema).mutation(async ({ input }) => {
625
+ return prisma.dbAppForCatalog.create({ data: {
626
+ slug: input.slug,
627
+ displayName: input.displayName,
628
+ description: input.description,
629
+ teams: input.teams ?? [],
630
+ accessRequest: input.accessRequest,
631
+ notes: input.notes,
632
+ tags: input.tags ?? [],
633
+ appUrl: input.appUrl,
634
+ links: input.links,
635
+ iconName: input.iconName,
636
+ screenshotIds: input.screenshotIds ?? []
637
+ } });
638
+ }),
639
+ update: adminProcedure.input(UpdateAppForCatalogSchema).mutation(async ({ input }) => {
640
+ const { id,...updateData } = input;
641
+ return prisma.dbAppForCatalog.update({
642
+ where: { id },
643
+ data: {
644
+ ...updateData.slug !== void 0 && { slug: updateData.slug },
645
+ ...updateData.displayName !== void 0 && { displayName: updateData.displayName },
646
+ ...updateData.description !== void 0 && { description: updateData.description },
647
+ ...updateData.teams !== void 0 && { teams: updateData.teams },
648
+ ...updateData.accessRequest !== void 0 && { accessRequest: updateData.accessRequest },
649
+ ...updateData.notes !== void 0 && { notes: updateData.notes },
650
+ ...updateData.tags !== void 0 && { tags: updateData.tags },
651
+ ...updateData.appUrl !== void 0 && { appUrl: updateData.appUrl },
652
+ ...updateData.links !== void 0 && { links: updateData.links },
653
+ ...updateData.iconName !== void 0 && { iconName: updateData.iconName },
654
+ ...updateData.screenshotIds !== void 0 && { screenshotIds: updateData.screenshotIds }
655
+ }
656
+ });
657
+ }),
658
+ updateScreenshots: adminProcedure.input(z.object({
659
+ id: z.string(),
660
+ screenshotIds: z.array(z.string())
661
+ })).mutation(async ({ input }) => {
662
+ return prisma.dbAppForCatalog.update({
663
+ where: { id: input.id },
664
+ data: { screenshotIds: input.screenshotIds }
665
+ });
666
+ }),
667
+ delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
668
+ return prisma.dbAppForCatalog.delete({ where: { id: input.id } });
669
+ })
670
+ });
671
+ }
672
+
673
+ //#endregion
674
+ //#region src/modules/approvalMethod/slugUtils.ts
675
+ /**
676
+ * Generates a URL-friendly slug from a display name.
677
+ * Converts to lowercase and replaces non-alphanumeric characters with hyphens.
678
+ *
679
+ * @param displayName - The display name to convert
680
+ * @returns A slug suitable for use as a primary key
681
+ *
682
+ * @example
683
+ * generateSlugFromDisplayName("My Service") // "my-service"
684
+ * generateSlugFromDisplayName("John's Team") // "john-s-team"
685
+ */
686
+ function generateSlugFromDisplayName(displayName) {
687
+ return displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
688
+ }
689
+
690
+ //#endregion
691
+ //#region src/modules/approvalMethod/approvalMethodRouter.ts
692
+ const ReachOutContactSchema = z.object({
693
+ displayName: z.string(),
694
+ contact: z.string()
695
+ });
696
+ const ServiceConfigSchema = z.object({
697
+ url: z.url().optional(),
698
+ icon: z.string().optional()
699
+ });
700
+ const PersonTeamConfigSchema = z.object({ reachOutContacts: z.array(ReachOutContactSchema).optional() });
701
+ const CustomConfigSchema = z.object({});
702
+ const ApprovalMethodConfigSchema = z.union([
703
+ ServiceConfigSchema,
704
+ PersonTeamConfigSchema,
705
+ CustomConfigSchema
706
+ ]);
707
+ const CreateApprovalMethodSchema = z.object({
708
+ type: z.enum([
709
+ "service",
710
+ "personTeam",
711
+ "custom"
712
+ ]),
713
+ displayName: z.string().min(1),
714
+ config: ApprovalMethodConfigSchema.optional()
715
+ });
716
+ const UpdateApprovalMethodSchema = z.object({
717
+ slug: z.string(),
718
+ type: z.enum([
719
+ "service",
720
+ "personTeam",
721
+ "custom"
722
+ ]).optional(),
723
+ displayName: z.string().min(1).optional(),
724
+ config: ApprovalMethodConfigSchema.optional()
725
+ });
726
+ /**
727
+ * Convert Prisma DbApprovalMethod to our ApprovalMethod type.
728
+ * This ensures tRPC infers proper types for frontend consumers.
729
+ */
730
+ function toApprovalMethod(db) {
731
+ const baseFields = {
732
+ slug: db.slug,
733
+ displayName: db.displayName,
734
+ createdAt: db.createdAt,
735
+ updatedAt: db.updatedAt
736
+ };
737
+ const config = db.config ?? {};
738
+ switch (db.type) {
739
+ case "service": return {
740
+ ...baseFields,
741
+ type: "service",
742
+ config
743
+ };
744
+ case "personTeam": return {
745
+ ...baseFields,
746
+ type: "personTeam",
747
+ config
748
+ };
749
+ case "custom": return {
750
+ ...baseFields,
751
+ type: "custom",
752
+ config
753
+ };
754
+ }
755
+ }
756
+ function createApprovalMethodRouter() {
757
+ return router({
758
+ list: publicProcedure.query(async () => {
759
+ return (await getDbClient().dbApprovalMethod.findMany({ orderBy: { displayName: "asc" } })).map(toApprovalMethod);
760
+ }),
761
+ getById: publicProcedure.input(z.object({ slug: z.string() })).query(async ({ input }) => {
762
+ const result = await getDbClient().dbApprovalMethod.findUnique({ where: { slug: input.slug } });
763
+ return result ? toApprovalMethod(result) : null;
764
+ }),
765
+ create: adminProcedure.input(CreateApprovalMethodSchema).mutation(async ({ input }) => {
766
+ return toApprovalMethod(await getDbClient().dbApprovalMethod.create({ data: {
767
+ slug: generateSlugFromDisplayName(input.displayName),
768
+ type: input.type,
769
+ displayName: input.displayName,
770
+ config: input.config ?? Prisma.JsonNull
771
+ } }));
772
+ }),
773
+ update: adminProcedure.input(UpdateApprovalMethodSchema).mutation(async ({ input }) => {
774
+ const prisma = getDbClient();
775
+ const { slug,...updateData } = input;
776
+ return toApprovalMethod(await prisma.dbApprovalMethod.update({
777
+ where: { slug },
778
+ data: {
779
+ ...updateData.type !== void 0 && { type: updateData.type },
780
+ ...updateData.displayName !== void 0 && { displayName: updateData.displayName },
781
+ ...updateData.config !== void 0 && { config: updateData.config ?? Prisma.JsonNull }
782
+ }
783
+ }));
784
+ }),
785
+ delete: adminProcedure.input(z.object({ slug: z.string() })).mutation(async ({ input }) => {
786
+ return toApprovalMethod(await getDbClient().dbApprovalMethod.delete({ where: { slug: input.slug } }));
787
+ }),
788
+ listByType: publicProcedure.input(z.object({ type: z.enum([
789
+ "service",
790
+ "personTeam",
791
+ "custom"
792
+ ]) })).query(async ({ input }) => {
793
+ return (await getDbClient().dbApprovalMethod.findMany({
794
+ where: { type: input.type },
795
+ orderBy: { displayName: "asc" }
796
+ })).map(toApprovalMethod);
797
+ })
798
+ });
799
+ }
800
+
801
+ //#endregion
802
+ //#region src/modules/assets/screenshotRouter.ts
803
+ function createScreenshotRouter() {
804
+ return router({
805
+ list: publicProcedure.query(async () => {
806
+ return getDbClient().dbAsset.findMany({
807
+ where: { assetType: "screenshot" },
808
+ select: {
809
+ id: true,
810
+ name: true,
811
+ mimeType: true,
812
+ fileSize: true,
813
+ width: true,
814
+ height: true,
815
+ createdAt: true,
816
+ updatedAt: true
817
+ },
818
+ orderBy: { createdAt: "desc" }
819
+ });
820
+ }),
821
+ getOne: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
822
+ return getDbClient().dbAsset.findFirst({
823
+ where: {
824
+ id: input.id,
825
+ assetType: "screenshot"
826
+ },
827
+ select: {
828
+ id: true,
829
+ name: true,
830
+ mimeType: true,
831
+ fileSize: true,
832
+ width: true,
833
+ height: true,
834
+ createdAt: true,
835
+ updatedAt: true
836
+ }
837
+ });
838
+ }),
839
+ getByAppSlug: publicProcedure.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
840
+ const prisma = getDbClient();
841
+ const app = await prisma.dbAppForCatalog.findUnique({
842
+ where: { slug: input.appSlug },
843
+ select: { screenshotIds: true }
844
+ });
845
+ if (!app) return [];
846
+ return prisma.dbAsset.findMany({
847
+ where: {
848
+ id: { in: app.screenshotIds },
849
+ assetType: "screenshot"
850
+ },
851
+ select: {
852
+ id: true,
853
+ name: true,
854
+ mimeType: true,
855
+ fileSize: true,
856
+ width: true,
857
+ height: true,
858
+ createdAt: true,
859
+ updatedAt: true
860
+ }
861
+ });
862
+ }),
863
+ getFirstByAppSlug: publicProcedure.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
864
+ const prisma = getDbClient();
865
+ const app = await prisma.dbAppForCatalog.findUnique({
866
+ where: { slug: input.appSlug },
867
+ select: { screenshotIds: true }
868
+ });
869
+ if (!app || app.screenshotIds.length === 0) return null;
870
+ return prisma.dbAsset.findUnique({
871
+ where: { id: app.screenshotIds[0] },
872
+ select: {
873
+ id: true,
874
+ name: true,
875
+ mimeType: true,
876
+ fileSize: true,
877
+ width: true,
878
+ height: true,
879
+ createdAt: true,
880
+ updatedAt: true
881
+ }
882
+ });
883
+ })
884
+ });
885
+ }
886
+
887
+ //#endregion
888
+ //#region src/modules/auth/authRouter.ts
889
+ /**
890
+ * Create auth tRPC procedures
891
+ * @param t - tRPC instance
892
+ * @param auth - Better Auth instance (optional, for future extensions)
893
+ * @returns tRPC router with auth procedures
894
+ */
895
+ function createAuthRouter(t$1, auth) {
896
+ const router$1 = t$1.router;
897
+ const publicProcedure$1 = t$1.procedure;
898
+ return router$1({
899
+ getSession: publicProcedure$1.query(async ({ ctx }) => {
900
+ return {
901
+ user: ctx.user ?? null,
902
+ isAuthenticated: !!ctx.user
903
+ };
904
+ }),
905
+ getProviders: publicProcedure$1.query(() => {
906
+ const providers = [];
907
+ const authOptions = auth === null || auth === void 0 ? void 0 : auth.options;
908
+ if (authOptions === null || authOptions === void 0 ? void 0 : authOptions.socialProviders) {
909
+ const socialProviders = authOptions.socialProviders;
910
+ Object.keys(socialProviders).forEach((key) => {
911
+ if (socialProviders[key]) providers.push(key);
912
+ });
913
+ }
914
+ if (authOptions === null || authOptions === void 0 ? void 0 : authOptions.plugins) authOptions.plugins.forEach((plugin) => {
915
+ var _pluginWithConfig$opt;
916
+ const pluginWithConfig = plugin;
917
+ if (pluginWithConfig.id === "generic-oauth" && ((_pluginWithConfig$opt = pluginWithConfig.options) === null || _pluginWithConfig$opt === void 0 ? void 0 : _pluginWithConfig$opt.config)) (Array.isArray(pluginWithConfig.options.config) ? pluginWithConfig.options.config : [pluginWithConfig.options.config]).forEach((config) => {
918
+ if (config.providerId) providers.push(config.providerId);
919
+ });
920
+ });
921
+ return { providers };
922
+ })
923
+ });
924
+ }
925
+
926
+ //#endregion
927
+ //#region src/modules/icons/iconUtils.ts
928
+ /**
929
+ * Get file extension from MIME type
930
+ */
931
+ function getExtensionFromMimeType(mimeType) {
932
+ return {
933
+ "image/svg+xml": "svg",
934
+ "image/png": "png",
935
+ "image/jpeg": "jpg",
936
+ "image/jpg": "jpg",
937
+ "image/webp": "webp",
938
+ "image/gif": "gif",
939
+ "image/bmp": "bmp",
940
+ "image/tiff": "tiff",
941
+ "image/x-icon": "ico",
942
+ "image/vnd.microsoft.icon": "ico"
943
+ }[mimeType.toLowerCase()] || "bin";
944
+ }
945
+ /**
946
+ * Get file extension from filename
947
+ */
948
+ function getExtensionFromFilename(filename) {
949
+ var _match$;
950
+ const match = filename.match(/\.([^.]+)$/);
951
+ return (match === null || match === void 0 || (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.toLowerCase()) || "";
952
+ }
953
+
954
+ //#endregion
955
+ //#region src/modules/icons/iconRouter.ts
956
+ function createIconRouter() {
957
+ return router({
958
+ list: publicProcedure.query(async () => {
959
+ return getDbClient().dbAsset.findMany({
960
+ where: { assetType: "icon" },
961
+ select: {
962
+ id: true,
963
+ name: true,
964
+ mimeType: true,
965
+ fileSize: true,
966
+ createdAt: true,
967
+ updatedAt: true
968
+ },
969
+ orderBy: { name: "asc" }
970
+ });
971
+ }),
972
+ getOne: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
973
+ return getDbClient().dbAsset.findFirst({
974
+ where: {
975
+ id: input.id,
976
+ assetType: "icon"
977
+ },
978
+ select: {
979
+ id: true,
980
+ name: true,
981
+ mimeType: true,
982
+ fileSize: true,
983
+ createdAt: true,
984
+ updatedAt: true
985
+ }
986
+ });
987
+ }),
988
+ create: adminProcedure.input(z.object({
989
+ name: z.string().min(1),
990
+ content: z.string(),
991
+ mimeType: z.string(),
992
+ fileSize: z.number().int().positive()
993
+ })).mutation(async ({ input }) => {
994
+ const prisma = getDbClient();
995
+ const buffer = Buffer.from(input.content, "base64");
996
+ const checksum = generateChecksum(buffer);
997
+ const { width, height } = await getImageDimensions(buffer);
998
+ let name = input.name;
999
+ if (!name.includes(".")) {
1000
+ const extension = getExtensionFromMimeType(input.mimeType);
1001
+ name = `${name}.${extension}`;
1002
+ }
1003
+ const existing = await prisma.dbAsset.findFirst({ where: {
1004
+ checksum,
1005
+ assetType: "icon"
1006
+ } });
1007
+ if (existing) return existing;
1008
+ return prisma.dbAsset.create({ data: {
1009
+ name,
1010
+ assetType: "icon",
1011
+ content: new Uint8Array(buffer),
1012
+ checksum,
1013
+ mimeType: input.mimeType,
1014
+ fileSize: input.fileSize,
1015
+ width,
1016
+ height
1017
+ } });
1018
+ }),
1019
+ update: adminProcedure.input(z.object({
1020
+ id: z.string(),
1021
+ name: z.string().min(1).optional(),
1022
+ content: z.string().optional(),
1023
+ mimeType: z.string().optional(),
1024
+ fileSize: z.number().int().positive().optional()
1025
+ })).mutation(async ({ input }) => {
1026
+ const prisma = getDbClient();
1027
+ const { id, content, name,...rest } = input;
1028
+ const data = { ...rest };
1029
+ if (content) {
1030
+ const buffer = Buffer.from(content, "base64");
1031
+ data.content = new Uint8Array(buffer);
1032
+ data.checksum = generateChecksum(buffer);
1033
+ const { width, height } = await getImageDimensions(buffer);
1034
+ data.width = width;
1035
+ data.height = height;
1036
+ }
1037
+ if (name) if (!name.includes(".") && input.mimeType) data.name = `${name}.${getExtensionFromMimeType(input.mimeType)}`;
1038
+ else data.name = name;
1039
+ return prisma.dbAsset.update({
1040
+ where: { id },
1041
+ data
1042
+ });
1043
+ }),
1044
+ delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
1045
+ return getDbClient().dbAsset.delete({ where: { id: input.id } });
1046
+ }),
1047
+ deleteMany: adminProcedure.input(z.object({ ids: z.array(z.string()) })).mutation(async ({ input }) => {
1048
+ return getDbClient().dbAsset.deleteMany({ where: {
1049
+ id: { in: input.ids },
1050
+ assetType: "icon"
1051
+ } });
1052
+ }),
1053
+ getContent: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
1054
+ const asset = await getDbClient().dbAsset.findFirst({
1055
+ where: {
1056
+ id: input.id,
1057
+ assetType: "icon"
1058
+ },
1059
+ select: {
1060
+ content: true,
1061
+ mimeType: true,
1062
+ name: true
1063
+ }
1064
+ });
1065
+ if (!asset) throw new Error("Icon not found");
1066
+ return {
1067
+ content: Buffer.from(asset.content).toString("base64"),
1068
+ mimeType: asset.mimeType,
1069
+ name: asset.name
1070
+ };
1071
+ })
1072
+ });
1073
+ }
1074
+
1075
+ //#endregion
1076
+ //#region src/server/controller.ts
1077
+ /**
1078
+ * Create the main tRPC router with optional auth instance
1079
+ * @param auth - Optional Better Auth instance for auth-related queries
1080
+ */
1081
+ function createTrpcRouter(auth) {
1082
+ return router({
1083
+ bootstrap: publicProcedure.query(async ({ ctx }) => {
1084
+ return await ctx.companySpecificBackend.getBootstrapData();
1085
+ }),
1086
+ authConfig: publicProcedure.query(async ({ ctx }) => {
1087
+ return { adminGroups: ctx.adminGroups };
1088
+ }),
1089
+ availabilityMatrix: publicProcedure.query(async ({ ctx }) => {
1090
+ return await ctx.companySpecificBackend.getAvailabilityMatrix();
1091
+ }),
1092
+ tryFindRenameRule: publicProcedure.input(z$1.object({
1093
+ envSlug: z$1.string().optional(),
1094
+ resourceSlug: z$1.string().optional()
1095
+ })).query(async ({ input, ctx }) => {
1096
+ return await ctx.companySpecificBackend.getNameMigrations(input);
1097
+ }),
1098
+ resourceJumps: publicProcedure.query(async ({ ctx }) => {
1099
+ return await ctx.companySpecificBackend.getResourceJumps();
1100
+ }),
1101
+ resourceJumpsExtended: publicProcedure.query(async ({ ctx }) => {
1102
+ return await ctx.companySpecificBackend.getResourceJumpsExtended();
1103
+ }),
1104
+ resourceJumpBySlugAndEnv: publicProcedure.input(z$1.object({
1105
+ jumpResourceSlug: z$1.string(),
1106
+ envSlug: z$1.string()
1107
+ })).query(async ({ input, ctx }) => {
1108
+ return filterSingleResourceJump(await ctx.companySpecificBackend.getResourceJumps(), input.jumpResourceSlug, input.envSlug);
1109
+ }),
1110
+ appCatalog: publicProcedure.query(async ({ ctx }) => {
1111
+ return await getAppCatalogData(ctx.companySpecificBackend.getApps);
1112
+ }),
1113
+ icon: createIconRouter(),
1114
+ screenshot: createScreenshotRouter(),
1115
+ appCatalogAdmin: createAppCatalogAdminRouter(),
1116
+ approvalMethod: createApprovalMethodRouter(),
1117
+ auth: createAuthRouter(t, auth)
1118
+ });
1119
+ }
1120
+ function filterSingleResourceJump(resourceJumps, jumpResourceSlug, envSlug) {
1121
+ const filteredResourceJump = resourceJumps.resourceJumps.find((item) => item.slug === jumpResourceSlug);
1122
+ const filteredEnv = resourceJumps.envs.find((item) => item.slug === envSlug);
1123
+ return {
1124
+ resourceJumps: filteredResourceJump ? [filteredResourceJump] : [],
1125
+ envs: filteredEnv ? [filteredEnv] : [],
1126
+ lateResolvableParams: resourceJumps.lateResolvableParams
1127
+ };
1128
+ }
1129
+
1130
+ //#endregion
1131
+ //#region src/server/ehTrpcContext.ts
1132
+ function createEhTrpcContext({ companySpecificBackend, user = null, adminGroups }) {
1133
+ return {
1134
+ companySpecificBackend,
1135
+ user,
1136
+ adminGroups
1137
+ };
1138
+ }
1139
+
1140
+ //#endregion
1141
+ //#region src/server/ehStaticControllerContract.ts
1142
+ const staticControllerContract = { methods: {
1143
+ getIcon: {
1144
+ method: "get",
1145
+ url: "icon/:icon"
1146
+ },
1147
+ getScreenshot: {
1148
+ method: "get",
1149
+ url: "screenshot/:id"
1150
+ }
1151
+ } };
1152
+
1153
+ //#endregion
1154
+ //#region src/modules/auth/auth.ts
1155
+ function createAuth(config) {
1156
+ const prisma = getDbClient();
1157
+ const isProduction = process.env.NODE_ENV === "production";
1158
+ return betterAuth({
1159
+ appName: config.appName || "EnvHopper",
1160
+ baseURL: config.baseURL,
1161
+ basePath: "/api/auth",
1162
+ secret: config.secret,
1163
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
1164
+ socialProviders: config.providers || {},
1165
+ plugins: config.plugins || [],
1166
+ emailAndPassword: { enabled: true },
1167
+ session: {
1168
+ expiresIn: config.sessionExpiresIn ?? 3600 * 24 * 30,
1169
+ updateAge: config.sessionUpdateAge ?? 3600 * 24,
1170
+ cookieCache: {
1171
+ enabled: true,
1172
+ maxAge: 300
1173
+ }
1174
+ },
1175
+ advanced: { useSecureCookies: isProduction }
1176
+ });
1177
+ }
1178
+
1179
+ //#endregion
1180
+ //#region src/modules/auth/registerAuthRoutes.ts
1181
+ /**
1182
+ * Register Better Auth routes with Express
1183
+ * @param app - Express application instance
1184
+ * @param auth - Better Auth instance
1185
+ */
1186
+ function registerAuthRoutes(app, auth) {
1187
+ app.get("/api/auth/session", async (req, res) => {
1188
+ try {
1189
+ const session = await auth.api.getSession({ headers: req.headers });
1190
+ if (session) res.json(session);
1191
+ else res.status(401).json({ error: "Not authenticated" });
1192
+ } catch (error) {
1193
+ console.error("[Auth Session Error]", error);
1194
+ res.status(500).json({ error: "Internal server error" });
1195
+ }
1196
+ });
1197
+ const authHandler = toNodeHandler(auth);
1198
+ app.all("/api/auth/{*any}", authHandler);
1199
+ }
1200
+
1201
+ //#endregion
1202
+ //#region src/modules/admin/chat/createAdminChatHandler.ts
1203
+ function convertToCoreMessages(messages) {
1204
+ return messages.map((msg) => {
1205
+ var _msg$parts;
1206
+ if (msg.content) return {
1207
+ role: msg.role,
1208
+ content: msg.content
1209
+ };
1210
+ const textContent = ((_msg$parts = msg.parts) === null || _msg$parts === void 0 ? void 0 : _msg$parts.filter((part) => part.type === "text").map((part) => part.text).join("")) ?? "";
1211
+ return {
1212
+ role: msg.role,
1213
+ content: textContent
1214
+ };
1215
+ });
1216
+ }
1217
+ /**
1218
+ * Creates an Express handler for the admin chat endpoint.
1219
+ *
1220
+ * Usage in thin wrappers:
1221
+ *
1222
+ * ```typescript
1223
+ * // With OpenAI
1224
+ * import { openai } from '@ai-sdk/openai'
1225
+ * app.post('/api/admin/chat', createAdminChatHandler({
1226
+ * model: openai('gpt-4o-mini'),
1227
+ * }))
1228
+ *
1229
+ * // With Claude
1230
+ * import { anthropic } from '@ai-sdk/anthropic'
1231
+ * app.post('/api/admin/chat', createAdminChatHandler({
1232
+ * model: anthropic('claude-sonnet-4-20250514'),
1233
+ * }))
1234
+ * ```
1235
+ */
1236
+ function createAdminChatHandler(options) {
1237
+ const { model, systemPrompt = "You are a helpful admin assistant for the Env Hopper application. Help users manage apps, data sources, and MCP server configurations.", tools = {}, validateConfig } = options;
1238
+ return async (req, res) => {
1239
+ try {
1240
+ if (validateConfig) validateConfig();
1241
+ const { messages } = req.body;
1242
+ const coreMessages = convertToCoreMessages(messages);
1243
+ console.log("[Admin Chat] Received messages:", JSON.stringify(coreMessages, null, 2));
1244
+ console.log("[Admin Chat] Available tools:", Object.keys(tools));
1245
+ const response = streamText({
1246
+ model,
1247
+ system: systemPrompt,
1248
+ messages: coreMessages,
1249
+ tools,
1250
+ stopWhen: stepCountIs(5),
1251
+ onFinish: (event) => {
1252
+ console.log("[Admin Chat] Finished:", {
1253
+ finishReason: event.finishReason,
1254
+ usage: event.usage,
1255
+ hasText: !!event.text,
1256
+ textLength: event.text.length
1257
+ });
1258
+ }
1259
+ }).toUIMessageStreamResponse();
1260
+ response.headers.forEach((value, key) => {
1261
+ res.setHeader(key, value);
1262
+ });
1263
+ if (response.body) {
1264
+ const reader = response.body.getReader();
1265
+ const pump = async () => {
1266
+ const { done, value } = await reader.read();
1267
+ if (done) {
1268
+ res.end();
1269
+ return;
1270
+ }
1271
+ res.write(value);
1272
+ return pump();
1273
+ };
1274
+ await pump();
1275
+ } else {
1276
+ console.error("[Admin Chat] No response body");
1277
+ res.status(500).json({ error: "No response from AI model" });
1278
+ }
1279
+ } catch (error) {
1280
+ console.error("[Admin Chat] Error:", error);
1281
+ res.status(500).json({ error: "Failed to process chat request" });
1282
+ }
1283
+ };
1284
+ }
1285
+
1286
+ //#endregion
1287
+ //#region src/modules/admin/chat/createDatabaseTools.ts
1288
+ /**
1289
+ * Creates a DatabaseClient from a Prisma client.
1290
+ */
1291
+ function createPrismaDatabaseClient(prisma) {
1292
+ return {
1293
+ query: async (sql) => {
1294
+ return await prisma.$queryRawUnsafe(sql);
1295
+ },
1296
+ execute: async (sql) => {
1297
+ return { affectedRows: await prisma.$executeRawUnsafe(sql) };
1298
+ },
1299
+ getTables: async () => {
1300
+ return (await prisma.$queryRawUnsafe(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)).map((t$1) => t$1.tablename);
1301
+ },
1302
+ getColumns: async (tableName) => {
1303
+ return (await prisma.$queryRawUnsafe(`SELECT column_name, data_type, is_nullable
1304
+ FROM information_schema.columns
1305
+ WHERE table_name = '${tableName}' AND table_schema = 'public'`)).map((c) => ({
1306
+ name: c.column_name,
1307
+ type: c.data_type,
1308
+ nullable: c.is_nullable === "YES"
1309
+ }));
1310
+ }
1311
+ };
1312
+ }
1313
+ const querySchema = z.object({ sql: z.string().describe("The SELECT SQL query to execute") });
1314
+ const modifySchema = z.object({
1315
+ sql: z.string().describe("The INSERT, UPDATE, or DELETE SQL query to execute"),
1316
+ confirmed: z.boolean().describe("Must be true to execute destructive operations")
1317
+ });
1318
+ const schemaParamsSchema = z.object({ tableName: z.string().optional().describe("Specific table name to get columns for. If not provided, returns list of all tables.") });
1319
+ /**
1320
+ * Creates a DatabaseClient using the internal backend-core Prisma client.
1321
+ * This is a convenience function for apps that don't need to pass their own Prisma client.
1322
+ */
1323
+ function createInternalDatabaseClient() {
1324
+ return createPrismaDatabaseClient(getDbClient());
1325
+ }
1326
+ /**
1327
+ * Creates AI tools for generic database access.
1328
+ *
1329
+ * The AI uses these internally - users interact via natural language.
1330
+ * Results are formatted as tables by the AI based on the system prompt.
1331
+ * Uses the internal backend-core Prisma client automatically.
1332
+ */
1333
+ function createDatabaseTools() {
1334
+ const db = createInternalDatabaseClient();
1335
+ return {
1336
+ queryDatabase: {
1337
+ description: `Execute a SELECT query to read data from the database.
1338
+ Use this to list, search, or filter records from any table.
1339
+ Always use double quotes around table and column names for PostgreSQL (e.g., SELECT * FROM "App").
1340
+ Return results will be formatted as a table for the user.`,
1341
+ inputSchema: querySchema,
1342
+ execute: async ({ sql }) => {
1343
+ console.log(`Executing ${sql}`);
1344
+ if (!sql.trim().toUpperCase().startsWith("SELECT")) return { error: "Only SELECT queries are allowed with queryDatabase. Use modifyDatabase for changes." };
1345
+ try {
1346
+ const results = await db.query(sql);
1347
+ return {
1348
+ success: true,
1349
+ rowCount: Array.isArray(results) ? results.length : 0,
1350
+ data: results
1351
+ };
1352
+ } catch (error) {
1353
+ return {
1354
+ success: false,
1355
+ error: error instanceof Error ? error.message : "Query failed"
1356
+ };
1357
+ }
1358
+ }
1359
+ },
1360
+ modifyDatabase: {
1361
+ description: `Execute an INSERT, UPDATE, or DELETE query to modify data.
1362
+ Use double quotes around table and column names for PostgreSQL.
1363
+ IMPORTANT: Always ask for user confirmation before executing. Set confirmed=true only after user confirms.
1364
+ For UPDATE/DELETE, always include a WHERE clause to avoid affecting all rows.`,
1365
+ inputSchema: modifySchema,
1366
+ execute: async ({ sql, confirmed }) => {
1367
+ if (!confirmed) return {
1368
+ needsConfirmation: true,
1369
+ message: "Please confirm you want to execute this operation.",
1370
+ sql
1371
+ };
1372
+ const normalizedSql = sql.trim().toUpperCase();
1373
+ if (normalizedSql.startsWith("SELECT")) return { error: "Use queryDatabase for SELECT queries." };
1374
+ if ((normalizedSql.startsWith("UPDATE") || normalizedSql.startsWith("DELETE")) && !normalizedSql.includes("WHERE")) return {
1375
+ error: "UPDATE and DELETE queries must include a WHERE clause for safety.",
1376
+ sql
1377
+ };
1378
+ try {
1379
+ const result = await db.execute(sql);
1380
+ return {
1381
+ success: true,
1382
+ affectedRows: result.affectedRows,
1383
+ message: `Operation completed. ${result.affectedRows} row(s) affected.`
1384
+ };
1385
+ } catch (error) {
1386
+ return {
1387
+ success: false,
1388
+ error: error instanceof Error ? error.message : "Operation failed"
1389
+ };
1390
+ }
1391
+ }
1392
+ },
1393
+ getDatabaseSchema: {
1394
+ description: `Get information about database tables and their columns.
1395
+ Use this to understand the database structure before writing queries.
1396
+ Call without tableName to list all tables, or with tableName to get columns for a specific table.`,
1397
+ inputSchema: schemaParamsSchema,
1398
+ execute: async ({ tableName }) => {
1399
+ try {
1400
+ if (tableName) return {
1401
+ success: true,
1402
+ table: tableName,
1403
+ columns: await db.getColumns(tableName)
1404
+ };
1405
+ else return {
1406
+ success: true,
1407
+ tables: await db.getTables()
1408
+ };
1409
+ } catch (error) {
1410
+ return {
1411
+ success: false,
1412
+ error: error instanceof Error ? error.message : "Failed to get schema"
1413
+ };
1414
+ }
1415
+ }
1416
+ }
1417
+ };
1418
+ }
1419
+ /**
1420
+ * Default system prompt for the database admin assistant.
1421
+ * Can be customized or extended.
1422
+ */
1423
+ const DEFAULT_ADMIN_SYSTEM_PROMPT = `You are a helpful database admin assistant. You help users view and manage data in the database.
1424
+
1425
+ IMPORTANT RULES:
1426
+ 1. When showing data, ALWAYS format it as a numbered ASCII table so users can reference rows by number
1427
+ 2. NEVER show raw SQL to users - just describe what you're doing in plain language
1428
+ 3. When users ask to modify data (update, delete, create), ALWAYS confirm before executing
1429
+ 4. For updates, show the current value and ask for confirmation before changing
1430
+ 5. Keep responses concise and focused on the data
1431
+
1432
+ FORMATTING EXAMPLE:
1433
+ When user asks "show me all apps", respond like:
1434
+ "Here are all the apps:
1435
+
1436
+ | # | ID | Slug | Display Name | Icon |
1437
+ |---|----|---------|-----------------| -------|
1438
+ | 1 | 1 | portal | Portal | portal |
1439
+ | 2 | 2 | admin | Admin Dashboard | admin |
1440
+ | 3 | 3 | api | API Service | null |
1441
+
1442
+ Found 3 apps total."
1443
+
1444
+ When user says "update row 2 display name to 'Admin Panel'":
1445
+ 1. First confirm: "I'll update the app 'Admin Dashboard' (ID: 2) to have display name 'Admin Panel'. Proceed?"
1446
+ 2. Only after user confirms, execute the update
1447
+ 3. Then show the updated row
1448
+
1449
+ AVAILABLE TABLES:
1450
+ Use getDatabaseSchema tool to discover tables and their columns.
1451
+ `;
1452
+
1453
+ //#endregion
1454
+ //#region src/modules/icons/iconRestController.ts
1455
+ const upload$1 = multer({
1456
+ storage: multer.memoryStorage(),
1457
+ limits: { fileSize: 10 * 1024 * 1024 },
1458
+ fileFilter: (_req, file, cb) => {
1459
+ if (!file.mimetype.startsWith("image/")) {
1460
+ cb(/* @__PURE__ */ new Error("Only image files are allowed"));
1461
+ return;
1462
+ }
1463
+ cb(null, true);
1464
+ }
1465
+ });
1466
+ /**
1467
+ * Registers REST endpoints for icon upload and retrieval
1468
+ *
1469
+ * Endpoints:
1470
+ * - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)
1471
+ * - GET {basePath}/:id - Get icon binary by ID
1472
+ * - GET {basePath}/:id/metadata - Get icon metadata only
1473
+ */
1474
+ function registerIconRestController(router$1, config) {
1475
+ const { basePath } = config;
1476
+ router$1.post(`${basePath}/upload`, upload$1.single("icon"), async (req, res) => {
1477
+ try {
1478
+ if (!req.file) {
1479
+ res.status(400).json({ error: "No file uploaded" });
1480
+ return;
1481
+ }
1482
+ let name = req.body["name"];
1483
+ if (!name) {
1484
+ res.status(400).json({ error: "Name is required" });
1485
+ return;
1486
+ }
1487
+ const extension = getExtensionFromFilename(req.file.originalname) || getExtensionFromMimeType(req.file.mimetype);
1488
+ if (!name.includes(".")) name = `${name}.${extension}`;
1489
+ const prisma = getDbClient();
1490
+ const checksum = createHash("sha256").update(req.file.buffer).digest("hex");
1491
+ const icon = await prisma.dbAsset.create({ data: {
1492
+ name,
1493
+ assetType: "icon",
1494
+ content: new Uint8Array(req.file.buffer),
1495
+ mimeType: req.file.mimetype,
1496
+ fileSize: req.file.size,
1497
+ checksum
1498
+ } });
1499
+ res.status(201).json({
1500
+ id: icon.id,
1501
+ name: icon.name,
1502
+ mimeType: icon.mimeType,
1503
+ fileSize: icon.fileSize,
1504
+ createdAt: icon.createdAt
1505
+ });
1506
+ } catch (error) {
1507
+ console.error("Error uploading icon:", error);
1508
+ res.status(500).json({ error: "Failed to upload icon" });
1509
+ }
1510
+ });
1511
+ router$1.get(`${basePath}/:name`, async (req, res) => {
1512
+ try {
1513
+ const { name } = req.params;
1514
+ const icon = await getDbClient().dbAsset.findFirst({
1515
+ where: {
1516
+ name,
1517
+ assetType: "icon"
1518
+ },
1519
+ select: {
1520
+ content: true,
1521
+ mimeType: true,
1522
+ name: true
1523
+ }
1524
+ });
1525
+ if (!icon) {
1526
+ res.status(404).json({ error: "Icon not found" });
1527
+ return;
1528
+ }
1529
+ res.setHeader("Content-Type", icon.mimeType);
1530
+ res.setHeader("Content-Disposition", `inline; filename="${icon.name}"`);
1531
+ res.setHeader("Cache-Control", "public, max-age=86400");
1532
+ res.send(icon.content);
1533
+ } catch (error) {
1534
+ console.error("Error fetching icon:", error);
1535
+ res.status(500).json({ error: "Failed to fetch icon" });
1536
+ }
1537
+ });
1538
+ router$1.get(`${basePath}/:name/metadata`, async (req, res) => {
1539
+ try {
1540
+ const { name } = req.params;
1541
+ const icon = await getDbClient().dbAsset.findFirst({
1542
+ where: {
1543
+ name,
1544
+ assetType: "icon"
1545
+ },
1546
+ select: {
1547
+ id: true,
1548
+ name: true,
1549
+ mimeType: true,
1550
+ fileSize: true,
1551
+ createdAt: true,
1552
+ updatedAt: true
1553
+ }
1554
+ });
1555
+ if (!icon) {
1556
+ res.status(404).json({ error: "Icon not found" });
1557
+ return;
1558
+ }
1559
+ res.json(icon);
1560
+ } catch (error) {
1561
+ console.error("Error fetching icon metadata:", error);
1562
+ res.status(500).json({ error: "Failed to fetch icon metadata" });
1563
+ }
1564
+ });
1565
+ }
1566
+
1567
+ //#endregion
1568
+ //#region src/modules/icons/iconService.ts
1569
+ /**
1570
+ * Upsert an icon to the database.
1571
+ * If an icon with the same name exists, it will be updated.
1572
+ * Otherwise, a new icon will be created.
1573
+ */
1574
+ async function upsertIcon(input) {
1575
+ const prisma = getDbClient();
1576
+ const checksum = generateChecksum(input.content);
1577
+ const { width, height } = await getImageDimensions(input.content);
1578
+ return prisma.dbAsset.upsert({
1579
+ where: { name: input.name },
1580
+ update: {
1581
+ content: new Uint8Array(input.content),
1582
+ checksum,
1583
+ mimeType: input.mimeType,
1584
+ fileSize: input.fileSize,
1585
+ width,
1586
+ height
1587
+ },
1588
+ create: {
1589
+ name: input.name,
1590
+ assetType: "icon",
1591
+ content: new Uint8Array(input.content),
1592
+ checksum,
1593
+ mimeType: input.mimeType,
1594
+ fileSize: input.fileSize,
1595
+ width,
1596
+ height
1597
+ }
1598
+ });
1599
+ }
1600
+ /**
1601
+ * Upsert multiple icons to the database.
1602
+ * This is more efficient than calling upsertIcon multiple times.
1603
+ */
1604
+ async function upsertIcons(icons) {
1605
+ const results = [];
1606
+ for (const icon of icons) {
1607
+ const result = await upsertIcon(icon);
1608
+ results.push(result);
1609
+ }
1610
+ return results;
1611
+ }
1612
+ /**
1613
+ * Get an asset (icon or screenshot) by name from the database.
1614
+ * Returns the asset content, mimeType, and name if found.
1615
+ */
1616
+ async function getAssetByName(name) {
1617
+ return getDbClient().dbAsset.findUnique({
1618
+ where: { name },
1619
+ select: {
1620
+ content: true,
1621
+ mimeType: true,
1622
+ name: true
1623
+ }
1624
+ });
1625
+ }
1626
+
1627
+ //#endregion
1628
+ //#region src/modules/assets/assetRestController.ts
1629
+ const upload = multer({
1630
+ storage: multer.memoryStorage(),
1631
+ limits: { fileSize: 10 * 1024 * 1024 },
1632
+ fileFilter: (_req, file, cb) => {
1633
+ if (!file.mimetype.startsWith("image/")) {
1634
+ cb(/* @__PURE__ */ new Error("Only image files are allowed"));
1635
+ return;
1636
+ }
1637
+ cb(null, true);
1638
+ }
1639
+ });
1640
+ /**
1641
+ * Registers REST endpoints for universal asset upload and retrieval
1642
+ *
1643
+ * Endpoints:
1644
+ * - POST {basePath}/upload - Upload a new asset (multipart/form-data)
1645
+ * - GET {basePath}/:id - Get asset binary by ID
1646
+ * - GET {basePath}/:id/metadata - Get asset metadata only
1647
+ * - GET {basePath}/by-name/:name - Get asset binary by name
1648
+ */
1649
+ function registerAssetRestController(router$1, config) {
1650
+ const { basePath } = config;
1651
+ const prisma = getDbClient();
1652
+ router$1.post(`${basePath}/upload`, upload.single("asset"), async (req, res) => {
1653
+ try {
1654
+ if (!req.file) {
1655
+ res.status(400).json({ error: "No file uploaded" });
1656
+ return;
1657
+ }
1658
+ const name = req.body["name"];
1659
+ const assetType = req.body["assetType"];
1660
+ if (!name) {
1661
+ res.status(400).json({ error: "Name is required" });
1662
+ return;
1663
+ }
1664
+ const id = await upsertAsset({
1665
+ prisma,
1666
+ buffer: req.file.buffer,
1667
+ name,
1668
+ originalFilename: req.file.filename,
1669
+ assetType
1670
+ });
1671
+ res.status(201).json({ id });
1672
+ } catch (error) {
1673
+ console.error("Error uploading asset:", error);
1674
+ res.status(500).json({ error: "Failed to upload asset" });
1675
+ }
1676
+ });
1677
+ router$1.get(`${basePath}/:id`, async (req, res) => {
1678
+ try {
1679
+ const { id } = req.params;
1680
+ const asset = await prisma.dbAsset.findUnique({
1681
+ where: { id },
1682
+ select: {
1683
+ content: true,
1684
+ mimeType: true,
1685
+ name: true,
1686
+ width: true,
1687
+ height: true
1688
+ }
1689
+ });
1690
+ if (!asset) {
1691
+ res.status(404).json({ error: "Asset not found" });
1692
+ return;
1693
+ }
1694
+ const resizeEnabled = String(process.env.EH_ASSETS_RESIZE_ENABLED || "true") === "true";
1695
+ const wParam = req.query["w"];
1696
+ const width = wParam ? Number.parseInt(wParam, 10) : void 0;
1697
+ let outBuffer = asset.content;
1698
+ let outMime = asset.mimeType;
1699
+ if (resizeEnabled && isRasterImage(asset.mimeType) && !!width && Number.isFinite(width) && width > 0) {
1700
+ const fmt = getImageFormat(asset.mimeType) || "jpeg";
1701
+ const buf = await resizeImage(Buffer.from(asset.content), width, void 0, fmt);
1702
+ outBuffer = new Uint8Array(buf);
1703
+ outMime = `image/${fmt}`;
1704
+ }
1705
+ res.setHeader("Content-Type", outMime);
1706
+ res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
1707
+ res.setHeader("Cache-Control", "public, max-age=86400");
1708
+ res.send(outBuffer);
1709
+ } catch (error) {
1710
+ console.error("Error fetching asset:", error);
1711
+ res.status(500).json({ error: "Failed to fetch asset" });
1712
+ }
1713
+ });
1714
+ router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
1715
+ try {
1716
+ const { id } = req.params;
1717
+ const asset = await prisma.dbAsset.findUnique({
1718
+ where: { id },
1719
+ select: {
1720
+ id: true,
1721
+ name: true,
1722
+ assetType: true,
1723
+ mimeType: true,
1724
+ fileSize: true,
1725
+ width: true,
1726
+ height: true,
1727
+ createdAt: true,
1728
+ updatedAt: true
1729
+ }
1730
+ });
1731
+ if (!asset) {
1732
+ res.status(404).json({ error: "Asset not found" });
1733
+ return;
1734
+ }
1735
+ res.json(asset);
1736
+ } catch (error) {
1737
+ console.error("Error fetching asset metadata:", error);
1738
+ res.status(500).json({ error: "Failed to fetch asset metadata" });
1739
+ }
1740
+ });
1741
+ router$1.get(`${basePath}/by-name/:name`, async (req, res) => {
1742
+ try {
1743
+ const { name } = req.params;
1744
+ const asset = await prisma.dbAsset.findUnique({
1745
+ where: { name },
1746
+ select: {
1747
+ content: true,
1748
+ mimeType: true,
1749
+ name: true,
1750
+ width: true,
1751
+ height: true
1752
+ }
1753
+ });
1754
+ if (!asset) {
1755
+ res.status(404).json({ error: "Asset not found" });
1756
+ return;
1757
+ }
1758
+ const resizeEnabled = String(process.env.EH_ASSETS_RESIZE_ENABLED || "true") === "true";
1759
+ const wParam = req.query["w"];
1760
+ const width = wParam ? Number.parseInt(wParam, 10) : void 0;
1761
+ let outBuffer = asset.content;
1762
+ let outMime = asset.mimeType;
1763
+ const isRaster = asset.mimeType.startsWith("image/") && !asset.mimeType.includes("svg");
1764
+ if (resizeEnabled && isRaster && !!width && Number.isFinite(width) && width > 0) {
1765
+ const fmt = asset.mimeType.includes("png") ? "png" : asset.mimeType.includes("webp") ? "webp" : "jpeg";
1766
+ let buf;
1767
+ const pipeline = sharp(Buffer.from(asset.content)).resize({
1768
+ width,
1769
+ fit: "inside",
1770
+ withoutEnlargement: true
1771
+ });
1772
+ if (fmt === "png") {
1773
+ buf = await pipeline.png().toBuffer();
1774
+ outMime = "image/png";
1775
+ } else if (fmt === "webp") {
1776
+ buf = await pipeline.webp().toBuffer();
1777
+ outMime = "image/webp";
1778
+ } else {
1779
+ buf = await pipeline.jpeg().toBuffer();
1780
+ outMime = "image/jpeg";
1781
+ }
1782
+ outBuffer = new Uint8Array(buf);
1783
+ }
1784
+ res.setHeader("Content-Type", outMime);
1785
+ res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
1786
+ res.setHeader("Cache-Control", "public, max-age=86400");
1787
+ res.send(outBuffer);
1788
+ } catch (error) {
1789
+ console.error("Error fetching asset by name:", error);
1790
+ res.status(500).json({ error: "Failed to fetch asset" });
1791
+ }
1792
+ });
1793
+ }
1794
+
1795
+ //#endregion
1796
+ //#region src/modules/assets/screenshotRestController.ts
1797
+ /**
1798
+ * Registers REST endpoints for screenshot retrieval
1799
+ *
1800
+ * Endpoints:
1801
+ * - GET {basePath}/app/:appId - Get all screenshots for an app
1802
+ * - GET {basePath}/:id - Get screenshot binary by ID
1803
+ * - GET {basePath}/:id/metadata - Get screenshot metadata only
1804
+ */
1805
+ function registerScreenshotRestController(router$1, config) {
1806
+ const { basePath } = config;
1807
+ router$1.get(`${basePath}/app/:appSlug`, async (req, res) => {
1808
+ try {
1809
+ const { appSlug } = req.params;
1810
+ const prisma = getDbClient();
1811
+ const app = await prisma.dbAppForCatalog.findUnique({
1812
+ where: { slug: appSlug },
1813
+ select: { screenshotIds: true }
1814
+ });
1815
+ if (!app) {
1816
+ res.status(404).json({ error: "App not found" });
1817
+ return;
1818
+ }
1819
+ const screenshots = await prisma.dbAsset.findMany({
1820
+ where: {
1821
+ id: { in: app.screenshotIds },
1822
+ assetType: "screenshot"
1823
+ },
1824
+ select: {
1825
+ id: true,
1826
+ name: true,
1827
+ mimeType: true,
1828
+ fileSize: true,
1829
+ width: true,
1830
+ height: true,
1831
+ createdAt: true
1832
+ }
1833
+ });
1834
+ res.json(screenshots);
1835
+ } catch (error) {
1836
+ console.error("Error fetching app screenshots:", error);
1837
+ res.status(500).json({ error: "Failed to fetch screenshots" });
1838
+ }
1839
+ });
1840
+ router$1.get(`${basePath}/app/:appSlug/first`, async (req, res) => {
1841
+ try {
1842
+ const { appSlug } = req.params;
1843
+ const prisma = getDbClient();
1844
+ const app = await prisma.dbAppForCatalog.findUnique({
1845
+ where: { slug: appSlug },
1846
+ select: { screenshotIds: true }
1847
+ });
1848
+ if (!app || app.screenshotIds.length === 0) {
1849
+ res.status(404).json({ error: "No screenshots found" });
1850
+ return;
1851
+ }
1852
+ const screenshot = await prisma.dbAsset.findUnique({
1853
+ where: { id: app.screenshotIds[0] },
1854
+ select: {
1855
+ id: true,
1856
+ name: true,
1857
+ mimeType: true,
1858
+ fileSize: true,
1859
+ width: true,
1860
+ height: true,
1861
+ createdAt: true
1862
+ }
1863
+ });
1864
+ if (!screenshot) {
1865
+ res.status(404).json({ error: "Screenshot not found" });
1866
+ return;
1867
+ }
1868
+ res.json(screenshot);
1869
+ } catch (error) {
1870
+ console.error("Error fetching first screenshot:", error);
1871
+ res.status(500).json({ error: "Failed to fetch screenshot" });
1872
+ }
1873
+ });
1874
+ router$1.get(`${basePath}/:id`, async (req, res) => {
1875
+ try {
1876
+ const { id } = req.params;
1877
+ const sizeParam = req.query.size;
1878
+ const targetSize = sizeParam ? parseInt(sizeParam, 10) : void 0;
1879
+ const screenshot = await getDbClient().dbAsset.findUnique({
1880
+ where: { id },
1881
+ select: {
1882
+ content: true,
1883
+ mimeType: true,
1884
+ name: true
1885
+ }
1886
+ });
1887
+ if (!screenshot) {
1888
+ res.status(404).json({ error: "Screenshot not found" });
1889
+ return;
1890
+ }
1891
+ let content = screenshot.content;
1892
+ if (targetSize && targetSize > 0) try {
1893
+ content = await sharp(screenshot.content).resize(targetSize, targetSize, {
1894
+ fit: "inside",
1895
+ withoutEnlargement: true
1896
+ }).toBuffer();
1897
+ } catch (resizeError) {
1898
+ console.error("Error resizing screenshot:", resizeError);
1899
+ }
1900
+ res.setHeader("Content-Type", screenshot.mimeType);
1901
+ res.setHeader("Content-Disposition", `inline; filename="${screenshot.name}"`);
1902
+ res.setHeader("Cache-Control", "public, max-age=86400");
1903
+ res.send(content);
1904
+ } catch (error) {
1905
+ console.error("Error fetching screenshot:", error);
1906
+ res.status(500).json({ error: "Failed to fetch screenshot" });
1907
+ }
1908
+ });
1909
+ router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
1910
+ try {
1911
+ const { id } = req.params;
1912
+ const screenshot = await getDbClient().dbAsset.findUnique({
1913
+ where: { id },
1914
+ select: {
1915
+ id: true,
1916
+ name: true,
1917
+ mimeType: true,
1918
+ fileSize: true,
1919
+ width: true,
1920
+ height: true,
1921
+ createdAt: true,
1922
+ updatedAt: true
1923
+ }
1924
+ });
1925
+ if (!screenshot) {
1926
+ res.status(404).json({ error: "Screenshot not found" });
1927
+ return;
1928
+ }
1929
+ res.json(screenshot);
1930
+ } catch (error) {
1931
+ console.error("Error fetching screenshot metadata:", error);
1932
+ res.status(500).json({ error: "Failed to fetch screenshot metadata" });
1933
+ }
1934
+ });
1935
+ }
1936
+
1937
+ //#endregion
1938
+ //#region src/modules/assets/syncAssets.ts
1939
+ /**
1940
+ * Sync local asset files (icons and screenshots) from directories into the database.
1941
+ *
1942
+ * This function allows consuming applications to sync asset files without directly
1943
+ * exposing the Prisma client. It handles:
1944
+ * - Icon files: Assigned to apps by matching filename to icon name patterns
1945
+ * - Screenshot files: Assigned to apps by matching filename to app ID (format: <app-id>_screenshot_<no>.<ext>)
1946
+ *
1947
+ * @param config Configuration with paths to icon and screenshot directories
1948
+ */
1949
+ async function syncAssets(config) {
1950
+ const prisma = getDbClient();
1951
+ let iconsUpserted = 0;
1952
+ let screenshotsUpserted = 0;
1953
+ if (config.iconsDir) {
1954
+ console.log(`📁 Syncing icons from ${config.iconsDir}...`);
1955
+ iconsUpserted = await syncIconsFromDirectory(prisma, config.iconsDir);
1956
+ console.log(` ✓ Upserted ${iconsUpserted} icons`);
1957
+ }
1958
+ if (config.screenshotsDir) {
1959
+ console.log(`📷 Syncing screenshots from ${config.screenshotsDir}...`);
1960
+ screenshotsUpserted = await syncScreenshotsFromDirectory(prisma, config.screenshotsDir);
1961
+ console.log(` ✓ Upserted ${screenshotsUpserted} screenshots and assigned to apps`);
1962
+ }
1963
+ return {
1964
+ iconsUpserted,
1965
+ screenshotsUpserted
1966
+ };
1967
+ }
1968
+ /**
1969
+ * Sync icon files from a directory
1970
+ */
1971
+ async function syncIconsFromDirectory(prisma, iconsDir) {
1972
+ let count = 0;
1973
+ try {
1974
+ const files = readdirSync(iconsDir);
1975
+ for (const file of files) {
1976
+ const filePath = join(iconsDir, file);
1977
+ const ext = extname(file).toLowerCase().slice(1);
1978
+ if (![
1979
+ "png",
1980
+ "jpg",
1981
+ "jpeg",
1982
+ "gif",
1983
+ "webp",
1984
+ "svg"
1985
+ ].includes(ext)) continue;
1986
+ try {
1987
+ const content = readFileSync(filePath);
1988
+ const buffer = Buffer.from(content);
1989
+ const checksum = generateChecksum(buffer);
1990
+ const iconName = file.replace(/\.[^/.]+$/, "");
1991
+ if (await prisma.dbAsset.findFirst({ where: {
1992
+ checksum,
1993
+ assetType: "icon"
1994
+ } })) continue;
1995
+ let width = null;
1996
+ let height = null;
1997
+ if (!ext.includes("svg")) {
1998
+ const { width: w, height: h } = await getImageDimensions(buffer);
1999
+ width = w ?? null;
2000
+ height = h ?? null;
2001
+ }
2002
+ const mimeType = {
2003
+ png: "image/png",
2004
+ jpg: "image/jpeg",
2005
+ jpeg: "image/jpeg",
2006
+ gif: "image/gif",
2007
+ webp: "image/webp",
2008
+ svg: "image/svg+xml"
2009
+ }[ext] || "application/octet-stream";
2010
+ await prisma.dbAsset.create({ data: {
2011
+ name: iconName,
2012
+ assetType: "icon",
2013
+ content: new Uint8Array(buffer),
2014
+ checksum,
2015
+ mimeType,
2016
+ fileSize: buffer.length,
2017
+ width,
2018
+ height
2019
+ } });
2020
+ count++;
2021
+ } catch (error) {
2022
+ console.warn(` ⚠ Failed to sync icon ${file}:`, error);
2023
+ }
2024
+ }
2025
+ } catch (error) {
2026
+ console.error(` ❌ Error reading icons directory:`, error);
2027
+ }
2028
+ return count;
2029
+ }
2030
+ /**
2031
+ * Sync screenshot files from a directory and assign to apps
2032
+ */
2033
+ async function syncScreenshotsFromDirectory(prisma, screenshotsDir) {
2034
+ let count = 0;
2035
+ try {
2036
+ const files = readdirSync(screenshotsDir);
2037
+ const screenshotsByApp = /* @__PURE__ */ new Map();
2038
+ for (const file of files) {
2039
+ const match = file.match(/^(.+?)_screenshot_(\d+)\.([^.]+)$/);
2040
+ if (!match || !match[1] || !match[3]) continue;
2041
+ const appId = match[1];
2042
+ const ext = match[3];
2043
+ if (!screenshotsByApp.has(appId)) screenshotsByApp.set(appId, []);
2044
+ screenshotsByApp.get(appId).push({
2045
+ path: join(screenshotsDir, file),
2046
+ ext
2047
+ });
2048
+ }
2049
+ for (const [appId, screenshots] of screenshotsByApp) try {
2050
+ if (!await prisma.dbAppForCatalog.findUnique({
2051
+ where: { slug: appId },
2052
+ select: { id: true }
2053
+ })) {
2054
+ console.warn(` ⚠ App not found: ${appId}`);
2055
+ continue;
2056
+ }
2057
+ for (const screenshot of screenshots) try {
2058
+ const content = readFileSync(screenshot.path);
2059
+ const buffer = Buffer.from(content);
2060
+ const checksum = generateChecksum(buffer);
2061
+ const existing = await prisma.dbAsset.findFirst({ where: {
2062
+ checksum,
2063
+ assetType: "screenshot"
2064
+ } });
2065
+ if (existing) {
2066
+ const existingApp = await prisma.dbAppForCatalog.findUnique({ where: { slug: appId } });
2067
+ if (existingApp && !existingApp.screenshotIds.includes(existing.id)) await prisma.dbAppForCatalog.update({
2068
+ where: { slug: appId },
2069
+ data: { screenshotIds: [...existingApp.screenshotIds, existing.id] }
2070
+ });
2071
+ continue;
2072
+ }
2073
+ const { width, height } = await getImageDimensions(buffer);
2074
+ const mimeType = {
2075
+ png: "image/png",
2076
+ jpg: "image/jpeg",
2077
+ jpeg: "image/jpeg",
2078
+ gif: "image/gif",
2079
+ webp: "image/webp"
2080
+ }[screenshot.ext.toLowerCase()] || "application/octet-stream";
2081
+ const asset = await prisma.dbAsset.create({ data: {
2082
+ name: `${appId}-screenshot-${Date.now()}`,
2083
+ assetType: "screenshot",
2084
+ content: new Uint8Array(buffer),
2085
+ checksum,
2086
+ mimeType,
2087
+ fileSize: buffer.length,
2088
+ width: width ?? null,
2089
+ height: height ?? null
2090
+ } });
2091
+ await prisma.dbAppForCatalog.update({
2092
+ where: { slug: appId },
2093
+ data: { screenshotIds: { push: asset.id } }
2094
+ });
2095
+ count++;
2096
+ } catch (error) {
2097
+ console.warn(` ⚠ Failed to sync screenshot ${screenshot.path}:`, error);
2098
+ }
2099
+ } catch (error) {
2100
+ console.warn(` ⚠ Failed to process app ${appId}:`, error);
2101
+ }
2102
+ } catch (error) {
2103
+ console.error(` ❌ Error reading screenshots directory:`, error);
2104
+ }
2105
+ return count;
2106
+ }
2107
+
2108
+ //#endregion
2109
+ //#region src/modules/approvalMethod/syncApprovalMethods.ts
2110
+ /**
2111
+ * Syncs approval methods to the database using upsert logic based on type + displayName.
2112
+ *
2113
+ * @param prisma - The PrismaClient instance from the backend-core database
2114
+ * @param methods - Array of approval methods to sync
2115
+ */
2116
+ async function syncApprovalMethods(prisma, methods) {
2117
+ await prisma.$transaction(methods.map((method) => prisma.dbApprovalMethod.upsert({
2118
+ where: { slug: method.slug },
2119
+ update: {
2120
+ displayName: method.displayName,
2121
+ type: method.type
2122
+ },
2123
+ create: {
2124
+ slug: method.slug,
2125
+ type: method.type,
2126
+ displayName: method.displayName
2127
+ }
2128
+ })));
2129
+ }
2130
+
2131
+ //#endregion
2132
+ //#region src/middleware/database.ts
2133
+ /**
2134
+ * Formats a database connection URL from structured config.
2135
+ */
2136
+ function formatConnectionUrl(config) {
2137
+ if ("url" in config) return config.url;
2138
+ const { host, port, database, username, password, schema = "public" } = config;
2139
+ return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=${schema}`;
2140
+ }
2141
+ /**
2142
+ * Internal database manager used by the middleware.
2143
+ * Handles connection URL formatting and lifecycle.
2144
+ */
2145
+ var EhDatabaseManager = class {
2146
+ constructor(config) {
2147
+ this.client = null;
2148
+ this.config = config;
2149
+ }
2150
+ /**
2151
+ * Get or create the Prisma client instance.
2152
+ * Uses lazy initialization for flexibility.
2153
+ */
2154
+ getClient() {
2155
+ if (!this.client) {
2156
+ this.client = new PrismaClient({
2157
+ datasourceUrl: formatConnectionUrl(this.config),
2158
+ log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["warn", "error"]
2159
+ });
2160
+ setDbClient(this.client);
2161
+ }
2162
+ return this.client;
2163
+ }
2164
+ async connect() {
2165
+ await this.getClient().$connect();
2166
+ }
2167
+ async disconnect() {
2168
+ if (this.client) {
2169
+ await this.client.$disconnect();
2170
+ this.client = null;
2171
+ }
2172
+ }
2173
+ };
2174
+
2175
+ //#endregion
2176
+ //#region src/middleware/backendResolver.ts
2177
+ /**
2178
+ * Type guard to check if an object implements EhBackendCompanySpecificBackend.
2179
+ */
2180
+ function isBackendInstance(obj) {
2181
+ return typeof obj === "object" && obj !== null && typeof obj.getBootstrapData === "function" && typeof obj.getAvailabilityMatrix === "function" && typeof obj.getNameMigrations === "function" && typeof obj.getResourceJumps === "function" && typeof obj.getResourceJumpsExtended === "function";
2182
+ }
2183
+ /**
2184
+ * Normalizes different backend provider types into a consistent async factory function.
2185
+ * Supports:
2186
+ * - Direct object implementing EhBackendCompanySpecificBackend
2187
+ * - Sync factory function that returns the backend
2188
+ * - Async factory function that returns the backend
2189
+ */
2190
+ function createBackendResolver(provider) {
2191
+ if (isBackendInstance(provider)) return async () => provider;
2192
+ if (typeof provider === "function") return async () => {
2193
+ const result = provider();
2194
+ return result instanceof Promise ? result : result;
2195
+ };
2196
+ throw new Error("Invalid backend provider: must be an object implementing EhBackendCompanySpecificBackend or a factory function");
2197
+ }
2198
+
2199
+ //#endregion
2200
+ //#region src/modules/appCatalogAdmin/catalogBackupController.ts
2201
+ /**
2202
+ * Export the complete app catalog as JSON
2203
+ * Includes all fields from DbAppForCatalog and DbApprovalMethod
2204
+ */
2205
+ async function exportCatalog(_req, res) {
2206
+ try {
2207
+ const prisma = getDbClient();
2208
+ const apps = await prisma.dbAppForCatalog.findMany({ orderBy: { slug: "asc" } });
2209
+ const approvalMethods = await prisma.dbApprovalMethod.findMany({ orderBy: { displayName: "asc" } });
2210
+ res.json({
2211
+ version: "2.0",
2212
+ exportDate: (/* @__PURE__ */ new Date()).toISOString(),
2213
+ apps,
2214
+ approvalMethods
2215
+ });
2216
+ } catch (error) {
2217
+ console.error("Error exporting catalog:", error);
2218
+ res.status(500).json({ error: "Failed to export catalog" });
2219
+ }
2220
+ }
2221
+ /**
2222
+ * Import/restore the complete app catalog from JSON
2223
+ * Overwrites existing data
2224
+ */
2225
+ async function importCatalog(req, res) {
2226
+ try {
2227
+ const prisma = getDbClient();
2228
+ const { apps, approvalMethods } = req.body;
2229
+ if (!Array.isArray(apps)) {
2230
+ res.status(400).json({ error: "Invalid data format: apps must be an array" });
2231
+ return;
2232
+ }
2233
+ await prisma.$transaction(async (tx) => {
2234
+ if (Array.isArray(approvalMethods)) await tx.dbApprovalMethod.deleteMany({});
2235
+ await tx.dbAppForCatalog.deleteMany({});
2236
+ if (Array.isArray(approvalMethods)) for (const method of approvalMethods) {
2237
+ const { id, createdAt, updatedAt,...methodData } = method;
2238
+ await tx.dbApprovalMethod.create({ data: methodData });
2239
+ }
2240
+ for (const app of apps) {
2241
+ const { id, createdAt, updatedAt,...appData } = app;
2242
+ await tx.dbAppForCatalog.create({ data: appData });
2243
+ }
2244
+ });
2245
+ res.json({
2246
+ success: true,
2247
+ imported: {
2248
+ apps: apps.length,
2249
+ approvalMethods: Array.isArray(approvalMethods) ? approvalMethods.length : 0
2250
+ }
2251
+ });
2252
+ } catch (error) {
2253
+ console.error("Error importing catalog:", error);
2254
+ res.status(500).json({ error: "Failed to import catalog" });
2255
+ }
2256
+ }
2257
+ /**
2258
+ * Export an asset (icon or screenshot) by name
2259
+ */
2260
+ async function exportAsset(req, res) {
2261
+ try {
2262
+ const { name } = req.params;
2263
+ const asset = await getDbClient().dbAsset.findUnique({ where: { name } });
2264
+ if (!asset) {
2265
+ res.status(404).json({ error: "Asset not found" });
2266
+ return;
2267
+ }
2268
+ res.set("Content-Type", asset.mimeType);
2269
+ res.set("Content-Disposition", `attachment; filename="${name}"`);
2270
+ res.send(Buffer.from(asset.content));
2271
+ } catch (error) {
2272
+ console.error("Error exporting asset:", error);
2273
+ res.status(500).json({ error: "Failed to export asset" });
2274
+ }
2275
+ }
2276
+ /**
2277
+ * List all assets with metadata
2278
+ */
2279
+ async function listAssets(_req, res) {
2280
+ try {
2281
+ const assets = await getDbClient().dbAsset.findMany({
2282
+ select: {
2283
+ id: true,
2284
+ name: true,
2285
+ assetType: true,
2286
+ mimeType: true,
2287
+ fileSize: true,
2288
+ width: true,
2289
+ height: true,
2290
+ checksum: true
2291
+ },
2292
+ orderBy: { name: "asc" }
2293
+ });
2294
+ res.json({ assets });
2295
+ } catch (error) {
2296
+ console.error("Error listing assets:", error);
2297
+ res.status(500).json({ error: "Failed to list assets" });
2298
+ }
2299
+ }
2300
+ /**
2301
+ * Import an asset (icon or screenshot)
2302
+ */
2303
+ async function importAsset(req, res) {
2304
+ try {
2305
+ const file = req.file;
2306
+ const { name, assetType, mimeType, width, height } = req.body;
2307
+ if (!file) {
2308
+ res.status(400).json({ error: "No file uploaded" });
2309
+ return;
2310
+ }
2311
+ const prisma = getDbClient();
2312
+ const checksum = (await import("node:crypto")).createHash("sha256").update(file.buffer).digest("hex");
2313
+ const content = new Uint8Array(file.buffer);
2314
+ await prisma.dbAsset.upsert({
2315
+ where: { name },
2316
+ update: {
2317
+ content,
2318
+ checksum,
2319
+ mimeType: mimeType || file.mimetype,
2320
+ fileSize: file.size,
2321
+ width: width ? parseInt(width) : null,
2322
+ height: height ? parseInt(height) : null,
2323
+ assetType: assetType || "icon"
2324
+ },
2325
+ create: {
2326
+ name,
2327
+ content,
2328
+ checksum,
2329
+ mimeType: mimeType || file.mimetype,
2330
+ fileSize: file.size,
2331
+ width: width ? parseInt(width) : null,
2332
+ height: height ? parseInt(height) : null,
2333
+ assetType: assetType || "icon"
2334
+ }
2335
+ });
2336
+ res.json({
2337
+ success: true,
2338
+ name,
2339
+ size: file.size
2340
+ });
2341
+ } catch (error) {
2342
+ console.error("Error importing asset:", error);
2343
+ res.status(500).json({ error: "Failed to import asset" });
2344
+ }
2345
+ }
2346
+
2347
+ //#endregion
2348
+ //#region src/modules/auth/devMockUserUtils.ts
2349
+ /**
2350
+ * Creates a complete User object from basic dev mock user details
2351
+ */
2352
+ function createMockUserFromDevConfig(devUser) {
2353
+ return {
2354
+ id: devUser.id,
2355
+ email: devUser.email,
2356
+ name: devUser.name,
2357
+ emailVerified: true,
2358
+ createdAt: /* @__PURE__ */ new Date(),
2359
+ updatedAt: /* @__PURE__ */ new Date(),
2360
+ env_hopper_groups: devUser.groups
2361
+ };
2362
+ }
2363
+ /**
2364
+ * Creates a mock session response for /api/auth/session endpoint
2365
+ */
2366
+ function createMockSessionResponse(devUser) {
2367
+ return {
2368
+ user: {
2369
+ id: devUser.id,
2370
+ email: devUser.email,
2371
+ name: devUser.name,
2372
+ emailVerified: true,
2373
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2374
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2375
+ env_hopper_groups: devUser.groups
2376
+ },
2377
+ session: {
2378
+ id: `${devUser.id}-session`,
2379
+ userId: devUser.id,
2380
+ expiresAt: new Date(Date.now() + 1e3 * 60 * 60 * 24 * 30).toISOString(),
2381
+ token: `${devUser.id}-token`,
2382
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2383
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2384
+ }
2385
+ };
2386
+ }
2387
+
2388
+ //#endregion
2389
+ //#region src/middleware/featureRegistry.ts
2390
+ const FEATURES = [
2391
+ {
2392
+ name: "auth",
2393
+ defaultEnabled: true,
2394
+ register: (router$1, options, ctx) => {
2395
+ const basePath = options.basePath;
2396
+ router$1.get(`${basePath}/auth/session`, async (req, res) => {
2397
+ try {
2398
+ if (ctx.authConfig.devMockUser) {
2399
+ res.json(createMockSessionResponse(ctx.authConfig.devMockUser));
2400
+ return;
2401
+ }
2402
+ const session = await ctx.auth.api.getSession({ headers: req.headers });
2403
+ if (session) res.json(session);
2404
+ else res.status(401).json({ error: "Not authenticated" });
2405
+ } catch (error) {
2406
+ console.error("[Auth Session Error]", error);
2407
+ res.status(500).json({ error: "Internal server error" });
2408
+ }
2409
+ });
2410
+ const authHandler = toNodeHandler(ctx.auth);
2411
+ router$1.all(`${basePath}/auth/{*any}`, authHandler);
2412
+ }
2413
+ },
2414
+ {
2415
+ name: "adminChat",
2416
+ defaultEnabled: false,
2417
+ register: (router$1, options) => {
2418
+ if (options.adminChat) router$1.post(`${options.basePath}/admin/chat`, createAdminChatHandler(options.adminChat));
2419
+ }
2420
+ },
2421
+ {
2422
+ name: "legacyIconEndpoint",
2423
+ defaultEnabled: false,
2424
+ register: (router$1) => {
2425
+ router$1.get("/static/icon/:icon", async (req, res) => {
2426
+ const { icon } = req.params;
2427
+ if (!icon || !/^[a-z0-9-]+$/i.test(icon)) {
2428
+ res.status(400).send("Invalid icon name");
2429
+ return;
2430
+ }
2431
+ try {
2432
+ const dbIcon = await getAssetByName(icon);
2433
+ if (!dbIcon) {
2434
+ res.status(404).send("Icon not found");
2435
+ return;
2436
+ }
2437
+ res.setHeader("Content-Type", dbIcon.mimeType);
2438
+ res.setHeader("Cache-Control", "public, max-age=86400");
2439
+ res.send(dbIcon.content);
2440
+ } catch (error) {
2441
+ console.error("Error fetching icon:", error);
2442
+ res.status(404).send("Icon not found");
2443
+ }
2444
+ });
2445
+ }
2446
+ }
2447
+ ];
2448
+ /**
2449
+ * Registers all enabled features on the router.
2450
+ */
2451
+ function registerFeatures(router$1, options, context) {
2452
+ const basePath = options.basePath;
2453
+ registerIconRestController(router$1, { basePath: `${basePath}/icons` });
2454
+ registerAssetRestController(router$1, { basePath: `${basePath}/assets` });
2455
+ registerScreenshotRestController(router$1, { basePath: `${basePath}/screenshots` });
2456
+ const upload$2 = multer({ storage: multer.memoryStorage() });
2457
+ router$1.get(`${basePath}/catalog/backup/export`, exportCatalog);
2458
+ router$1.post(`${basePath}/catalog/backup/import`, importCatalog);
2459
+ router$1.get(`${basePath}/catalog/backup/assets`, listAssets);
2460
+ router$1.get(`${basePath}/catalog/backup/assets/:name`, exportAsset);
2461
+ router$1.post(`${basePath}/catalog/backup/assets`, upload$2.single("file"), importAsset);
2462
+ const toggles = options.features || {};
2463
+ for (const feature of FEATURES) {
2464
+ const isEnabled = toggles[feature.name] ?? feature.defaultEnabled;
2465
+ if (feature.name === "adminChat" && !options.adminChat) continue;
2466
+ if (isEnabled) feature.register(router$1, options, context);
2467
+ }
2468
+ }
2469
+
2470
+ //#endregion
2471
+ //#region src/middleware/createEhMiddleware.ts
2472
+ async function createEhMiddleware(options) {
2473
+ var _normalizedOptions$fe, _options$hooks;
2474
+ const basePath = options.basePath ?? "/api";
2475
+ const normalizedOptions = {
2476
+ ...options,
2477
+ basePath
2478
+ };
2479
+ const dbManager = new EhDatabaseManager(options.database);
2480
+ dbManager.getClient();
2481
+ const auth = createAuth({
2482
+ appName: options.auth.appName,
2483
+ baseURL: options.auth.baseURL,
2484
+ secret: options.auth.secret,
2485
+ providers: options.auth.providers,
2486
+ plugins: options.auth.plugins,
2487
+ sessionExpiresIn: options.auth.sessionExpiresIn,
2488
+ sessionUpdateAge: options.auth.sessionUpdateAge
2489
+ });
2490
+ const trpcRouter = createTrpcRouter(auth);
2491
+ const resolveBackend = createBackendResolver(options.backend);
2492
+ const adminGroups = options.auth.adminGroups ?? ["env_hopper_ui_super_admins"];
2493
+ const createContext = async ({ req }) => {
2494
+ const companySpecificBackend = await resolveBackend();
2495
+ let user = null;
2496
+ let userGroups = [];
2497
+ if (options.auth.devMockUser) {
2498
+ user = createMockUserFromDevConfig(options.auth.devMockUser);
2499
+ userGroups = options.auth.devMockUser.groups;
2500
+ } else try {
2501
+ const session = await auth.api.getSession({ headers: req.headers });
2502
+ user = (session === null || session === void 0 ? void 0 : session.user) ?? null;
2503
+ if (user && options.auth.oktaGroupsClaim) try {
2504
+ const tokenResult = await auth.api.getAccessToken({
2505
+ body: { providerId: "okta" },
2506
+ headers: req.headers
2507
+ });
2508
+ if (tokenResult.accessToken) {
2509
+ const parts = tokenResult.accessToken.split(".");
2510
+ if (parts.length === 3 && parts[1]) {
2511
+ const groups = JSON.parse(Buffer.from(parts[1], "base64").toString())[options.auth.oktaGroupsClaim];
2512
+ userGroups = Array.isArray(groups) ? groups : [];
2513
+ }
2514
+ }
2515
+ } catch (error) {
2516
+ console.error("[tRPC Context] Failed to get access token:", error);
2517
+ }
2518
+ } catch (error) {
2519
+ console.error("[tRPC Context] Failed to get session:", error);
2520
+ }
2521
+ return createEhTrpcContext({
2522
+ companySpecificBackend,
2523
+ user: user ? {
2524
+ ...user,
2525
+ groups: userGroups
2526
+ } : null,
2527
+ adminGroups
2528
+ });
2529
+ };
2530
+ const router$1 = Router();
2531
+ router$1.use(express.json());
2532
+ const middlewareContext = {
2533
+ auth,
2534
+ trpcRouter,
2535
+ createContext: async () => {
2536
+ return createEhTrpcContext({
2537
+ companySpecificBackend: await resolveBackend(),
2538
+ adminGroups
2539
+ });
2540
+ },
2541
+ authConfig: options.auth
2542
+ };
2543
+ if (((_normalizedOptions$fe = normalizedOptions.features) === null || _normalizedOptions$fe === void 0 ? void 0 : _normalizedOptions$fe.trpc) !== false) router$1.use(`${basePath}/trpc`, trpcExpress.createExpressMiddleware({
2544
+ router: trpcRouter,
2545
+ createContext
2546
+ }));
2547
+ registerFeatures(router$1, normalizedOptions, middlewareContext);
2548
+ if ((_options$hooks = options.hooks) === null || _options$hooks === void 0 ? void 0 : _options$hooks.onRoutesRegistered) await options.hooks.onRoutesRegistered(router$1);
2549
+ return {
2550
+ router: router$1,
2551
+ auth,
2552
+ trpcRouter,
2553
+ async connect() {
2554
+ var _options$hooks2;
2555
+ await dbManager.connect();
2556
+ if ((_options$hooks2 = options.hooks) === null || _options$hooks2 === void 0 ? void 0 : _options$hooks2.onDatabaseConnected) await options.hooks.onDatabaseConnected();
2557
+ },
2558
+ async disconnect() {
2559
+ var _options$hooks3;
2560
+ if ((_options$hooks3 = options.hooks) === null || _options$hooks3 === void 0 ? void 0 : _options$hooks3.onDatabaseDisconnecting) await options.hooks.onDatabaseDisconnecting();
2561
+ await dbManager.disconnect();
2562
+ },
2563
+ addRoutes(callback) {
2564
+ callback(router$1);
2565
+ }
2566
+ };
2567
+ }
2568
+
2569
+ //#endregion
2570
+ export { DEFAULT_ADMIN_SYSTEM_PROMPT, EhDatabaseManager, TABLE_SYNC_MAGAZINE, connectDb, createAdminChatHandler, createAppCatalogAdminRouter, createApprovalMethodRouter, createAuth, createAuthRouter, createDatabaseTools, createEhMiddleware, createEhTrpcContext, createPrismaDatabaseClient, createScreenshotRouter, createTrpcRouter, disconnectDb, getAssetByName, getDbClient, getUserGroups, isAdmin, isMemberOfAllGroups, isMemberOfAnyGroup, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, requireAdmin, requireGroups, setDbClient, staticControllerContract, syncAppCatalog, syncApprovalMethods, syncAssets, tableSyncPrisma, tool, upsertIcon, upsertIcons };
2571
+ //# sourceMappingURL=index.js.map