@igstack/app-catalog-backend-core 0.15.0 → 0.16.0

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 (46) hide show
  1. package/dist/db/client.d.mts +54 -1
  2. package/dist/db/client.d.mts.map +1 -1
  3. package/dist/db/client.mjs +88 -7
  4. package/dist/db/client.mjs.map +1 -1
  5. package/dist/db/index.d.mts +1 -1
  6. package/dist/index.d.mts +2 -2
  7. package/dist/index.mjs +2 -2
  8. package/dist/middleware/database.d.mts +1 -0
  9. package/dist/middleware/database.d.mts.map +1 -1
  10. package/dist/middleware/database.mjs +10 -13
  11. package/dist/middleware/database.mjs.map +1 -1
  12. package/dist/modules/assets/assetCache.mjs +25 -0
  13. package/dist/modules/assets/assetCache.mjs.map +1 -0
  14. package/dist/modules/assets/assetRestController.d.mts.map +1 -1
  15. package/dist/modules/assets/assetRestController.mjs +17 -6
  16. package/dist/modules/assets/assetRestController.mjs.map +1 -1
  17. package/dist/modules/assets/assetUtils.mjs +1 -0
  18. package/dist/modules/assets/assetUtils.mjs.map +1 -1
  19. package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
  20. package/dist/modules/assets/screenshotRestController.mjs +7 -2
  21. package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
  22. package/dist/modules/assets/upsertAsset.mjs +7 -1
  23. package/dist/modules/assets/upsertAsset.mjs.map +1 -1
  24. package/dist/modules/icons/iconRestController.d.mts.map +1 -1
  25. package/dist/modules/icons/iconRestController.mjs +7 -2
  26. package/dist/modules/icons/iconRestController.mjs.map +1 -1
  27. package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
  28. package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
  29. package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
  30. package/package.json +4 -4
  31. package/src/__tests__/assetMime.test.ts +74 -0
  32. package/src/__tests__/dbSchema.test.ts +129 -0
  33. package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
  34. package/src/__tests__/iconCache.test.ts +81 -0
  35. package/src/db/client.ts +118 -12
  36. package/src/db/index.ts +10 -1
  37. package/src/index.ts +4 -0
  38. package/src/middleware/database.ts +20 -19
  39. package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
  40. package/src/modules/assets/assetCache.ts +30 -0
  41. package/src/modules/assets/assetRestController.ts +25 -2
  42. package/src/modules/assets/assetUtils.ts +1 -0
  43. package/src/modules/assets/screenshotRestController.ts +13 -1
  44. package/src/modules/assets/upsertAsset.ts +8 -0
  45. package/src/modules/icons/iconRestController.ts +11 -1
  46. package/src/modules/lighthouseKeeper/tools.ts +6 -7
