@igstack/app-catalog-backend-core 0.15.0 → 0.17.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 (70) 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/db/syncAppCatalog.mjs +2 -1
  7. package/dist/db/syncAppCatalog.mjs.map +1 -1
  8. package/dist/generated/prisma/internal/class.mjs +4 -4
  9. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  10. package/dist/generated/prisma/internal/prismaNamespace.d.mts +1 -0
  11. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  12. package/dist/generated/prisma/models/DbResource.d.mts +43 -1
  13. package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +2 -2
  16. package/dist/middleware/database.d.mts +1 -0
  17. package/dist/middleware/database.d.mts.map +1 -1
  18. package/dist/middleware/database.mjs +10 -13
  19. package/dist/middleware/database.mjs.map +1 -1
  20. package/dist/modules/appCatalog/freshness.mjs +5 -3
  21. package/dist/modules/appCatalog/freshness.mjs.map +1 -1
  22. package/dist/modules/appCatalog/service.mjs +5 -1
  23. package/dist/modules/appCatalog/service.mjs.map +1 -1
  24. package/dist/modules/assets/assetCache.mjs +25 -0
  25. package/dist/modules/assets/assetCache.mjs.map +1 -0
  26. package/dist/modules/assets/assetRestController.d.mts.map +1 -1
  27. package/dist/modules/assets/assetRestController.mjs +17 -6
  28. package/dist/modules/assets/assetRestController.mjs.map +1 -1
  29. package/dist/modules/assets/assetUtils.mjs +1 -0
  30. package/dist/modules/assets/assetUtils.mjs.map +1 -1
  31. package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
  32. package/dist/modules/assets/screenshotRestController.mjs +7 -2
  33. package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
  34. package/dist/modules/assets/upsertAsset.mjs +7 -1
  35. package/dist/modules/assets/upsertAsset.mjs.map +1 -1
  36. package/dist/modules/icons/iconRestController.d.mts.map +1 -1
  37. package/dist/modules/icons/iconRestController.mjs +7 -2
  38. package/dist/modules/icons/iconRestController.mjs.map +1 -1
  39. package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
  40. package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
  41. package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
  42. package/dist/types/common/appCatalogTypes.d.mts +8 -0
  43. package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
  44. package/package.json +4 -4
  45. package/prisma/schema.prisma +5 -2
  46. package/src/__tests__/assetMime.test.ts +74 -0
  47. package/src/__tests__/dbSchema.test.ts +129 -0
  48. package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
  49. package/src/__tests__/freshness.test.ts +56 -17
  50. package/src/__tests__/iconCache.test.ts +81 -0
  51. package/src/db/client.ts +118 -12
  52. package/src/db/index.ts +10 -1
  53. package/src/db/syncAppCatalog.ts +3 -0
  54. package/src/generated/prisma/internal/class.ts +4 -4
  55. package/src/generated/prisma/internal/prismaNamespace.ts +1 -0
  56. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +1 -0
  57. package/src/generated/prisma/models/DbResource.ts +44 -1
  58. package/src/index.ts +4 -0
  59. package/src/middleware/database.ts +20 -19
  60. package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
  61. package/src/modules/appCatalog/freshness.ts +29 -11
  62. package/src/modules/appCatalog/service.ts +9 -4
  63. package/src/modules/assets/assetCache.ts +30 -0
  64. package/src/modules/assets/assetRestController.ts +25 -2
  65. package/src/modules/assets/assetUtils.ts +1 -0
  66. package/src/modules/assets/screenshotRestController.ts +13 -1
  67. package/src/modules/assets/upsertAsset.ts +8 -0
  68. package/src/modules/icons/iconRestController.ts +11 -1
  69. package/src/modules/lighthouseKeeper/tools.ts +6 -7
  70. package/src/types/common/appCatalogTypes.ts +8 -0
