feat(cdn): add CDN asset pipeline module — upload, purge, origin config

- 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
This commit is contained in:
saravanakumardb1 2026-03-19 23:47:59 -07:00
parent 4071429871
commit 7b43a02126
4 changed files with 692 additions and 0 deletions

View File

@ -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);
});
});

View File

@ -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<CdnAssetDoc> {
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<CdnAssetDoc | null> {
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<number>({ 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<CdnAssetDoc>({ query, parameters }).fetchAll();
return { assets: resources, total };
}
export async function deleteAsset(id: string, productId: string): Promise<boolean> {
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<CdnAssetDoc | null> {
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<CdnAssetDoc>({ query, parameters }).fetchAll();
return resources[0] ?? null;
}
export async function getStorageStats(productId: string): Promise<{
totalAssets: number;
totalSizeBytes: number;
byCategory: Record<string, { count: number; sizeBytes: number }>;
}> {
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<string, { count: number; sizeBytes: number }> = {};
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<CdnPurgeRequestDoc> {
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<CdnPurgeRequestDoc | null> {
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<CdnPurgeRequestDoc | null> {
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<CdnPurgeRequestDoc[]> {
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<CdnPurgeRequestDoc>({ query, parameters })
.fetchAll();
return resources;
}
// =============================================================================
// Origin Config (singleton per product)
// =============================================================================
export async function getOriginConfig(productId: string): Promise<CdnOriginConfigDoc | null> {
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<CdnOriginConfigDoc> {
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;
}

View File

@ -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<void> {
// ── 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;
});
}

View File

@ -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<string, unknown>;
/** 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<Record<AssetCategory, number>>;
/** 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<typeof UploadAssetSchema>;
export type PurgeRequestInput = z.infer<typeof PurgeRequestSchema>;
export type UpdateOriginConfigInput = z.infer<typeof UpdateOriginConfigSchema>;