@@ -1 +1 @@
1
- {"version":3,"file":"screenshotRestController.mjs","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\n\nexport interface ScreenshotRestControllerConfig {\n /**\n * Base path for screenshot endpoints (e.g., '/api/screenshots')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for screenshot retrieval\n *\n * Endpoints:\n * - GET {basePath}/app/:appId - Get all screenshots for an app\n * - GET {basePath}/:id - Get screenshot binary by ID\n * - GET {basePath}/:id/metadata - Get screenshot metadata only\n */\nexport function registerScreenshotRestController(\n router: Router,\n config: ScreenshotRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Get all screenshots for an app\n router.get(\n `${basePath}/app/:appSlug`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app) {\n res.status(404).json({ error: 'App not found' })\n return\n }\n\n // Fetch all screenshots for the app, then sort by screenshotIds order\n const results = await prisma.dbAsset.findMany({\n where: {\n id: { in: app.screenshotIds },\n assetType: 'screenshot',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n const screenshots = app.screenshotIds\n .map((id) => results.find((r) => r.id === id))\n .filter((r) => r !== undefined)\n\n res.json(screenshots)\n } catch (error) {\n console.error('Error fetching app screenshots:', error)\n res.status(500).json({ error: 'Failed to fetch screenshots' })\n }\n },\n )\n\n // Get first screenshot for an app (convenience endpoint)\n router.get(\n `${basePath}/app/:appSlug/first`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app || app.screenshotIds.length === 0) {\n res.status(404).json({ error: 'No screenshots found' })\n return\n }\n\n // Fetch first screenshot\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id: app.screenshotIds[0] },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching first screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n },\n )\n\n // Get screenshot binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n const sizeParam = req.query.size as string | undefined\n const targetSize = sizeParam ? parseInt(sizeParam, 10) : undefined\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n let content: Uint8Array | Buffer = screenshot.content\n\n // Resize if size parameter provided\n if (targetSize && targetSize > 0) {\n try {\n content = await sharp(screenshot.content)\n .resize(targetSize, targetSize, {\n fit: 'inside',\n withoutEnlargement: true,\n })\n .toBuffer()\n } catch (resizeError) {\n console.error('Error resizing screenshot:', resizeError)\n // Fall back to original if resize fails\n }\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', screenshot.mimeType)\n res.setHeader(\n 'Content-Disposition',\n `inline; filename=\"${screenshot.name}\"`,\n )\n res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day\n\n // Send binary content\n res.send(content)\n } catch (error) {\n console.error('Error fetching screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n })\n\n // Get screenshot metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching screenshot metadata:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"screenshotRestController.mjs","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { isNotModified, setRevalidatingCacheHeaders } from './assetCache'\n\nexport interface ScreenshotRestControllerConfig {\n /**\n * Base path for screenshot endpoints (e.g., '/api/screenshots')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for screenshot retrieval\n *\n * Endpoints:\n * - GET {basePath}/app/:appId - Get all screenshots for an app\n * - GET {basePath}/:id - Get screenshot binary by ID\n * - GET {basePath}/:id/metadata - Get screenshot metadata only\n */\nexport function registerScreenshotRestController(\n router: Router,\n config: ScreenshotRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Get all screenshots for an app\n router.get(\n `${basePath}/app/:appSlug`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app) {\n res.status(404).json({ error: 'App not found' })\n return\n }\n\n // Fetch all screenshots for the app, then sort by screenshotIds order\n const results = await prisma.dbAsset.findMany({\n where: {\n id: { in: app.screenshotIds },\n assetType: 'screenshot',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n const screenshots = app.screenshotIds\n .map((id) => results.find((r) => r.id === id))\n .filter((r) => r !== undefined)\n\n res.json(screenshots)\n } catch (error) {\n console.error('Error fetching app screenshots:', error)\n res.status(500).json({ error: 'Failed to fetch screenshots' })\n }\n },\n )\n\n // Get first screenshot for an app (convenience endpoint)\n router.get(\n `${basePath}/app/:appSlug/first`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app || app.screenshotIds.length === 0) {\n res.status(404).json({ error: 'No screenshots found' })\n return\n }\n\n // Fetch first screenshot\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id: app.screenshotIds[0] },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching first screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n },\n )\n\n // Get screenshot binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n const sizeParam = req.query.size as string | undefined\n const targetSize = sizeParam ? parseInt(sizeParam, 10) : undefined\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n checksum: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n // Answered before resizing, so an unchanged screenshot costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n screenshot.checksum,\n targetSize && targetSize > 0 ? `s${targetSize}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n let content: Uint8Array | Buffer = screenshot.content\n\n // Resize if size parameter provided\n if (targetSize && targetSize > 0) {\n try {\n content = await sharp(screenshot.content)\n .resize(targetSize, targetSize, {\n fit: 'inside',\n withoutEnlargement: true,\n })\n .toBuffer()\n } catch (resizeError) {\n console.error('Error resizing screenshot:', resizeError)\n // Fall back to original if resize fails\n }\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', screenshot.mimeType)\n res.setHeader(\n 'Content-Disposition',\n `inline; filename=\"${screenshot.name}\"`,\n )\n\n // Send binary content\n res.send(content)\n } catch (error) {\n console.error('Error fetching screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n })\n\n // Get screenshot metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching screenshot metadata:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
@@ -7,7 +7,13 @@ async function upsertAsset({ prisma, buffer, name, originalFilename, assetType }
7
7
  originalFilename
8
8
  });
9
9
  const existing = await prisma.dbAsset.findUnique({ where: { name } });
10
- if (existing) return existing.id;
10
+ if (existing) {
11
+ if (existing.mimeType !== mimeType) await prisma.dbAsset.update({
12
+ where: { id: existing.id },
13
+ data: { mimeType }
14
+ });
15
+ return existing.id;
16
+ }
11
17
  return (await prisma.dbAsset.create({ data: {
12
18
  name,
13
19
  checksum,
@@ -1 +1 @@
1
- {"version":3,"file":"upsertAsset.mjs","names":[],"sources":["../../../src/modules/assets/upsertAsset.ts"],"sourcesContent":["import { parseAssetMeta } from './assetUtils'\nimport type { AssetType, PrismaClient } from '../../generated/prisma/client'\n\nexport interface UpsertAssetParams {\n prisma: PrismaClient\n buffer: Buffer\n name: string\n originalFilename: string\n assetType: AssetType\n}\n\nexport async function upsertAsset({\n prisma,\n buffer,\n name,\n originalFilename,\n assetType,\n}: UpsertAssetParams) {\n const { checksum, fileSize, width, height, mimeType } = await parseAssetMeta({\n buffer,\n originalFilename,\n })\n\n // If an asset with the same checksum already exists, reuse it instead of storing duplicate binary.\n const existing = await prisma.dbAsset.findUnique({\n where: { name },\n })\n\n if (existing) {\n return existing.id\n }\n\n const asset = await prisma.dbAsset.create({\n data: {\n name,\n checksum,\n assetType,\n content: new Uint8Array(buffer),\n mimeType,\n fileSize,\n width,\n height,\n },\n })\n return asset.id\n}\n"],"mappings":";;;AAWA,eAAsB,YAAY,EAChC,QACA,QACA,MACA,kBACA,aACoB;CACpB,MAAM,EAAE,UAAU,UAAU,OAAO,QAAQ,aAAa,MAAM,eAAe;EAC3E;EACA;EACD,CAAC;CAGF,MAAM,WAAW,MAAM,OAAO,QAAQ,WAAW,EAC/C,OAAO,EAAE,MAAM,EAChB,CAAC;AAEF,KAAI,SACF,QAAO,SAAS;AAelB,SAZc,MAAM,OAAO,QAAQ,OAAO,EACxC,MAAM;EACJ;EACA;EACA;EACA,SAAS,IAAI,WAAW,OAAO;EAC/B;EACA;EACA;EACA;EACD,EACF,CAAC,EACW"}
1
+ {"version":3,"file":"upsertAsset.mjs","names":[],"sources":["../../../src/modules/assets/upsertAsset.ts"],"sourcesContent":["import { parseAssetMeta } from './assetUtils'\nimport type { AssetType, PrismaClient } from '../../generated/prisma/client'\n\nexport interface UpsertAssetParams {\n prisma: PrismaClient\n buffer: Buffer\n name: string\n originalFilename: string\n assetType: AssetType\n}\n\nexport async function upsertAsset({\n prisma,\n buffer,\n name,\n originalFilename,\n assetType,\n}: UpsertAssetParams) {\n const { checksum, fileSize, width, height, mimeType } = await parseAssetMeta({\n buffer,\n originalFilename,\n })\n\n // If an asset with the same checksum already exists, reuse it instead of storing duplicate binary.\n const existing = await prisma.dbAsset.findUnique({\n where: { name },\n })\n\n if (existing) {\n // Reusing the stored binary must not keep a mimeType we no longer derive:\n // rows written by an older, wrong derivation would never be corrected.\n if (existing.mimeType !== mimeType) {\n await prisma.dbAsset.update({\n where: { id: existing.id },\n data: { mimeType },\n })\n }\n return existing.id\n }\n\n const asset = await prisma.dbAsset.create({\n data: {\n name,\n checksum,\n assetType,\n content: new Uint8Array(buffer),\n mimeType,\n fileSize,\n width,\n height,\n },\n })\n return asset.id\n}\n"],"mappings":";;;AAWA,eAAsB,YAAY,EAChC,QACA,QACA,MACA,kBACA,aACoB;CACpB,MAAM,EAAE,UAAU,UAAU,OAAO,QAAQ,aAAa,MAAM,eAAe;EAC3E;EACA;EACD,CAAC;CAGF,MAAM,WAAW,MAAM,OAAO,QAAQ,WAAW,EAC/C,OAAO,EAAE,MAAM,EAChB,CAAC;AAEF,KAAI,UAAU;AAGZ,MAAI,SAAS,aAAa,SACxB,OAAM,OAAO,QAAQ,OAAO;GAC1B,OAAO,EAAE,IAAI,SAAS,IAAI;GAC1B,MAAM,EAAE,UAAU;GACnB,CAAC;AAEJ,SAAO,SAAS;;AAelB,SAZc,MAAM,OAAO,QAAQ,OAAO,EACxC,MAAM;EACJ;EACA;EACA;EACA,SAAS,IAAI,WAAW,OAAO;EAC/B;EACA;EACA;EACA;EACD,EACF,CAAC,EACW"}
@@ -1 +1 @@
1
- {"version":3,"file":"iconRestController.d.mts","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"mappings":";;;UAsBiB,wBAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,0BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,wBAAA"}
1
+ {"version":3,"file":"iconRestController.d.mts","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"mappings":";;;UA0BiB,wBAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,0BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,wBAAA"}
@@ -1,5 +1,6 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
2
  import { getExtensionFromFilename, getExtensionFromMimeType } from "./iconUtils.mjs";
3
+ import { isNotModified, setRevalidatingCacheHeaders } from "../assets/assetCache.mjs";
3
4
  import { createHash } from "node:crypto";
4
5
  import multer from "multer";
5
6
 
@@ -71,16 +72,20 @@ function registerIconRestController(router, config) {
71
72
  select: {
72
73
  content: true,
73
74
  mimeType: true,
74
- name: true
75
+ name: true,
76
+ checksum: true
75
77
  }
76
78
  });
77
79
  if (!icon) {
78
80
  res.status(404).json({ error: "Icon not found" });
79
81
  return;
80
82
  }
83
+ if (isNotModified(req, setRevalidatingCacheHeaders(res, icon.checksum))) {
84
+ res.status(304).end();
85
+ return;
86
+ }
81
87
  res.setHeader("Content-Type", icon.mimeType);
82
88
  res.setHeader("Content-Disposition", `inline; filename="${icon.name}"`);
83
- res.setHeader("Cache-Control", "public, max-age=86400");
84
89
  res.send(icon.content);
85
90
  } catch (error) {
86
91
  console.error("Error fetching icon:", error);
@@ -1 +1 @@
1
- {"version":3,"file":"iconRestController.mjs","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport { createHash } from 'node:crypto'\nimport { getDbClient } from '../../db'\nimport { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface IconRestControllerConfig {\n /**\n * Base path for icon endpoints (e.g., '/api/icons')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for icon upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)\n * - GET {basePath}/:id - Get icon binary by ID\n * - GET {basePath}/:id/metadata - Get icon metadata only\n */\nexport function registerIconRestController(\n router: Router,\n config: IconRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('icon'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n let name = req.body['name'] as string\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n // Extract extension from original filename or derive from MIME type\n const extension =\n getExtensionFromFilename(req.file.originalname) ||\n getExtensionFromMimeType(req.file.mimetype)\n\n // Add extension to name if not already present\n if (!name.includes('.')) {\n name = `${name}.${extension}`\n }\n\n const prisma = getDbClient()\n const checksum = createHash('sha256')\n .update(req.file.buffer)\n .digest('hex')\n const icon = await prisma.dbAsset.create({\n data: {\n name,\n assetType: 'icon',\n content: new Uint8Array(req.file.buffer),\n mimeType: req.file.mimetype,\n fileSize: req.file.size,\n checksum,\n },\n })\n\n res.status(201).json({\n id: icon.id,\n name: icon.name,\n mimeType: icon.mimeType,\n fileSize: icon.fileSize,\n createdAt: icon.createdAt,\n })\n } catch (error) {\n console.error('Error uploading icon:', error)\n res.status(500).json({ error: 'Failed to upload icon' })\n }\n },\n )\n\n // Get icon binary by name (e.g., /api/icons/jira.svg)\n router.get(`${basePath}/:name`, async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n content: true,\n mimeType: true,\n name: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', icon.mimeType)\n res.setHeader('Content-Disposition', `inline; filename=\"${icon.name}\"`)\n res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day\n\n // Send binary content\n res.send(icon.content)\n } catch (error) {\n console.error('Error fetching icon:', error)\n res.status(500).json({ error: 'Failed to fetch icon' })\n }\n })\n\n // Get icon metadata only (no binary content)\n router.get(\n `${basePath}/:name/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n res.json(icon)\n } catch (error) {\n console.error('Error fetching icon metadata:', error)\n res.status(500).json({ error: 'Failed to fetch icon metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"iconRestController.mjs","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport { createHash } from 'node:crypto'\nimport { getDbClient } from '../../db'\nimport { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'\nimport {\n isNotModified,\n setRevalidatingCacheHeaders,\n} from '../assets/assetCache'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface IconRestControllerConfig {\n /**\n * Base path for icon endpoints (e.g., '/api/icons')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for icon upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)\n * - GET {basePath}/:id - Get icon binary by ID\n * - GET {basePath}/:id/metadata - Get icon metadata only\n */\nexport function registerIconRestController(\n router: Router,\n config: IconRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('icon'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n let name = req.body['name'] as string\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n // Extract extension from original filename or derive from MIME type\n const extension =\n getExtensionFromFilename(req.file.originalname) ||\n getExtensionFromMimeType(req.file.mimetype)\n\n // Add extension to name if not already present\n if (!name.includes('.')) {\n name = `${name}.${extension}`\n }\n\n const prisma = getDbClient()\n const checksum = createHash('sha256')\n .update(req.file.buffer)\n .digest('hex')\n const icon = await prisma.dbAsset.create({\n data: {\n name,\n assetType: 'icon',\n content: new Uint8Array(req.file.buffer),\n mimeType: req.file.mimetype,\n fileSize: req.file.size,\n checksum,\n },\n })\n\n res.status(201).json({\n id: icon.id,\n name: icon.name,\n mimeType: icon.mimeType,\n fileSize: icon.fileSize,\n createdAt: icon.createdAt,\n })\n } catch (error) {\n console.error('Error uploading icon:', error)\n res.status(500).json({ error: 'Failed to upload icon' })\n }\n },\n )\n\n // Get icon binary by name (e.g., /api/icons/jira.svg)\n router.get(`${basePath}/:name`, async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n content: true,\n mimeType: true,\n name: true,\n checksum: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n const etag = setRevalidatingCacheHeaders(res, icon.checksum)\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', icon.mimeType)\n res.setHeader('Content-Disposition', `inline; filename=\"${icon.name}\"`)\n\n // Send binary content\n res.send(icon.content)\n } catch (error) {\n console.error('Error fetching icon:', error)\n res.status(500).json({ error: 'Failed to fetch icon' })\n }\n })\n\n // Get icon metadata only (no binary content)\n router.get(\n `${basePath}/:name/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n res.json(icon)\n } catch (error) {\n console.error('Error fetching icon metadata:', error)\n res.status(500).json({ error: 'Failed to fetch icon metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.mts","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"mappings":";;;;;;AA0BA;;iBAAgB,uBAAA,CACd,cAAA,EAAgB,gBAAA,GACf,MAAA,SAAe,IAAA;;;;;cAuEL,4BAAA"}
1
+ {"version":3,"file":"tools.d.mts","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"mappings":";;;;;;AAwBA;;iBAAgB,uBAAA,CACd,cAAA,EAAgB,gBAAA,GACf,MAAA,SAAe,IAAA;;;;;cAwEL,4BAAA"}
@@ -1,6 +1,4 @@
1
- import { PrismaClient } from "../../generated/prisma/client.mjs";
2
- import { PrismaPg } from "@prisma/adapter-pg";
3
- import pg from "pg";
1
+ import { createCorePrismaClient } from "../../db/client.mjs";
4
2
  import { z } from "zod";
5
3
 
6
4
  //#region src/modules/lighthouseKeeper/tools.ts
@@ -14,8 +12,10 @@ function getDatabaseUrl(config) {
14
12
  * These tools allow the AI to fetch and inspect app information.
15
13
  */
16
14
  function createAppCatalogAITools(databaseConfig) {
17
- const databaseUrl = getDatabaseUrl(databaseConfig);
18
- const prisma = new PrismaClient({ adapter: new PrismaPg(new pg.Pool({ connectionString: databaseUrl })) });
15
+ const { client: prisma } = createCorePrismaClient({
16
+ connectionString: getDatabaseUrl(databaseConfig),
17
+ configuredSchema: "url" in databaseConfig ? void 0 : databaseConfig.schema
18
+ });
19
19
  return { getAppCard: {
20
20
  description: "Fetch complete app catalog card information by ID. Returns title, description, tags, access request details, links, and all other app metadata.",
21
21
  inputSchema: z.object({ slug: z.string().describe("The app slug to fetch") }),
@@ -1 +1 @@
1
- {"version":3,"file":"tools.mjs","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"sourcesContent":["import type { Tool } from 'ai'\nimport { z } from 'zod'\nimport { PrismaClient } from '../../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport type { AcDatabaseConfig } from '../../middleware/types.js'\n\n// ============================================================================\n// Database URL Helper\n// ============================================================================\n\nfunction getDatabaseUrl(config: AcDatabaseConfig): string {\n if ('url' in config) return config.url\n const { host, port, database, username, password, schema } = config\n const schemaParam = schema ? `?schema=${schema}` : ''\n return `postgresql://${username}:${password}@${host}:${port}/${database}${schemaParam}`\n}\n\n// ============================================================================\n// AI Tools for App Catalog\n// ============================================================================\n\n/**\n * Create AI tools for working with app catalog cards.\n * These tools allow the AI to fetch and inspect app information.\n */\nexport function createAppCatalogAITools(\n databaseConfig: AcDatabaseConfig,\n): Record<string, Tool> {\n const databaseUrl = getDatabaseUrl(databaseConfig)\n const pool = new pg.Pool({ connectionString: databaseUrl })\n const adapter = new PrismaPg(pool)\n const prisma = new PrismaClient({ adapter })\n\n const getAppCardSchema = z.object({\n slug: z.string().describe('The app slug to fetch'),\n })\n\n type GetAppCardInput = z.infer<typeof getAppCardSchema>\n\n const getAppCard: Tool<GetAppCardInput, unknown> = {\n description:\n 'Fetch complete app catalog card information by ID. Returns title, description, tags, access request details, links, and all other app metadata.',\n inputSchema: getAppCardSchema,\n execute: async ({ slug }: { slug: string }) => {\n try {\n const app = await prisma.dbResource.findUnique({\n where: { slug },\n include: {\n sourceRefs: true,\n },\n })\n\n if (!app) {\n return { error: `App not found with slug: ${slug}` }\n }\n\n // Return structured card data\n return {\n success: true,\n card: {\n id: app.id,\n slug: app.slug,\n displayName: app.displayName,\n description: app.description,\n teams: app.teams,\n tags: app.tags,\n appUrl: app.appUrl,\n links: app.links,\n iconName: app.iconName,\n sources: app.sourceRefs.map((ref) => ref.url),\n notes: app.notes,\n accessRequest: app.accessRequest,\n deprecated: app.deprecated,\n createdAt: app.createdAt,\n updatedAt: app.updatedAt,\n },\n }\n } catch (error) {\n return {\n error: error instanceof Error ? error.message : 'Failed to fetch app',\n }\n }\n },\n }\n\n return {\n getAppCard,\n }\n}\n\n// ============================================================================\n// System Prompt\n// ============================================================================\n\n/**\n * Default system prompt for AI working with app catalog cards.\n * Use this when configuring lighthouseKeeper for card updates.\n */\nexport const APP_CATALOG_AI_SYSTEM_PROMPT = `You are an assistant that helps update app catalog cards based on information from provided links.\n\nWhen given a link and an app ID:\n1. Use getAppCard to fetch the current card information\n2. Analyze the provided link for relevant information\n3. Output a new card structure with updated information based on the link\n\nReturn the updated card as a structured JSON object matching the card format:\n- displayName: App title\n- description: Full description\n- tags: Array of tags\n- teams: Array of team names\n- appUrl: Main application URL\n- links: Array of {url, title} objects for related links\n- sources: Array of source URLs (documentation, Confluence pages, etc.)\n- accessRequest: Object with approvalMethodSlug, comments, requestPrompt, postApprovalInstructions, roles, approverPersonSlugs, urls\n- notes: Additional notes\n- deprecated: Optional deprecation info with type, replacementSlug, comment\n\nDo NOT save the card to the database. Only output the updated structure.`\n"],"mappings":";;;;;;AAWA,SAAS,eAAe,QAAkC;AACxD,KAAI,SAAS,OAAQ,QAAO,OAAO;CACnC,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,WAAW;AAE7D,QAAO,gBAAgB,SAAS,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,WAD3C,SAAS,WAAW,WAAW;;;;;;AAYrD,SAAgB,wBACd,gBACsB;CACtB,MAAM,cAAc,eAAe,eAAe;CAGlD,MAAM,SAAS,IAAI,aAAa,EAAE,SADlB,IAAI,SADP,IAAI,GAAG,KAAK,EAAE,kBAAkB,aAAa,CAAC,CACzB,EACS,CAAC;AAsD5C,QAAO,EACL,YA/CiD;EACjD,aACE;EACF,aATuB,EAAE,OAAO,EAChC,MAAM,EAAE,QAAQ,CAAC,SAAS,wBAAwB,EACnD,CAAC;EAQA,SAAS,OAAO,EAAE,WAA6B;AAC7C,OAAI;IACF,MAAM,MAAM,MAAM,OAAO,WAAW,WAAW;KAC7C,OAAO,EAAE,MAAM;KACf,SAAS,EACP,YAAY,MACb;KACF,CAAC;AAEF,QAAI,CAAC,IACH,QAAO,EAAE,OAAO,4BAA4B,QAAQ;AAItD,WAAO;KACL,SAAS;KACT,MAAM;MACJ,IAAI,IAAI;MACR,MAAM,IAAI;MACV,aAAa,IAAI;MACjB,aAAa,IAAI;MACjB,OAAO,IAAI;MACX,MAAM,IAAI;MACV,QAAQ,IAAI;MACZ,OAAO,IAAI;MACX,UAAU,IAAI;MACd,SAAS,IAAI,WAAW,KAAK,QAAQ,IAAI,IAAI;MAC7C,OAAO,IAAI;MACX,eAAe,IAAI;MACnB,YAAY,IAAI;MAChB,WAAW,IAAI;MACf,WAAW,IAAI;MAChB;KACF;YACM,OAAO;AACd,WAAO,EACL,OAAO,iBAAiB,QAAQ,MAAM,UAAU,uBACjD;;;EAGN,EAIA;;;;;;AAWH,MAAa,+BAA+B"}
1
+ {"version":3,"file":"tools.mjs","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"sourcesContent":["import type { Tool } from 'ai'\nimport { z } from 'zod'\nimport { createCorePrismaClient } from '../../db/client'\nimport type { AcDatabaseConfig } from '../../middleware/types.js'\n\n// ============================================================================\n// Database URL Helper\n// ============================================================================\n\nfunction getDatabaseUrl(config: AcDatabaseConfig): string {\n if ('url' in config) return config.url\n const { host, port, database, username, password, schema } = config\n const schemaParam = schema ? `?schema=${schema}` : ''\n return `postgresql://${username}:${password}@${host}:${port}/${database}${schemaParam}`\n}\n\n// ============================================================================\n// AI Tools for App Catalog\n// ============================================================================\n\n/**\n * Create AI tools for working with app catalog cards.\n * These tools allow the AI to fetch and inspect app information.\n */\nexport function createAppCatalogAITools(\n databaseConfig: AcDatabaseConfig,\n): Record<string, Tool> {\n const { client: prisma } = createCorePrismaClient({\n connectionString: getDatabaseUrl(databaseConfig),\n configuredSchema:\n 'url' in databaseConfig ? undefined : databaseConfig.schema,\n })\n\n const getAppCardSchema = z.object({\n slug: z.string().describe('The app slug to fetch'),\n })\n\n type GetAppCardInput = z.infer<typeof getAppCardSchema>\n\n const getAppCard: Tool<GetAppCardInput, unknown> = {\n description:\n 'Fetch complete app catalog card information by ID. Returns title, description, tags, access request details, links, and all other app metadata.',\n inputSchema: getAppCardSchema,\n execute: async ({ slug }: { slug: string }) => {\n try {\n const app = await prisma.dbResource.findUnique({\n where: { slug },\n include: {\n sourceRefs: true,\n },\n })\n\n if (!app) {\n return { error: `App not found with slug: ${slug}` }\n }\n\n // Return structured card data\n return {\n success: true,\n card: {\n id: app.id,\n slug: app.slug,\n displayName: app.displayName,\n description: app.description,\n teams: app.teams,\n tags: app.tags,\n appUrl: app.appUrl,\n links: app.links,\n iconName: app.iconName,\n sources: app.sourceRefs.map((ref) => ref.url),\n notes: app.notes,\n accessRequest: app.accessRequest,\n deprecated: app.deprecated,\n createdAt: app.createdAt,\n updatedAt: app.updatedAt,\n },\n }\n } catch (error) {\n return {\n error: error instanceof Error ? error.message : 'Failed to fetch app',\n }\n }\n },\n }\n\n return {\n getAppCard,\n }\n}\n\n// ============================================================================\n// System Prompt\n// ============================================================================\n\n/**\n * Default system prompt for AI working with app catalog cards.\n * Use this when configuring lighthouseKeeper for card updates.\n */\nexport const APP_CATALOG_AI_SYSTEM_PROMPT = `You are an assistant that helps update app catalog cards based on information from provided links.\n\nWhen given a link and an app ID:\n1. Use getAppCard to fetch the current card information\n2. Analyze the provided link for relevant information\n3. Output a new card structure with updated information based on the link\n\nReturn the updated card as a structured JSON object matching the card format:\n- displayName: App title\n- description: Full description\n- tags: Array of tags\n- teams: Array of team names\n- appUrl: Main application URL\n- links: Array of {url, title} objects for related links\n- sources: Array of source URLs (documentation, Confluence pages, etc.)\n- accessRequest: Object with approvalMethodSlug, comments, requestPrompt, postApprovalInstructions, roles, approverPersonSlugs, urls\n- notes: Additional notes\n- deprecated: Optional deprecation info with type, replacementSlug, comment\n\nDo NOT save the card to the database. Only output the updated structure.`\n"],"mappings":";;;;AASA,SAAS,eAAe,QAAkC;AACxD,KAAI,SAAS,OAAQ,QAAO,OAAO;CACnC,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,WAAW;AAE7D,QAAO,gBAAgB,SAAS,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,WAD3C,SAAS,WAAW,WAAW;;;;;;AAYrD,SAAgB,wBACd,gBACsB;CACtB,MAAM,EAAE,QAAQ,WAAW,uBAAuB;EAChD,kBAAkB,eAAe,eAAe;EAChD,kBACE,SAAS,iBAAiB,SAAY,eAAe;EACxD,CAAC;AAsDF,QAAO,EACL,YA/CiD;EACjD,aACE;EACF,aATuB,EAAE,OAAO,EAChC,MAAM,EAAE,QAAQ,CAAC,SAAS,wBAAwB,EACnD,CAAC;EAQA,SAAS,OAAO,EAAE,WAA6B;AAC7C,OAAI;IACF,MAAM,MAAM,MAAM,OAAO,WAAW,WAAW;KAC7C,OAAO,EAAE,MAAM;KACf,SAAS,EACP,YAAY,MACb;KACF,CAAC;AAEF,QAAI,CAAC,IACH,QAAO,EAAE,OAAO,4BAA4B,QAAQ;AAItD,WAAO;KACL,SAAS;KACT,MAAM;MACJ,IAAI,IAAI;MACR,MAAM,IAAI;MACV,aAAa,IAAI;MACjB,aAAa,IAAI;MACjB,OAAO,IAAI;MACX,MAAM,IAAI;MACV,QAAQ,IAAI;MACZ,OAAO,IAAI;MACX,UAAU,IAAI;MACd,SAAS,IAAI,WAAW,KAAK,QAAQ,IAAI,IAAI;MAC7C,OAAO,IAAI;MACX,eAAe,IAAI;MACnB,YAAY,IAAI;MAChB,WAAW,IAAI;MACf,WAAW,IAAI;MAChB;KACF;YACM,OAAO;AACd,WAAO,EACL,OAAO,iBAAiB,QAAQ,MAAM,UAAU,uBACjD;;;EAGN,EAIA;;;;;;AAWH,MAAa,+BAA+B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Backend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -45,8 +45,8 @@
45
45
  "tsyringe": "^4.10.0",
46
46
  "yaml": "^2.8.0",
47
47
  "zod": "^4.3.5",
48
- "@igstack/app-catalog-shared-core": "0.15.0",
49
- "@igstack/app-catalog-table-sync": "0.15.0"
48
+ "@igstack/app-catalog-shared-core": "0.16.0",
49
+ "@igstack/app-catalog-table-sync": "0.16.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tanstack/vite-config": "^0.4.3",
@@ -73,7 +73,7 @@
73
73
  "engines": {
74
74
  "node": ">=24"
75
75
  },
76
- "gitHead": "e021e223f8da284a81e2204908587d55b2f87b6a",
76
+ "gitHead": "8ef46d75c527a6d4a00965f7529e000c03cf0bc6",
77
77
  "scripts": {
78
78
  "build": "tsdown",
79
79
  "build:lenient": "tsdown",
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { parseAssetMeta } from '../modules/assets/assetUtils'
3
+ import { upsertAsset } from '../modules/assets/upsertAsset'
4
+ import type { PrismaClient } from '../generated/prisma/client'
5
+
6
+ const SVG = Buffer.from(
7
+ '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#123456"/></svg>',
8
+ )
9
+
10
+ describe('parseAssetMeta mime derivation', () => {
11
+ it('serves SVG as image/svg+xml, not the invalid image/svg', async () => {
12
+ const meta = await parseAssetMeta({
13
+ buffer: SVG,
14
+ originalFilename: 'example-icon.svg',
15
+ })
16
+
17
+ expect(meta.mimeType).toBe('image/svg+xml')
18
+ })
19
+ })
20
+
21
+ describe('upsertAsset mimeType repair', () => {
22
+ const fakePrisma = (existing: { id: string; mimeType: string } | null) => {
23
+ const update = vi.fn().mockResolvedValue(existing)
24
+ const create = vi.fn().mockResolvedValue({ id: 'created' })
25
+ const prisma = {
26
+ dbAsset: {
27
+ findUnique: vi.fn().mockResolvedValue(existing),
28
+ update,
29
+ create,
30
+ },
31
+ } as unknown as PrismaClient
32
+
33
+ return { prisma, update, create }
34
+ }
35
+
36
+ it('rewrites a stale mimeType on an already-stored asset', async () => {
37
+ const { prisma, update } = fakePrisma({
38
+ id: 'existing',
39
+ mimeType: 'image/svg',
40
+ })
41
+
42
+ const id = await upsertAsset({
43
+ prisma,
44
+ buffer: SVG,
45
+ name: 'example-icon',
46
+ originalFilename: 'example-icon.svg',
47
+ assetType: 'icon',
48
+ })
49
+
50
+ expect(id).toBe('existing')
51
+ expect(update).toHaveBeenCalledWith({
52
+ where: { id: 'existing' },
53
+ data: { mimeType: 'image/svg+xml' },
54
+ })
55
+ })
56
+
57
+ it('leaves an already-correct asset untouched', async () => {
58
+ const { prisma, update } = fakePrisma({
59
+ id: 'existing',
60
+ mimeType: 'image/svg+xml',
61
+ })
62
+
63
+ const id = await upsertAsset({
64
+ prisma,
65
+ buffer: SVG,
66
+ name: 'example-icon',
67
+ originalFilename: 'example-icon.svg',
68
+ assetType: 'icon',
69
+ })
70
+
71
+ expect(id).toBe('existing')
72
+ expect(update).not.toHaveBeenCalled()
73
+ })
74
+ })
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Unit tests for schema-per-branch DB isolation (#44).
3
+ * Tests the logic that maps DB_SCHEMA env → pg pool options.
4
+ */
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
+ import {
7
+ buildPgSchemaOptions,
8
+ resolveDbSchema,
9
+ verifyDbSchema,
10
+ } from '../db/client'
11
+
12
+ const original = process.env.DB_SCHEMA
13
+
14
+ beforeEach(() => {
15
+ // The helper reads the env directly, so each case states its own world.
16
+ delete process.env.DB_SCHEMA
17
+ })
18
+
19
+ afterEach(() => {
20
+ if (original === undefined) delete process.env.DB_SCHEMA
21
+ else process.env.DB_SCHEMA = original
22
+ })
23
+
24
+ describe('DB_SCHEMA schema-per-branch isolation (#44)', () => {
25
+ it('returns empty object when DB_SCHEMA is unset (public schema implicit)', () => {
26
+ expect(buildPgSchemaOptions(undefined)).toEqual({})
27
+ })
28
+
29
+ it('returns empty object when DB_SCHEMA is empty string', () => {
30
+ expect(buildPgSchemaOptions('')).toEqual({})
31
+ })
32
+
33
+ it('sets search_path on the pool when DB_SCHEMA is set', () => {
34
+ expect(buildPgSchemaOptions('preview_feat-my-branch')).toEqual({
35
+ options: '-c search_path=preview_feat-my-branch',
36
+ })
37
+ })
38
+
39
+ it('handles schema names with underscores and hyphens', () => {
40
+ const result = buildPgSchemaOptions('preview_feature-123')
41
+ expect(result).toEqual({ options: '-c search_path=preview_feature-123' })
42
+ })
43
+
44
+ it('falls back to DB_SCHEMA when no schema is passed', () => {
45
+ // The middleware pool has no schema in its config when it is given a plain
46
+ // url, so the env var is the only thing standing between a preview env and
47
+ // the shared `public` tables.
48
+ process.env.DB_SCHEMA = 'preview_from-env'
49
+ expect(buildPgSchemaOptions()).toEqual({
50
+ options: '-c search_path=preview_from-env',
51
+ })
52
+ })
53
+
54
+ it('lets DB_SCHEMA override a schema from the app config', () => {
55
+ // The deployment knows it is a preview; the config server does not, and its
56
+ // baked `public` would otherwise undo the isolation.
57
+ process.env.DB_SCHEMA = 'preview_from-env'
58
+ expect(buildPgSchemaOptions('public')).toEqual({
59
+ options: '-c search_path=preview_from-env',
60
+ })
61
+ })
62
+
63
+ it('uses the configured schema when DB_SCHEMA is unset', () => {
64
+ expect(buildPgSchemaOptions('public')).toEqual({
65
+ options: '-c search_path=public',
66
+ })
67
+ })
68
+ })
69
+
70
+ describe('resolveDbSchema', () => {
71
+ it('is undefined when nothing configures a schema', () => {
72
+ expect(resolveDbSchema()).toBeUndefined()
73
+ expect(resolveDbSchema('')).toBeUndefined()
74
+ })
75
+
76
+ it('prefers DB_SCHEMA over the configured schema', () => {
77
+ process.env.DB_SCHEMA = 'preview_from-env'
78
+ expect(resolveDbSchema('public')).toBe('preview_from-env')
79
+ })
80
+
81
+ it('falls back to the configured schema', () => {
82
+ expect(resolveDbSchema('public')).toBe('public')
83
+ })
84
+ })
85
+
86
+ describe('verifyDbSchema fail-fast (#82)', () => {
87
+ const poolReturning = (schema: string | null) =>
88
+ ({
89
+ query: async () => ({ rows: [{ schema }] }),
90
+ }) as unknown as Parameters<typeof verifyDbSchema>[0]
91
+
92
+ it('stays quiet for a deployment with no isolation to verify', async () => {
93
+ await expect(
94
+ verifyDbSchema(poolReturning('public')),
95
+ ).resolves.toBeUndefined()
96
+ })
97
+
98
+ it('passes when the database resolves the expected schema', async () => {
99
+ process.env.DB_SCHEMA = 'preview_feat-my-branch'
100
+ await expect(
101
+ verifyDbSchema(poolReturning('preview_feat-my-branch')),
102
+ ).resolves.toBeUndefined()
103
+ })
104
+
105
+ it('checks a deployment isolated by config alone, without DB_SCHEMA', async () => {
106
+ await expect(
107
+ verifyDbSchema(poolReturning('public'), 'preview_feat-my-branch'),
108
+ ).rejects.toThrow(/current_schema\(\) to "public"/)
109
+ })
110
+
111
+ it('accepts the name Postgres kept when the schema is over 63 bytes', async () => {
112
+ // CREATE SCHEMA and search_path truncate identically, so a long branch slug
113
+ // is still isolated - failing it here would crash-loop a healthy preview env.
114
+ const long = 'preview_' + 'b'.repeat(70)
115
+ process.env.DB_SCHEMA = long
116
+ await expect(
117
+ verifyDbSchema(poolReturning(long.slice(0, 63))),
118
+ ).resolves.toBeUndefined()
119
+ })
120
+
121
+ it('throws when a missing schema falls the connection through to public', async () => {
122
+ // Postgres skips a search_path entry that does not exist, which would let a
123
+ // preview env quietly read and overwrite the shared catalog.
124
+ process.env.DB_SCHEMA = 'preview_feat-my-branch'
125
+ await expect(verifyDbSchema(poolReturning('public'))).rejects.toThrow(
126
+ /current_schema\(\) to "public"/,
127
+ )
128
+ })
129
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Regression tests for preview-env schema isolation (#82).
3
+ *
4
+ * A `search_path` on the pool is not enough: Prisma 7 driver adapters qualify
5
+ * every table name with the schema the adapter reports, so a preview env whose
6
+ * adapter reports nothing reads and writes `public` no matter what the pool's
7
+ * `current_schema()` says. These tests pin the schema all the way to the
8
+ * `PrismaPg` constructor for every core call site.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+
12
+ const poolCtor = vi.fn()
13
+ const adapterCtor = vi.fn()
14
+
15
+ vi.mock('pg', () => ({
16
+ default: {
17
+ Pool: class {
18
+ constructor(config: unknown) {
19
+ poolCtor(config)
20
+ }
21
+ },
22
+ },
23
+ }))
24
+
25
+ vi.mock('@prisma/adapter-pg', () => ({
26
+ PrismaPg: class {
27
+ constructor(pool: unknown, options: unknown) {
28
+ adapterCtor(pool, options)
29
+ }
30
+ },
31
+ }))
32
+
33
+ vi.mock('../generated/prisma/client', () => ({
34
+ PrismaClient: class {
35
+ $connect = vi.fn()
36
+ $disconnect = vi.fn()
37
+ },
38
+ }))
39
+
40
+ const original = process.env.DB_SCHEMA
41
+ const originalUrl = process.env.AC_CORE_DATABASE_URL
42
+
43
+ beforeEach(() => {
44
+ vi.resetModules()
45
+ poolCtor.mockClear()
46
+ adapterCtor.mockClear()
47
+ process.env.DB_SCHEMA = 'preview_feat-my-branch'
48
+ })
49
+
50
+ afterEach(() => {
51
+ if (original === undefined) delete process.env.DB_SCHEMA
52
+ else process.env.DB_SCHEMA = original
53
+ if (originalUrl === undefined) delete process.env.AC_CORE_DATABASE_URL
54
+ else process.env.AC_CORE_DATABASE_URL = originalUrl
55
+ })
56
+
57
+ const adapterSchema = () => adapterCtor.mock.calls[0]?.[1]
58
+ const poolOptions = () =>
59
+ (poolCtor.mock.calls[0]?.[0] as { options?: string } | undefined)?.options
60
+
61
+ describe('preview-env schema reaches the Prisma adapter (#82)', () => {
62
+ it('getDbClient() passes DB_SCHEMA to the adapter, not just the pool', async () => {
63
+ process.env.AC_CORE_DATABASE_URL = 'postgresql://u:p@h:5432/db'
64
+ const { getDbClient } = await import('../db/client')
65
+
66
+ getDbClient()
67
+
68
+ expect(poolOptions()).toBe('-c search_path=preview_feat-my-branch')
69
+ expect(adapterSchema()).toEqual({ schema: 'preview_feat-my-branch' })
70
+ })
71
+
72
+ it('the middleware pool - the one the app uses - carries the schema too', async () => {
73
+ const { AcDatabaseManager } = await import('../middleware/database')
74
+
75
+ new AcDatabaseManager({
76
+ host: 'h',
77
+ port: 5432,
78
+ database: 'db',
79
+ username: 'u',
80
+ password: 'p',
81
+ schema: 'public',
82
+ }).getClient()
83
+
84
+ expect(poolOptions()).toBe('-c search_path=preview_feat-my-branch')
85
+ expect(adapterSchema()).toEqual({ schema: 'preview_feat-my-branch' })
86
+ })
87
+
88
+ it('the AI tools client reads the preview schema, not the shared catalog', async () => {
89
+ const { createAppCatalogAITools } =
90
+ await import('../modules/lighthouseKeeper/tools')
91
+
92
+ createAppCatalogAITools({
93
+ host: 'h',
94
+ port: 5432,
95
+ database: 'db',
96
+ username: 'u',
97
+ password: 'p',
98
+ schema: 'public',
99
+ })
100
+
101
+ expect(poolOptions()).toBe('-c search_path=preview_feat-my-branch')
102
+ expect(adapterSchema()).toEqual({ schema: 'preview_feat-my-branch' })
103
+ })
104
+
105
+ it('leaves the adapter schema unset when DB_SCHEMA is not configured', async () => {
106
+ delete process.env.DB_SCHEMA
107
+ process.env.AC_CORE_DATABASE_URL = 'postgresql://u:p@h:5432/db'
108
+ const { getDbClient } = await import('../db/client')
109
+
110
+ getDbClient()
111
+
112
+ expect(poolOptions()).toBeUndefined()
113
+ // Not toEqual({ schema: undefined }) - that also passes for {}, and an
114
+ // explicit undefined is what keeps the adapter on its own default.
115
+ expect(adapterSchema()).toHaveProperty('schema', undefined)
116
+ expect(adapterSchema()?.schema).toBeUndefined()
117
+ })
118
+ })
@@ -0,0 +1,81 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { registerIconRestController } from '../modules/icons/iconRestController'
3
+ import * as dbClient from '../db/client'
4
+ import type { PrismaClient } from '../generated/prisma/client'
5
+ import type { Request, Response, Router } from 'express'
6
+
7
+ const ICON = {
8
+ content: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'),
9
+ mimeType: 'image/svg+xml',
10
+ name: 'example-icon',
11
+ checksum: 'abc123',
12
+ }
13
+
14
+ /**
15
+ * Captures the `GET {basePath}/:name` handler the controller registers, so the
16
+ * test can drive it directly instead of standing up an HTTP server.
17
+ */
18
+ const captureBinaryHandler = () => {
19
+ let handler!: (req: Request, res: Response) => Promise<void>
20
+ const router = {
21
+ get: (path: string, fn: (req: Request, res: Response) => Promise<void>) => {
22
+ if (path === '/api/icons/:name') handler = fn
23
+ },
24
+ post: () => {},
25
+ } as unknown as Router
26
+
27
+ registerIconRestController(router, { basePath: '/api/icons' })
28
+ return handler
29
+ }
30
+
31
+ const fakeRes = () => {
32
+ const headers: Record<string, string> = {}
33
+ const res = {
34
+ setHeader: (k: string, v: string) => {
35
+ headers[k] = v
36
+ },
37
+ status: vi.fn().mockReturnThis(),
38
+ json: vi.fn().mockReturnThis(),
39
+ send: vi.fn().mockReturnThis(),
40
+ end: vi.fn().mockReturnThis(),
41
+ sendStatus: vi.fn().mockReturnThis(),
42
+ }
43
+ return { res: res as unknown as Response, headers, spy: res }
44
+ }
45
+
46
+ describe('icon binary caching', () => {
47
+ beforeEach(() => {
48
+ vi.spyOn(dbClient, 'getDbClient').mockReturnValue({
49
+ dbAsset: { findFirst: vi.fn().mockResolvedValue(ICON) },
50
+ } as unknown as PrismaClient)
51
+ })
52
+
53
+ it('requires revalidation so a replaced icon is not masked by a stale cache', async () => {
54
+ const handler = captureBinaryHandler()
55
+ const { res, headers } = fakeRes()
56
+
57
+ await handler(
58
+ { params: { name: 'example-icon' }, headers: {} } as unknown as Request,
59
+ res,
60
+ )
61
+
62
+ expect(headers['Cache-Control']).toBe('public, max-age=0, must-revalidate')
63
+ expect(headers['ETag']).toBe(`"${ICON.checksum}"`)
64
+ })
65
+
66
+ it('answers a matching If-None-Match with 304 and no body', async () => {
67
+ const handler = captureBinaryHandler()
68
+ const { res, spy } = fakeRes()
69
+
70
+ await handler(
71
+ {
72
+ params: { name: 'example-icon' },
73
+ headers: { 'if-none-match': `"${ICON.checksum}"` },
74
+ } as unknown as Request,
75
+ res,
76
+ )
77
+
78
+ expect(spy.status).toHaveBeenCalledWith(304)
79
+ expect(spy.send).not.toHaveBeenCalled()
80
+ })
81
+ })