From 7b43a0212641b06a38c7c830318c1c2ff05643c4 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 23:47:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(cdn):=20add=20CDN=20asset=20pipeline=20mod?= =?UTF-8?q?ule=20=E2=80=94=20upload,=20purge,=20origin=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: CdnAsset, PurgeRequest, OriginConfig + 3 Zod schemas - repository.ts: asset CRUD, purge request tracking, origin config management - routes.ts: 9 endpoints (asset upload/list/get/delete, purge, origin config CRUD) - cdn.test.ts: 15 schema validation tests - Supports categories (image/video/font/script/style/document/other) - SHA-256 content hash tracking, size limits, TTL-based purge requests - Cosmos containers: cdn_assets, cdn_purge_requests, cdn_origin_configs --- .../src/modules/cdn/cdn.test.ts | 147 ++++++++++++ .../src/modules/cdn/repository.ts | 206 ++++++++++++++++ .../src/modules/cdn/routes.ts | 224 ++++++++++++++++++ .../platform-service/src/modules/cdn/types.ts | 115 +++++++++ 4 files changed, 692 insertions(+) create mode 100644 services/platform-service/src/modules/cdn/cdn.test.ts create mode 100644 services/platform-service/src/modules/cdn/repository.ts create mode 100644 services/platform-service/src/modules/cdn/routes.ts create mode 100644 services/platform-service/src/modules/cdn/types.ts diff --git a/services/platform-service/src/modules/cdn/cdn.test.ts b/services/platform-service/src/modules/cdn/cdn.test.ts new file mode 100644 index 00000000..0bd1ec2c --- /dev/null +++ b/services/platform-service/src/modules/cdn/cdn.test.ts @@ -0,0 +1,147 @@ +/** + * CDN Pipeline module — unit tests. + */ + +import { describe, it, expect } from 'vitest'; +import { UploadAssetSchema, PurgeRequestSchema, UpdateOriginConfigSchema } from './types.js'; + +describe('UploadAssetSchema', () => { + it('validates minimal asset', () => { + const result = UploadAssetSchema.safeParse({ + originalName: 'hero.png', + mimeType: 'image/png', + sizeBytes: 1024, + contentHash: 'abc123def456', + blobPath: 'cdn-assets/hero.png', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.category).toBe('other'); + expect(result.data.isPublic).toBe(true); + } + }); + + it('validates with all fields', () => { + const result = UploadAssetSchema.safeParse({ + originalName: 'logo.svg', + mimeType: 'image/svg+xml', + sizeBytes: 2048, + contentHash: 'deadbeef01234567', + blobPath: 'cdn-assets/logo.svg', + category: 'image', + isPublic: false, + metadata: { width: 200, height: 200 }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.category).toBe('image'); + expect(result.data.isPublic).toBe(false); + } + }); + + it('rejects empty originalName', () => { + expect( + UploadAssetSchema.safeParse({ + originalName: '', + mimeType: 'image/png', + sizeBytes: 1024, + contentHash: 'abc123de', + blobPath: 'test.png', + }).success + ).toBe(false); + }); + + it('rejects oversized file', () => { + expect( + UploadAssetSchema.safeParse({ + originalName: 'big.bin', + mimeType: 'application/octet-stream', + sizeBytes: 200 * 1024 * 1024, // 200 MB + contentHash: 'abc123de', + blobPath: 'big.bin', + }).success + ).toBe(false); + }); + + it('rejects invalid category', () => { + expect( + UploadAssetSchema.safeParse({ + originalName: 'file.txt', + mimeType: 'text/plain', + sizeBytes: 100, + contentHash: 'abc123de', + blobPath: 'file.txt', + category: 'invalid_category', + }).success + ).toBe(false); + }); + + it('rejects short contentHash', () => { + expect( + UploadAssetSchema.safeParse({ + originalName: 'file.txt', + mimeType: 'text/plain', + sizeBytes: 100, + contentHash: 'abc', // too short + blobPath: 'file.txt', + }).success + ).toBe(false); + }); +}); + +describe('PurgeRequestSchema', () => { + it('validates single pattern', () => { + const result = PurgeRequestSchema.safeParse({ patterns: ['/assets/img/*'] }); + expect(result.success).toBe(true); + }); + + it('validates multiple patterns', () => { + const result = PurgeRequestSchema.safeParse({ + patterns: ['/assets/img/*', '/assets/css/*', '/favicon.ico'], + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.patterns).toHaveLength(3); + }); + + it('rejects empty patterns array', () => { + expect(PurgeRequestSchema.safeParse({ patterns: [] }).success).toBe(false); + }); + + it('rejects too many patterns', () => { + const patterns = Array.from({ length: 51 }, (_, i) => `/path${i}`); + expect(PurgeRequestSchema.safeParse({ patterns }).success).toBe(false); + }); +}); + +describe('UpdateOriginConfigSchema', () => { + it('validates partial update', () => { + const result = UpdateOriginConfigSchema.safeParse({ + defaultCacheTtlSeconds: 3600, + brotliEnabled: false, + }); + expect(result.success).toBe(true); + }); + + it('validates CORS origins', () => { + const result = UpdateOriginConfigSchema.safeParse({ + corsOrigins: ['https://app.example.com', '*'], + }); + expect(result.success).toBe(true); + }); + + it('validates category TTL overrides', () => { + const result = UpdateOriginConfigSchema.safeParse({ + categoryTtlOverrides: { image: 604800, font: 2592000 }, + }); + expect(result.success).toBe(true); + }); + + it('rejects negative cache TTL', () => { + expect(UpdateOriginConfigSchema.safeParse({ defaultCacheTtlSeconds: -1 }).success).toBe(false); + }); + + it('validates null customDomain (to clear)', () => { + const result = UpdateOriginConfigSchema.safeParse({ customDomain: null }); + expect(result.success).toBe(true); + }); +}); diff --git a/services/platform-service/src/modules/cdn/repository.ts b/services/platform-service/src/modules/cdn/repository.ts new file mode 100644 index 00000000..b243e434 --- /dev/null +++ b/services/platform-service/src/modules/cdn/repository.ts @@ -0,0 +1,206 @@ +/** + * CDN Pipeline repository — Cosmos DB CRUD for assets, purge requests, and origin config. + * @module cdn/repository + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { + CdnAssetDoc, + CdnPurgeRequestDoc, + CdnOriginConfigDoc, + AssetCategory, +} from './types.js'; + +// ============================================================================= +// Assets +// ============================================================================= + +export async function createAsset(doc: CdnAssetDoc): Promise { + const container = getContainer('cdn_assets'); + const { resource } = await container.items.create(doc); + if (!resource) throw new Error('Failed to create CDN asset'); + return resource as unknown as CdnAssetDoc; +} + +export async function getAsset(id: string, productId: string): Promise { + const container = getContainer('cdn_assets'); + try { + const { resource } = await container.item(id, productId).read(); + return resource as unknown as CdnAssetDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function listAssets( + productId: string, + options?: { category?: AssetCategory; limit?: number; offset?: number } +): Promise<{ assets: CdnAssetDoc[]; total: number }> { + const container = getContainer('cdn_assets'); + + let query = 'SELECT * FROM c WHERE c.productId = @productId'; + const parameters = [{ name: '@productId', value: productId }]; + + if (options?.category) { + query += ' AND c.category = @category'; + parameters.push({ name: '@category', value: options.category }); + } + + query += ' ORDER BY c.createdAt DESC'; + + const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)'); + const { resources: countResult } = await container.items + .query({ query: countQuery, parameters }) + .fetchAll(); + const total = countResult[0] ?? 0; + + if (options?.limit) { + query += ` OFFSET ${options.offset ?? 0} LIMIT ${options.limit}`; + } + + const { resources } = await container.items.query({ query, parameters }).fetchAll(); + + return { assets: resources, total }; +} + +export async function deleteAsset(id: string, productId: string): Promise { + const container = getContainer('cdn_assets'); + try { + await container.item(id, productId).delete(); + return true; + } catch (err) { + if ((err as { code?: number }).code === 404) return false; + throw err; + } +} + +export async function getAssetByHash( + productId: string, + contentHash: string +): Promise { + const container = getContainer('cdn_assets'); + const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.contentHash = @hash'; + const parameters = [ + { name: '@productId', value: productId }, + { name: '@hash', value: contentHash }, + ]; + + const { resources } = await container.items.query({ query, parameters }).fetchAll(); + + return resources[0] ?? null; +} + +export async function getStorageStats(productId: string): Promise<{ + totalAssets: number; + totalSizeBytes: number; + byCategory: Record; +}> { + const container = getContainer('cdn_assets'); + const query = 'SELECT c.category, c.sizeBytes FROM c WHERE c.productId = @productId'; + const parameters = [{ name: '@productId', value: productId }]; + + const { resources } = await container.items + .query<{ category: string; sizeBytes: number }>({ query, parameters }) + .fetchAll(); + + const byCategory: Record = {}; + let totalSizeBytes = 0; + + for (const r of resources) { + totalSizeBytes += r.sizeBytes; + if (!byCategory[r.category]) { + byCategory[r.category] = { count: 0, sizeBytes: 0 }; + } + byCategory[r.category].count++; + byCategory[r.category].sizeBytes += r.sizeBytes; + } + + return { totalAssets: resources.length, totalSizeBytes, byCategory }; +} + +// ============================================================================= +// Purge Requests +// ============================================================================= + +export async function createPurgeRequest(doc: CdnPurgeRequestDoc): Promise { + const container = getContainer('cdn_purge_requests'); + const { resource } = await container.items.create(doc); + if (!resource) throw new Error('Failed to create purge request'); + return resource as unknown as CdnPurgeRequestDoc; +} + +export async function getPurgeRequest( + id: string, + productId: string +): Promise { + const container = getContainer('cdn_purge_requests'); + try { + const { resource } = await container.item(id, productId).read(); + return resource as unknown as CdnPurgeRequestDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function updatePurgeStatus( + id: string, + productId: string, + status: CdnPurgeRequestDoc['status'], + purgedCount?: number, + error?: string +): Promise { + const existing = await getPurgeRequest(id, productId); + if (!existing) return null; + + const container = getContainer('cdn_purge_requests'); + const updated: CdnPurgeRequestDoc = { + ...existing, + status, + purgedCount: purgedCount ?? existing.purgedCount, + completedAt: status === 'completed' || status === 'failed' ? new Date().toISOString() : null, + error: error ?? null, + }; + + const { resource } = await container.items.upsert(updated); + return resource as unknown as CdnPurgeRequestDoc; +} + +export async function listPurgeRequests( + productId: string, + limit = 20 +): Promise { + const container = getContainer('cdn_purge_requests'); + const safeLimit = Math.min(Math.max(limit, 1), 100); + const query = `SELECT TOP ${safeLimit} * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC`; + const parameters = [{ name: '@productId', value: productId }]; + + const { resources } = await container.items + .query({ query, parameters }) + .fetchAll(); + + return resources; +} + +// ============================================================================= +// Origin Config (singleton per product) +// ============================================================================= + +export async function getOriginConfig(productId: string): Promise { + const container = getContainer('cdn_origin_configs'); + try { + const { resource } = await container.item(productId, productId).read(); + return resource as unknown as CdnOriginConfigDoc | null; + } catch (err) { + if ((err as { code?: number }).code === 404) return null; + throw err; + } +} + +export async function upsertOriginConfig(doc: CdnOriginConfigDoc): Promise { + const container = getContainer('cdn_origin_configs'); + const { resource } = await container.items.upsert(doc); + if (!resource) throw new Error('Failed to upsert origin config'); + return resource as unknown as CdnOriginConfigDoc; +} diff --git a/services/platform-service/src/modules/cdn/routes.ts b/services/platform-service/src/modules/cdn/routes.ts new file mode 100644 index 00000000..17bd50ce --- /dev/null +++ b/services/platform-service/src/modules/cdn/routes.ts @@ -0,0 +1,224 @@ +/** + * CDN Pipeline routes — admin asset management, purge, and origin config. + * @module cdn/routes + */ + +import type { FastifyInstance } from 'fastify'; +import { + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, +} from '../../lib/errors.js'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { + UploadAssetSchema, + PurgeRequestSchema, + UpdateOriginConfigSchema, + type CdnAssetDoc, + type CdnPurgeRequestDoc, + type CdnOriginConfigDoc, + type AssetCategory, +} from './types.js'; +import * as repo from './repository.js'; + +function requireAuth(req: { jwtPayload?: { sub: string } }): string { + if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required'); + return req.jwtPayload.sub; +} + +function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string { + const userId = requireAuth(req); + if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required'); + return userId; +} + +export async function cdnRoutes(app: FastifyInstance): Promise { + // ── Register asset (after blob upload) ───────────────────── + app.post('/cdn/assets', async (req, reply) => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const input = UploadAssetSchema.parse(req.body); + + // Dedup by content hash + const existing = await repo.getAssetByHash(productId, input.contentHash); + if (existing) { + throw new ConflictError( + `Asset with hash ${input.contentHash} already exists: ${existing.id}` + ); + } + + const now = new Date().toISOString(); + const cdnBase = process.env.CDN_BASE_URL ?? 'https://cdn.bytelyst.com'; + const doc: CdnAssetDoc = { + id: `asset_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + productId, + originalName: input.originalName, + blobPath: input.blobPath, + cdnUrl: `${cdnBase}/${productId}/${input.contentHash}/${input.originalName}`, + contentHash: input.contentHash, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + category: input.category, + metadata: input.metadata, + uploadedBy: userId, + isPublic: input.isPublic, + createdAt: now, + updatedAt: now, + }; + + const created = await repo.createAsset(doc); + req.log.info({ assetId: created.id, userId }, 'CDN asset registered'); + reply.status(201); + return created; + }); + + // ── List assets ──────────────────────────────────────────── + app.get('/cdn/assets', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { + category, + limit: limitStr, + offset: offsetStr, + } = req.query as { + category?: string; + limit?: string; + offset?: string; + }; + + const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50; + const parsedOffset = offsetStr ? parseInt(offsetStr, 10) : 0; + const safeLimit = + Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50; + const safeOffset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; + + return repo.listAssets(productId, { + category: category as AssetCategory | undefined, + limit: safeLimit, + offset: safeOffset, + }); + }); + + // ── Get single asset ─────────────────────────────────────── + app.get<{ Params: { id: string } }>('/cdn/assets/:id', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + const asset = await repo.getAsset(id, productId); + if (!asset) throw new NotFoundError('CDN asset not found'); + return asset; + }); + + // ── Delete asset ─────────────────────────────────────────── + app.delete<{ Params: { id: string } }>('/cdn/assets/:id', async (req, reply) => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const { id } = req.params; + + const deleted = await repo.deleteAsset(id, productId); + if (!deleted) throw new NotFoundError('CDN asset not found'); + + req.log.info({ assetId: id, userId }, 'CDN asset deleted'); + reply.status(204); + return; + }); + + // ── Storage stats ────────────────────────────────────────── + app.get('/cdn/stats', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + return repo.getStorageStats(productId); + }); + + // ── Purge request ────────────────────────────────────────── + app.post('/cdn/purge', async (req, reply) => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const input = PurgeRequestSchema.parse(req.body); + + const now = new Date().toISOString(); + const doc: CdnPurgeRequestDoc = { + id: `purge_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + productId, + patterns: input.patterns, + status: 'pending', + purgedCount: 0, + requestedBy: userId, + completedAt: null, + error: null, + createdAt: now, + }; + + const created = await repo.createPurgeRequest(doc); + + // In production, this would enqueue an async job via the event bus. + // For MVP, mark as completed immediately (simulating CDN purge). + await repo.updatePurgeStatus(created.id, productId, 'completed', input.patterns.length); + + req.log.info({ purgeId: created.id, patterns: input.patterns, userId }, 'CDN purge requested'); + reply.status(201); + return { ...created, status: 'completed', purgedCount: input.patterns.length }; + }); + + // ── List purge history ───────────────────────────────────── + app.get('/cdn/purges', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const { limit: limitStr } = req.query as { limit?: string }; + const parsedLimit = limitStr ? parseInt(limitStr, 10) : 20; + const safeLimit = + Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20; + return { purges: await repo.listPurgeRequests(productId, safeLimit) }; + }); + + // ── Get origin config ────────────────────────────────────── + app.get('/cdn/config', async req => { + requireAdmin(req); + const productId = getRequestProductId(req); + const config = await repo.getOriginConfig(productId); + + if (!config) { + // Return defaults + return { + productId, + customDomain: null, + defaultCacheTtlSeconds: 86400, + categoryTtlOverrides: {}, + corsOrigins: ['*'], + brotliEnabled: true, + webpAutoConvert: true, + }; + } + + return config; + }); + + // ── Update origin config ─────────────────────────────────── + app.put('/cdn/config', async req => { + const userId = requireAdmin(req); + const productId = getRequestProductId(req); + const input = UpdateOriginConfigSchema.parse(req.body); + + const existing = await repo.getOriginConfig(productId); + const now = new Date().toISOString(); + + const doc: CdnOriginConfigDoc = { + id: productId, // singleton per product + productId, + customDomain: input.customDomain ?? existing?.customDomain ?? null, + defaultCacheTtlSeconds: + input.defaultCacheTtlSeconds ?? existing?.defaultCacheTtlSeconds ?? 86400, + categoryTtlOverrides: input.categoryTtlOverrides ?? existing?.categoryTtlOverrides ?? {}, + corsOrigins: input.corsOrigins ?? existing?.corsOrigins ?? ['*'], + brotliEnabled: input.brotliEnabled ?? existing?.brotliEnabled ?? true, + webpAutoConvert: input.webpAutoConvert ?? existing?.webpAutoConvert ?? true, + updatedAt: now, + updatedBy: userId, + }; + + const saved = await repo.upsertOriginConfig(doc); + req.log.info({ productId, userId }, 'CDN origin config updated'); + return saved; + }); +} diff --git a/services/platform-service/src/modules/cdn/types.ts b/services/platform-service/src/modules/cdn/types.ts new file mode 100644 index 00000000..5137473a --- /dev/null +++ b/services/platform-service/src/modules/cdn/types.ts @@ -0,0 +1,115 @@ +/** + * CDN Pipeline module — types and schemas. + * Manages asset uploads, purge requests, and CDN origin configuration. + * Assets are stored in Azure Blob and served via CDN with cache-busting. + */ + +import { z } from 'zod'; + +// ── Asset Types ────────────────────────────────────────────────── + +export type AssetCategory = 'image' | 'font' | 'script' | 'style' | 'document' | 'video' | 'other'; + +export interface CdnAssetDoc { + id: string; + productId: string; + /** Original filename uploaded by the user */ + originalName: string; + /** Blob storage path (container/path/hash.ext) */ + blobPath: string; + /** Public CDN URL */ + cdnUrl: string; + /** Content hash for cache-busting (SHA-256 first 16 hex chars) */ + contentHash: string; + /** MIME type */ + mimeType: string; + /** File size in bytes */ + sizeBytes: number; + category: AssetCategory; + /** Optional metadata (alt text, dimensions, etc.) */ + metadata?: Record; + /** Upload user */ + uploadedBy: string; + /** Whether asset is publicly accessible */ + isPublic: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CdnPurgeRequestDoc { + id: string; + productId: string; + /** Glob patterns to purge (e.g. /assets/img/*) */ + patterns: string[]; + /** Status of the purge */ + status: 'pending' | 'processing' | 'completed' | 'failed'; + /** Number of URLs purged */ + purgedCount: number; + requestedBy: string; + completedAt: string | null; + error: string | null; + createdAt: string; +} + +export interface CdnOriginConfigDoc { + id: string; + productId: string; + /** Custom domain (e.g. cdn.myapp.com) */ + customDomain: string | null; + /** Default cache TTL in seconds */ + defaultCacheTtlSeconds: number; + /** Per-category cache overrides */ + categoryTtlOverrides: Partial>; + /** Allowed CORS origins */ + corsOrigins: string[]; + /** Whether to enable Brotli compression */ + brotliEnabled: boolean; + /** Whether to enable WebP auto-conversion for images */ + webpAutoConvert: boolean; + updatedAt: string; + updatedBy: string; +} + +// ── Schemas ────────────────────────────────────────────────────── + +export const UploadAssetSchema = z.object({ + originalName: z.string().min(1).max(255), + mimeType: z.string().min(1).max(128), + sizeBytes: z + .number() + .int() + .min(1) + .max(100 * 1024 * 1024), // 100 MB max + category: z + .enum(['image', 'font', 'script', 'style', 'document', 'video', 'other']) + .default('other'), + contentHash: z.string().min(8).max(64), + blobPath: z.string().min(1).max(512), + metadata: z.record(z.unknown()).optional(), + isPublic: z.boolean().default(true), +}); + +export const PurgeRequestSchema = z.object({ + patterns: z.array(z.string().min(1).max(256)).min(1).max(50), +}); + +export const UpdateOriginConfigSchema = z.object({ + customDomain: z.string().max(253).nullable().optional(), + defaultCacheTtlSeconds: z.number().int().min(0).max(31536000).optional(), // up to 1 year + categoryTtlOverrides: z + .record( + z.enum(['image', 'font', 'script', 'style', 'document', 'video', 'other']), + z.number().int().min(0) + ) + .optional(), + corsOrigins: z + .array(z.string().url().or(z.literal('*'))) + .max(20) + .optional(), + brotliEnabled: z.boolean().optional(), + webpAutoConvert: z.boolean().optional(), +}); + +export type UploadAssetInput = z.infer; +export type PurgeRequestInput = z.infer; +export type UpdateOriginConfigInput = z.infer;