@@ -1 +1 @@
1
- {"version":3,"file":"assetRestController.mjs","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { getImageFormat, isRasterImage, resizeImage } from './assetUtils'\nimport { upsertAsset } from './upsertAsset'\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 AssetRestControllerConfig {\n /**\n * Base path for asset endpoints (e.g., '/api/assets')\n */\n basePath: string\n}\n\nexport interface ParseAssetParams {\n buffer: Buffer\n originalFilename: string\n fileSize?: number\n}\n\nexport interface ParseAssetReturn {\n checksum: string\n fileSize: number\n mimeType: string\n width?: number\n height?: number\n}\n\n/**\n * Registers REST endpoints for universal asset upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new asset (multipart/form-data)\n * - GET {basePath}/:id - Get asset binary by ID\n * - GET {basePath}/:id/metadata - Get asset metadata only\n * - GET {basePath}/by-name/:name - Get asset binary by name\n */\nexport function registerAssetRestController(\n router: Router,\n config: AssetRestControllerConfig,\n): void {\n const { basePath } = config\n const prisma = getDbClient()\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('asset'),\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 const name = req.body['name'] as string\n const assetType = req.body['assetType']\n\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n const id = await upsertAsset({\n prisma,\n buffer: req.file.buffer,\n name,\n originalFilename: req.file.filename,\n assetType,\n })\n\n res.status(201).json({ id })\n } catch (error) {\n console.error('Error uploading asset:', error)\n res.status(500).json({ error: 'Failed to upload asset' })\n }\n },\n )\n\n // Get asset binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const shouldResize =\n resizeEnabled &&\n isRasterImage(asset.mimeType) &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n if (shouldResize) {\n const fmt = getImageFormat(asset.mimeType) || 'jpeg'\n const buf = await resizeImage(\n Buffer.from(asset.content),\n width,\n undefined,\n fmt,\n )\n outBuffer = new Uint8Array(buf)\n outMime = `image/${fmt}`\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n })\n\n // Get asset 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 asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n assetType: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n res.json(asset)\n } catch (error) {\n console.error('Error fetching asset metadata:', error)\n res.status(500).json({ error: 'Failed to fetch asset metadata' })\n }\n },\n )\n\n // Get asset binary by name\n router.get(\n `${basePath}/by-name/:name`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { name },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const isRaster =\n asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')\n const shouldResize =\n resizeEnabled &&\n isRaster &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n if (shouldResize) {\n const fmt = asset.mimeType.includes('png')\n ? 'png'\n : asset.mimeType.includes('webp')\n ? 'webp'\n : 'jpeg'\n\n let buf: Buffer\n const pipeline = sharp(Buffer.from(asset.content)).resize({\n width,\n fit: 'inside',\n withoutEnlargement: true,\n })\n if (fmt === 'png') {\n buf = await pipeline.png().toBuffer()\n outMime = 'image/png'\n } else if (fmt === 'webp') {\n buf = await pipeline.webp().toBuffer()\n outMime = 'image/webp'\n } else {\n buf = await pipeline.jpeg().toBuffer()\n outMime = 'image/jpeg'\n }\n outBuffer = new Uint8Array(buf)\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset by name:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n },\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"assetRestController.mjs","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { getImageFormat, isRasterImage, resizeImage } from './assetUtils'\nimport { upsertAsset } from './upsertAsset'\nimport { isNotModified, setRevalidatingCacheHeaders } from './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 AssetRestControllerConfig {\n /**\n * Base path for asset endpoints (e.g., '/api/assets')\n */\n basePath: string\n}\n\nexport interface ParseAssetParams {\n buffer: Buffer\n originalFilename: string\n fileSize?: number\n}\n\nexport interface ParseAssetReturn {\n checksum: string\n fileSize: number\n mimeType: string\n width?: number\n height?: number\n}\n\n/**\n * Registers REST endpoints for universal asset upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new asset (multipart/form-data)\n * - GET {basePath}/:id - Get asset binary by ID\n * - GET {basePath}/:id/metadata - Get asset metadata only\n * - GET {basePath}/by-name/:name - Get asset binary by name\n */\nexport function registerAssetRestController(\n router: Router,\n config: AssetRestControllerConfig,\n): void {\n const { basePath } = config\n const prisma = getDbClient()\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('asset'),\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 const name = req.body['name'] as string\n const assetType = req.body['assetType']\n\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n const id = await upsertAsset({\n prisma,\n buffer: req.file.buffer,\n name,\n originalFilename: req.file.filename,\n assetType,\n })\n\n res.status(201).json({ id })\n } catch (error) {\n console.error('Error uploading asset:', error)\n res.status(500).json({ error: 'Failed to upload asset' })\n }\n },\n )\n\n // Get asset binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const shouldResize =\n resizeEnabled &&\n isRasterImage(asset.mimeType) &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = getImageFormat(asset.mimeType) || 'jpeg'\n const buf = await resizeImage(\n Buffer.from(asset.content),\n width,\n undefined,\n fmt,\n )\n outBuffer = new Uint8Array(buf)\n outMime = `image/${fmt}`\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n })\n\n // Get asset 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 asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n assetType: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n res.json(asset)\n } catch (error) {\n console.error('Error fetching asset metadata:', error)\n res.status(500).json({ error: 'Failed to fetch asset metadata' })\n }\n },\n )\n\n // Get asset binary by name\n router.get(\n `${basePath}/by-name/:name`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { name },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const isRaster =\n asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')\n const shouldResize =\n resizeEnabled &&\n isRaster &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = asset.mimeType.includes('png')\n ? 'png'\n : asset.mimeType.includes('webp')\n ? 'webp'\n : 'jpeg'\n\n let buf: Buffer\n const pipeline = sharp(Buffer.from(asset.content)).resize({\n width,\n fit: 'inside',\n withoutEnlargement: true,\n })\n if (fmt === 'png') {\n buf = await pipeline.png().toBuffer()\n outMime = 'image/png'\n } else if (fmt === 'webp') {\n buf = await pipeline.webp().toBuffer()\n outMime = 'image/webp'\n } else {\n buf = await pipeline.jpeg().toBuffer()\n outMime = 'image/jpeg'\n }\n outBuffer = new Uint8Array(buf)\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset by name:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n },\n )\n}\n"],"mappings":"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"}
@@ -76,6 +76,7 @@ async function parseAssetMeta(p) {
76
76
  tiff: "image/tiff",
77
77
  gif: "image/gif",
78
78
  heif: "image/heif",
79
+ svg: "image/svg+xml",
79
80
  raw: "application/octet-stream"
80
81
  }[format] ?? `image/${format}` : "application/octet-stream",
