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:
parent
4071429871
commit
7b43a02126
147
services/platform-service/src/modules/cdn/cdn.test.ts
Normal file
147
services/platform-service/src/modules/cdn/cdn.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
206
services/platform-service/src/modules/cdn/repository.ts
Normal file
206
services/platform-service/src/modules/cdn/repository.ts
Normal 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;
|
||||
}
|
||||
224
services/platform-service/src/modules/cdn/routes.ts
Normal file
224
services/platform-service/src/modules/cdn/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
115
services/platform-service/src/modules/cdn/types.ts
Normal file
115
services/platform-service/src/modules/cdn/types.ts
Normal 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>;
|
||||
Loading…
Reference in New Issue
Block a user