81
82
  fileSize: size || 0
@@ -1 +1 @@
1
- {"version":3,"file":"assetUtils.mjs","names":[],"sources":["../../../src/modules/assets/assetUtils.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport type { FormatEnum } from 'sharp'\nimport sharp from 'sharp'\nimport type { ParseAssetParams, ParseAssetReturn } from './assetRestController'\n\n/**\n * Extract image dimensions from a buffer using sharp\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width?: number; height?: number }> {\n try {\n const metadata = await sharp(buffer).metadata()\n return {\n width: metadata.width,\n height: metadata.height,\n }\n } catch (error) {\n console.error('Error extracting image dimensions:', error)\n return { width: undefined, height: undefined }\n }\n}\n\n/**\n * Resize an image buffer to the specified dimensions\n * @param buffer - The image buffer to resize\n * @param width - Target width (optional)\n * @param height - Target height (optional)\n * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided\n */\nexport async function resizeImage(\n buffer: Buffer,\n width?: number,\n height?: number,\n format?: 'png' | 'jpeg' | 'webp',\n): Promise<Buffer> {\n let pipeline = sharp(buffer)\n\n // Apply resize if dimensions provided\n if (width || height) {\n pipeline = pipeline.resize({\n width,\n height,\n fit: 'inside',\n withoutEnlargement: true,\n })\n }\n\n // Apply format conversion if specified\n if (format === 'png') {\n pipeline = pipeline.png()\n } else if (format === 'webp') {\n pipeline = pipeline.webp()\n } else if (format === 'jpeg') {\n pipeline = pipeline.jpeg()\n }\n\n return pipeline.toBuffer()\n}\n\n/**\n * Generate SHA-256 checksum for a buffer\n */\nexport function generateChecksum(buffer: Buffer): string {\n return createHash('sha256').update(buffer).digest('hex')\n}\n\n/**\n * Detect image format from mime type\n */\nexport function getImageFormat(\n mimeType: string,\n): 'png' | 'webp' | 'jpeg' | null {\n if (mimeType.includes('png')) return 'png'\n if (mimeType.includes('webp')) return 'webp'\n if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'\n return null\n}\n\n/**\n * Check if a mime type represents a raster image (not SVG)\n */\nexport function isRasterImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !mimeType.includes('svg')\n}\n\nexport async function parseAssetMeta(\n p: ParseAssetParams,\n): Promise<ParseAssetReturn> {\n // Get image dimensions using our utility\n const { width, height, format, size } = await sharp(p.buffer).metadata()\n\n const formatToMime: Partial<Record<keyof FormatEnum, string>> = {\n jpeg: 'image/jpeg',\n jpg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n avif: 'image/avif',\n tiff: 'image/tiff',\n gif: 'image/gif',\n heif: 'image/heif',\n raw: 'application/octet-stream',\n }\n\n return {\n checksum: generateChecksum(p.buffer),\n width,\n height,\n mimeType: format\n ? (formatToMime[format] ?? `image/${format}`)\n : 'application/octet-stream',\n fileSize: size || 0,\n }\n}\n"],"mappings":";;;;;;;AAQA,eAAsB,mBACpB,QAC8C;AAC9C,KAAI;EACF,MAAM,WAAW,MAAM,MAAM,OAAO,CAAC,UAAU;AAC/C,SAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GAClB;UACM,OAAO;AACd,UAAQ,MAAM,sCAAsC,MAAM;AAC1D,SAAO;GAAE,OAAO;GAAW,QAAQ;GAAW;;;;;;;;;;AAWlD,eAAsB,YACpB,QACA,OACA,QACA,QACiB;CACjB,IAAI,WAAW,MAAM,OAAO;AAG5B,KAAI,SAAS,OACX,YAAW,SAAS,OAAO;EACzB;EACA;EACA,KAAK;EACL,oBAAoB;EACrB,CAAC;AAIJ,KAAI,WAAW,MACb,YAAW,SAAS,KAAK;UAChB,WAAW,OACpB,YAAW,SAAS,MAAM;UACjB,WAAW,OACpB,YAAW,SAAS,MAAM;AAG5B,QAAO,SAAS,UAAU;;;;;AAM5B,SAAgB,iBAAiB,QAAwB;AACvD,QAAO,WAAW,SAAS,CAAC,OAAO,OAAO,CAAC,OAAO,MAAM;;;;;AAM1D,SAAgB,eACd,UACgC;AAChC,KAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AACrC,KAAI,SAAS,SAAS,OAAO,CAAE,QAAO;AACtC,KAAI,SAAS,SAAS,OAAO,IAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AAClE,QAAO;;;;;AAMT,SAAgB,cAAc,UAA2B;AACvD,QAAO,SAAS,WAAW,SAAS,IAAI,CAAC,SAAS,SAAS,MAAM;;AAGnE,eAAsB,eACpB,GAC2B;CAE3B,MAAM,EAAE,OAAO,QAAQ,QAAQ,SAAS,MAAM,MAAM,EAAE,OAAO,CAAC,UAAU;AAcxE,QAAO;EACL,UAAU,iBAAiB,EAAE,OAAO;EACpC;EACA;EACA,UAAU,SAhBoD;GAC9D,MAAM;GACN,KAAK;GACL,KAAK;GACL,MAAM;GACN,MAAM;GACN,MAAM;GACN,KAAK;GACL,MAAM;GACN,KAAK;GACN,CAOmB,WAAW,SAAS,WAClC;EACJ,UAAU,QAAQ;EACnB"}
1
+ {"version":3,"file":"assetUtils.mjs","names":[],"sources":["../../../src/modules/assets/assetUtils.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport type { FormatEnum } from 'sharp'\nimport sharp from 'sharp'\nimport type { ParseAssetParams, ParseAssetReturn } from './assetRestController'\n\n/**\n * Extract image dimensions from a buffer using sharp\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width?: number; height?: number }> {\n try {\n const metadata = await sharp(buffer).metadata()\n return {\n width: metadata.width,\n height: metadata.height,\n }\n } catch (error) {\n console.error('Error extracting image dimensions:', error)\n return { width: undefined, height: undefined }\n }\n}\n\n/**\n * Resize an image buffer to the specified dimensions\n * @param buffer - The image buffer to resize\n * @param width - Target width (optional)\n * @param height - Target height (optional)\n * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided\n */\nexport async function resizeImage(\n buffer: Buffer,\n width?: number,\n height?: number,\n format?: 'png' | 'jpeg' | 'webp',\n): Promise<Buffer> {\n let pipeline = sharp(buffer)\n\n // Apply resize if dimensions provided\n if (width || height) {\n pipeline = pipeline.resize({\n width,\n height,\n fit: 'inside',\n withoutEnlargement: true,\n })\n }\n\n // Apply format conversion if specified\n if (format === 'png') {\n pipeline = pipeline.png()\n } else if (format === 'webp') {\n pipeline = pipeline.webp()\n } else if (format === 'jpeg') {\n pipeline = pipeline.jpeg()\n }\n\n return pipeline.toBuffer()\n}\n\n/**\n * Generate SHA-256 checksum for a buffer\n */\nexport function generateChecksum(buffer: Buffer): string {\n return createHash('sha256').update(buffer).digest('hex')\n}\n\n/**\n * Detect image format from mime type\n */\nexport function getImageFormat(\n mimeType: string,\n): 'png' | 'webp' | 'jpeg' | null {\n if (mimeType.includes('png')) return 'png'\n if (mimeType.includes('webp')) return 'webp'\n if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'\n return null\n}\n\n/**\n * Check if a mime type represents a raster image (not SVG)\n */\nexport function isRasterImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !mimeType.includes('svg')\n}\n\nexport async function parseAssetMeta(\n p: ParseAssetParams,\n): Promise<ParseAssetReturn> {\n // Get image dimensions using our utility\n const { width, height, format, size } = await sharp(p.buffer).metadata()\n\n const formatToMime: Partial<Record<keyof FormatEnum, string>> = {\n jpeg: 'image/jpeg',\n jpg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n avif: 'image/avif',\n tiff: 'image/tiff',\n gif: 'image/gif',\n heif: 'image/heif',\n svg: 'image/svg+xml',\n raw: 'application/octet-stream',\n }\n\n return {\n checksum: generateChecksum(p.buffer),\n width,\n height,\n mimeType: format\n ? (formatToMime[format] ?? `image/${format}`)\n : 'application/octet-stream',\n fileSize: size || 0,\n }\n}\n"],"mappings":";;;;;;;AAQA,eAAsB,mBACpB,QAC8C;AAC9C,KAAI;EACF,MAAM,WAAW,MAAM,MAAM,OAAO,CAAC,UAAU;AAC/C,SAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GAClB;UACM,OAAO;AACd,UAAQ,MAAM,sCAAsC,MAAM;AAC1D,SAAO;GAAE,OAAO;GAAW,QAAQ;GAAW;;;;;;;;;;AAWlD,eAAsB,YACpB,QACA,OACA,QACA,QACiB;CACjB,IAAI,WAAW,MAAM,OAAO;AAG5B,KAAI,SAAS,OACX,YAAW,SAAS,OAAO;EACzB;EACA;EACA,KAAK;EACL,oBAAoB;EACrB,CAAC;AAIJ,KAAI,WAAW,MACb,YAAW,SAAS,KAAK;UAChB,WAAW,OACpB,YAAW,SAAS,MAAM;UACjB,WAAW,OACpB,YAAW,SAAS,MAAM;AAG5B,QAAO,SAAS,UAAU;;;;;AAM5B,SAAgB,iBAAiB,QAAwB;AACvD,QAAO,WAAW,SAAS,CAAC,OAAO,OAAO,CAAC,OAAO,MAAM;;;;;AAM1D,SAAgB,eACd,UACgC;AAChC,KAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AACrC,KAAI,SAAS,SAAS,OAAO,CAAE,QAAO;AACtC,KAAI,SAAS,SAAS,OAAO,IAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AAClE,QAAO;;;;;AAMT,SAAgB,cAAc,UAA2B;AACvD,QAAO,SAAS,WAAW,SAAS,IAAI,CAAC,SAAS,SAAS,MAAM;;AAGnE,eAAsB,eACpB,GAC2B;CAE3B,MAAM,EAAE,OAAO,QAAQ,QAAQ,SAAS,MAAM,MAAM,EAAE,OAAO,CAAC,UAAU;AAexE,QAAO;EACL,UAAU,iBAAiB,EAAE,OAAO;EACpC;EACA;EACA,UAAU,SAjBoD;GAC9D,MAAM;GACN,KAAK;GACL,KAAK;GACL,MAAM;GACN,MAAM;GACN,MAAM;GACN,KAAK;GACL,MAAM;GACN,KAAK;GACL,KAAK;GACN,CAOmB,WAAW,SAAS,WAClC;EACJ,UAAU,QAAQ;EACnB"}
@@ -1 +1 @@
1
- {"version":3,"file":"screenshotRestController.d.mts","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"mappings":";;;UAIiB,8BAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,gCAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,8BAAA"}
1
+ {"version":3,"file":"screenshotRestController.d.mts","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"mappings":";;;UAKiB,8BAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,gCAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,8BAAA"}
@@ -1,4 +1,5 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
+ import { isNotModified, setRevalidatingCacheHeaders } from "./assetCache.mjs";
2
3
  import sharp from "sharp";
3
4
 
4
5
  //#region src/modules/assets/screenshotRestController.ts
@@ -90,13 +91,18 @@ function registerScreenshotRestController(router, config) {
90
91
  select: {
91
92
  content: true,
92
93
  mimeType: true,
93
- name: true
94
+ name: true,
95
+ checksum: true
94
96
  }
95
97
  });
96
98
  if (!screenshot) {
97
99
  res.status(404).json({ error: "Screenshot not found" });
98
100
  return;
99
101
  }
102
+ if (isNotModified(req, setRevalidatingCacheHeaders(res, screenshot.checksum, targetSize && targetSize > 0 ? `s${targetSize}` : void 0))) {
103
+ res.status(304).end();
104
+ return;
105
+ }
100
106
  let content = screenshot.content;
101
107
  if (targetSize && targetSize > 0) try {
102
108
  content = await sharp(screenshot.content).resize(targetSize, targetSize, {
@@ -108,7 +114,6 @@ function registerScreenshotRestController(router, config) {
108
114
  }
109
115
  res.setHeader("Content-Type", screenshot.mimeType);
110
116
  res.setHeader("Content-Disposition", `inline; filename="${screenshot.name}"`);
111
- res.setHeader("Cache-Control", "public, max-age=86400");
112
117
  res.send(content);
113
118
  } catch (error) {
114
119
  console.error("Error fetching screenshot:", error);
@@ -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"}
@@ -29,6 +29,13 @@ interface SourceReference {
29
29
  interface Freshness {
30
30
  /** ISO-8601 timestamp the app's sources were last verified, or null if never. */
31
31
  lastCheckedAt: string | null;
32
+ /**
33
+ * ISO-8601 timestamp the source content last actually CHANGED — what the UI
34
+ * shows as "Updated". Distinct from `lastCheckedAt`: a source can be re-read
35
+ * repeatedly without changing. Null for entries scanned before this was
36
+ * tracked, in which case the frontend falls back to `lastCheckedAt`.
37
+ */
38
+ lastContentChangeAt: string | null;
32
39
  /** True when the entry is overdue for a re-check by more than the grace period. */
33
40
  isStale: boolean;
34
41
  }
@@ -98,6 +105,7 @@ interface Resource {
98
105
  */
99
106
  lastCheckedAt?: string | null;
100
107
  nextCheckAfter?: string | null;
108
+ lastContentChangeAt?: string | null;
101
109
  /** ISO-8601 timestamp of when this entry was first created in the catalog DB. */
102
110
  createdAt?: string;
103
111
  }
@@ -1 +1 @@
1
- {"version":3,"file":"appCatalogTypes.d.mts","names":[],"sources":["../../../src/types/common/appCatalogTypes.ts"],"mappings":";;;;;;;;UAmBiB,WAAA;EACf,QAAA;EACA,WAAA;EACA,WAAA;EACA,MAAA;EACA,aAAA,GAAgB,gBAAA;AAAA;;;;;UAWD,eAAA;EACf,UAAA;EACA,GAAA;EACA,SAAA;AAAA;;AAkBF;;;UAXiB,SAAA;EA4BM;EA1BrB,aAAA;EA0DQ;EAxDR,OAAA;AAAA;;;;;UAOe,QAAA;EACf,EAAA;EACA,IAAA;EAMA;EAJA,IAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA;EACA,WAAA;EACA,KAAA;EACA,aAAA,GAAgB,gBAAA;EAChB,KAAA;EACA,IAAA;EACA,MAAA;EACA,KAAA;IAAU,GAAA;IAAa,KAAA;EAAA;EACvB,QAAA;EACA,aAAA;EACA,OAAA,cAAqB,eAAA;EACrB,UAAA;IAWA,0HATE,IAAA,iCAWM;IATN,eAAA,WAeF;IAbE,OAAA;EAAA;EAmBF;EAhBA,QAAA;EAoBA;EAlBA,SAAA;EAoBQ;EAlBR,KAAA,GAAQ,WAAA;EAyBI;EArBZ,UAAA;EA4BA;EA1BA,IAAA;EA4BS;EA1BT,UAAA;EA8Be;EA5Bf,OAAA;;EAEA,eAAA;EA4BI;EA1BJ,0BAAA;EA6BoC;EA3BpC,cAAA;EA+BwB;EA7BxB,KAAA,GAAQ,MAAA;EA2BR;;;;;;EApBA,SAAA,GAAY,SAAA;EAyBO;;;;;EAnBnB,aAAA;EACA,cAAA;EAmBM;EAjBN,SAAA;AAAA;AAAA,UAIe,WAAA;EACf,EAAA;EACA,IAAA;AAAA;AAAA,UAGe,qBAAA;EACf,MAAA;EACA,WAAA;EACA,WAAA;EACA,MAAA,EAAQ,gBAAA;AAAA;AAAA,KAGL,gBAAA,8BAA8C,CAAA,eAC/C,IAAA,CAAK,CAAA,EAAG,IAAA;AAAA,KAGA,iBAAA,GAAoB,gBAAA,CAC9B,cAAA;AAAA,UAIe,gBAAA;EACf,KAAA;EACA,WAAA;EACA,WAAA;AAAA;AAAA,UAGe,WAAA;EACf,WAAA;EACA,GAAA;EAFe;EAIf,GAAA;;EAEA,MAAA;AAAA;AAAA,UAGe,cAAA;EACf,OAAA,GAAU,WAAA;EACV,QAAA,GAAW,WAAA;EACX,WAAA,GAAc,WAAA;AAAA;AAAA,UAGC,cAAA;EACf,SAAA,EAAW,QAAA;EACX,eAAA,EAAiB,qBAAA;EACjB,eAAA,EAAiB,iBAAA;EACjB,OAAA,EAAS,MAAA;EACT,MAAA,EAAQ,KAAA;EACR,QAAA,GAAW,cAAA;AAAA"}
1
+ {"version":3,"file":"appCatalogTypes.d.mts","names":[],"sources":["../../../src/types/common/appCatalogTypes.ts"],"mappings":";;;;;;;;UAmBiB,WAAA;EACf,QAAA;EACA,WAAA;EACA,WAAA;EACA,MAAA;EACA,aAAA,GAAgB,gBAAA;AAAA;;;;;UAWD,eAAA;EACf,UAAA;EACA,GAAA;EACA,SAAA;AAAA;;;;;UAOe,SAAA;EAkBA;EAhBf,aAAA;;;;;;;EAOA,mBAAA;EAiEqB;EA/DrB,OAAA;AAAA;;;;;UAOe,QAAA;EACf,EAAA;EACA,IAAA;EAQgB;EANhB,IAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA;EACA,WAAA;EACA,KAAA;EACA,aAAA,GAAgB,gBAAA;EAChB,KAAA;EACA,IAAA;EACA,MAAA;EACA,KAAA;IAAU,GAAA;IAAa,KAAA;EAAA;EACvB,QAAA;EACA,aAAA;EACA,OAAA,cAAqB,eAAA;EACrB,UAAA;IAiBA,0HAfE,IAAA,iCAmBF;IAjBE,eAAA,WAqBF;IAnBE,OAAA;EAAA;EAyBF;EAtBA,QAAA;EA6BA;EA3BA,SAAA;EAiCA;EA/BA,KAAA,GAAQ,WAAA;EAiCR;EA7BA,UAAA;EA+BS;EA7BT,IAAA;EAiCe;EA/Bf,UAAA;;EAEA,OAAA;EA+BI;EA7BJ,eAAA;EAgCoC;EA9BpC,0BAAA;EAkCwB;EAhCxB,cAAA;EA8BA;EA5BA,KAAA,GAAQ,MAAA;EA8BR;;;;AACD;;EAxBC,SAAA,GAAY,SAAA;EA0BqC;;;;;EApBjD,aAAA;EACA,cAAA;EACA,mBAAA;EAkBiD;EAhBjD,SAAA;AAAA;AAAA,UAIe,WAAA;EACf,EAAA;EACA,IAAA;AAAA;AAAA,UAGe,qBAAA;EACf,MAAA;EACA,WAAA;EACA,WAAA;EACA,MAAA,EAAQ,gBAAA;AAAA;AAAA,KAGL,gBAAA,8BAA8C,CAAA,eAC/C,IAAA,CAAK,CAAA,EAAG,IAAA;AAAA,KAGA,iBAAA,GAAoB,gBAAA,CAC9B,cAAA;AAAA,UAIe,gBAAA;EACf,KAAA;EACA,WAAA;EACA,WAAA;AAAA;AAAA,UAGe,WAAA;EACf,WAAA;EACA,GAAA;EAF0B;EAI1B,GAAA;EAFA;EAIA,MAAA;AAAA;AAAA,UAGe,cAAA;EACf,OAAA,GAAU,WAAA;EACV,QAAA,GAAW,WAAA;EACX,WAAA,GAAc,WAAA;AAAA;AAAA,UAGC,cAAA;EACf,SAAA,EAAW,QAAA;EACX,eAAA,EAAiB,qBAAA;EACjB,eAAA,EAAiB,iBAAA;EACjB,OAAA,EAAS,MAAA;EACT,MAAA,EAAQ,KAAA;EACR,QAAA,GAAW,cAAA;AAAA"}
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.17.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-table-sync": "0.17.0",
49
+ "@igstack/app-catalog-shared-core": "0.17.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": "c7c8e9105727375dce326320db3259b9ef2f9ceb",
77
77
  "scripts": {
78
78
  "build": "tsdown",
79
79
  "build:lenient": "tsdown",
@@ -164,10 +164,13 @@ model DbResource {
164
164
  accessComments String?
165
165
  /// [SubResourceExtra]
166
166
  extra Json?
167
- // Freshness (autoupdate scan): when sources were last verified and when the
168
- // next check is due. Nullable apps that have never been scanned carry null.
167
+ // Freshness (autoupdate scan): when sources were last verified, when the next
168
+ // check is due, and when the source content last actually changed (what the UI
169
+ // shows as "Updated" — a source can be re-read without changing).
170
+ // Nullable — apps that have never been scanned carry null.
169
171
  lastCheckedAt DateTime?
170
172
  nextCheckAfter DateTime?
173
+ lastContentChangeAt DateTime?
171
174
  createdAt DateTime @default(now())
172
175
  updatedAt DateTime @updatedAt
173
176
 
@@ -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
+ })