import { Smartlog } from '@push.rocks/smartlog'; import { BaseRegistry } from '../core/classes.baseregistry.js'; import { RegistryStorage } from '../core/classes.registrystorage.js'; import { AuthManager } from '../core/classes.authmanager.js'; import type { IRequestContext, IResponse, IAuthToken, IRegistryError, IRequestActor } from '../core/interfaces.core.js'; import { createHashTransform, streamToBuffer } from '../core/helpers.stream.js'; import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js'; import { OciUpstream } from './classes.ociupstream.js'; import type { IUploadSession, IOciManifest, IOciImageIndex, ITagList, IReferrersResponse, IPaginationOptions, } from './interfaces.oci.js'; /** * OCI Distribution Specification v1.1 compliant registry */ export class OciRegistry extends BaseRegistry { private storage: RegistryStorage; private authManager: AuthManager; private uploadSessions: Map = new Map(); private basePath: string = '/oci'; private cleanupInterval?: NodeJS.Timeout; private ociTokens?: { realm: string; service: string }; private upstreamProvider: IUpstreamProvider | null = null; private logger: Smartlog; constructor( storage: RegistryStorage, authManager: AuthManager, basePath: string = '/oci', ociTokens?: { realm: string; service: string }, upstreamProvider?: IUpstreamProvider ) { super(); this.storage = storage; this.authManager = authManager; this.basePath = basePath; this.ociTokens = ociTokens; this.upstreamProvider = upstreamProvider || null; // Initialize logger this.logger = new Smartlog({ logContext: { company: 'push.rocks', companyunit: 'smartregistry', containerName: 'oci-registry', environment: (process.env.NODE_ENV as any) || 'development', runtime: 'node', zone: 'oci' } }); this.logger.enableConsole(); if (upstreamProvider) { this.logger.log('info', 'OCI upstream provider configured'); } } /** * Extract scope from OCI repository name. * @example "myorg/myimage" -> "myorg" * @example "library/nginx" -> "library" * @example "nginx" -> null */ private extractScope(repository: string): string | null { const slashIndex = repository.indexOf('/'); if (slashIndex > 0) { return repository.substring(0, slashIndex); } return null; } /** * Get upstream for a specific request. * Calls the provider to resolve upstream config dynamically. */ private async getUpstreamForRequest( resource: string, resourceType: string, method: string, actor?: IRequestActor ): Promise { if (!this.upstreamProvider) return null; const config = await this.upstreamProvider.resolveUpstreamConfig({ protocol: 'oci', resource, scope: this.extractScope(resource), actor, method, resourceType, }); if (!config?.enabled) return null; return new OciUpstream(config, this.basePath, this.logger); } public async init(): Promise { // Start cleanup of stale upload sessions this.startUploadSessionCleanup(); } public getBasePath(): string { return this.basePath; } public async handleRequest(context: IRequestContext): Promise { // Remove base path from URL const path = context.path.replace(this.basePath, ''); // Extract token from Authorization header const authHeader = context.headers['authorization'] || context.headers['Authorization']; const tokenString = authHeader?.replace(/^Bearer\s+/i, ''); const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null; // Build actor from context and validated token const actor: IRequestActor = { ...context.actor, userId: token?.userId, ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'], userAgent: context.headers['user-agent'] || context.headers['User-Agent'], }; // Route to appropriate handler if (path === '/' || path === '') { return this.handleVersionCheck(); } // Manifest operations: /{name}/manifests/{reference} const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/); if (manifestMatch) { const [, name, reference] = manifestMatch; // Prefer rawBody for content-addressable operations to preserve exact bytes const bodyData = context.rawBody || context.body; return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor); } // Blob operations: /{name}/blobs/{digest} const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/); if (blobMatch) { const [, name, digest] = blobMatch; return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor); } // Blob upload operations: /{name}/blobs/uploads/ const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/); if (uploadInitMatch && context.method === 'POST') { const [, name] = uploadInitMatch; // Prefer rawBody for content-addressable operations to preserve exact bytes const bodyData = context.rawBody || context.body; return this.handleUploadInit(name, token, context.query, bodyData); } // Blob upload operations: /{name}/blobs/uploads/{uuid} const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/); if (uploadMatch) { const [, name, uploadId] = uploadMatch; return this.handleUploadSession(context.method, uploadId, token, context); } // Tags list: /{name}/tags/list const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/); if (tagsMatch) { const [, name] = tagsMatch; return this.handleTagsList(name, token, context.query); } // Referrers: /{name}/referrers/{digest} const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/); if (referrersMatch) { const [, name, digest] = referrersMatch; return this.handleReferrers(name, digest, token, context.query); } return { status: 404, headers: { 'Content-Type': 'application/json' }, body: this.createError('NOT_FOUND', 'Endpoint not found'), }; } protected async checkPermission( token: IAuthToken | null, resource: string, action: string ): Promise { if (!token) return false; return this.authManager.authorize(token, `oci:repository:${resource}`, action); } // ======================================================================== // REQUEST HANDLERS // ======================================================================== private handleVersionCheck(): IResponse { return { status: 200, headers: { 'Content-Type': 'application/json', 'Docker-Distribution-API-Version': 'registry/2.0', }, body: {}, }; } private async handleManifestRequest( method: string, repository: string, reference: string, token: IAuthToken | null, body?: Buffer | any, headers?: Record, actor?: IRequestActor ): Promise { switch (method) { case 'GET': return this.getManifest(repository, reference, token, headers, actor); case 'HEAD': return this.headManifest(repository, reference, token); case 'PUT': return this.putManifest(repository, reference, token, body, headers); case 'DELETE': return this.deleteManifest(repository, reference, token); default: return { status: 405, headers: {}, body: this.createError('UNSUPPORTED', 'Method not allowed'), }; } } private async handleBlobRequest( method: string, repository: string, digest: string, token: IAuthToken | null, headers: Record, actor?: IRequestActor ): Promise { switch (method) { case 'GET': return this.getBlob(repository, digest, token, headers['range'] || headers['Range'], actor); case 'HEAD': return this.headBlob(repository, digest, token); case 'DELETE': return this.deleteBlob(repository, digest, token); default: return { status: 405, headers: {}, body: this.createError('UNSUPPORTED', 'Method not allowed'), }; } } private async handleUploadInit( repository: string, token: IAuthToken | null, query: Record, body?: Buffer | any ): Promise { if (!await this.checkPermission(token, repository, 'push')) { return this.createUnauthorizedResponse(repository, 'push'); } // Check for monolithic upload (digest + body provided) const digest = query.digest; if (digest && body) { // Monolithic upload: complete upload in single POST const blobData = this.toBuffer(body); // Verify digest const calculatedDigest = await this.calculateDigest(blobData); if (calculatedDigest !== digest) { return { status: 400, headers: {}, body: this.createError('DIGEST_INVALID', 'Provided digest does not match uploaded content'), }; } // Store the blob await this.storage.putOciBlob(digest, blobData); return { status: 201, headers: { 'Location': `${this.basePath}/${repository}/blobs/${digest}`, 'Docker-Content-Digest': digest, }, body: null, }; } // Standard chunked upload: create session const uploadId = this.generateUploadId(); const session: IUploadSession = { uploadId, repository, chunks: [], chunkPaths: [], chunkIndex: 0, totalSize: 0, createdAt: new Date(), lastActivity: new Date(), }; this.uploadSessions.set(uploadId, session); return { status: 202, headers: { 'Location': `${this.basePath}/${repository}/blobs/uploads/${uploadId}`, 'Docker-Upload-UUID': uploadId, }, body: null, }; } private async handleUploadSession( method: string, uploadId: string, token: IAuthToken | null, context: IRequestContext ): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } if (!await this.checkPermission(token, session.repository, 'push')) { return this.createUnauthorizedResponse(session.repository, 'push'); } // Prefer rawBody for content-addressable operations to preserve exact bytes const bodyData = context.rawBody || context.body; switch (method) { case 'PATCH': return this.uploadChunk(uploadId, bodyData, context.headers['content-range']); case 'PUT': return this.completeUpload(uploadId, context.query['digest'], bodyData); case 'GET': return this.getUploadStatus(uploadId); default: return { status: 405, headers: {}, body: this.createError('UNSUPPORTED', 'Method not allowed'), }; } } // Continuing with the actual OCI operations implementation... // (The rest follows the same pattern as before, adapted to return IResponse objects) private async getManifest( repository: string, reference: string, token: IAuthToken | null, headers?: Record, actor?: IRequestActor ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedResponse(repository, 'pull'); } // Resolve tag to digest if needed let digest = reference; if (!reference.startsWith('sha256:')) { const tags = await this.getTagsData(repository); digest = tags[reference]; } // Try local storage first (if we have a digest) let manifestData: Buffer | null = null; let contentType: string | null = null; if (digest) { manifestData = await this.storage.getOciManifest(repository, digest); if (manifestData) { contentType = await this.storage.getOciManifestContentType(repository, digest); if (!contentType) { contentType = this.detectManifestContentType(manifestData); } } } // If not found locally, try upstream if (!manifestData) { const upstream = await this.getUpstreamForRequest(repository, 'manifest', 'GET', actor); if (upstream) { this.logger.log('debug', 'getManifest: fetching from upstream', { repository, reference }); const upstreamResult = await upstream.fetchManifest(repository, reference); if (upstreamResult) { manifestData = Buffer.from(JSON.stringify(upstreamResult.manifest), 'utf8'); contentType = upstreamResult.contentType; digest = upstreamResult.digest; // Cache the manifest locally await this.storage.putOciManifest(repository, digest, manifestData, contentType); // If reference is a tag, update tags mapping if (!reference.startsWith('sha256:')) { const tags = await this.getTagsData(repository); tags[reference] = digest; const tagsPath = `oci/tags/${repository}/tags.json`; await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8')); } this.logger.log('debug', 'getManifest: cached manifest locally', { repository, reference, digest, }); } } } if (!manifestData) { return { status: 404, headers: {}, body: this.createError('MANIFEST_UNKNOWN', 'Manifest not found'), }; } return { status: 200, headers: { 'Content-Type': contentType || 'application/vnd.oci.image.manifest.v1+json', 'Docker-Content-Digest': digest, }, body: manifestData, }; } private async headManifest( repository: string, reference: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedHeadResponse(repository, 'pull'); } // Similar logic as getManifest but return headers only let digest = reference; if (!reference.startsWith('sha256:')) { const tags = await this.getTagsData(repository); digest = tags[reference]; if (!digest) { return { status: 404, headers: {}, body: null }; } } const exists = await this.storage.ociManifestExists(repository, digest); if (!exists) { return { status: 404, headers: {}, body: null }; } const manifestData = await this.storage.getOciManifest(repository, digest); // Get stored content type, falling back to detecting from manifest content let contentType = await this.storage.getOciManifestContentType(repository, digest); if (!contentType && manifestData) { // Fallback: detect content type from manifest content contentType = this.detectManifestContentType(manifestData); } contentType = contentType || 'application/vnd.oci.image.manifest.v1+json'; return { status: 200, headers: { 'Content-Type': contentType, 'Docker-Content-Digest': digest, 'Content-Length': manifestData ? manifestData.length.toString() : '0', }, body: null, }; } private async putManifest( repository: string, reference: string, token: IAuthToken | null, body?: Buffer | any, headers?: Record ): Promise { if (!await this.checkPermission(token, repository, 'push')) { return this.createUnauthorizedResponse(repository, 'push'); } if (!body) { return { status: 400, headers: {}, body: this.createError('MANIFEST_INVALID', 'Manifest body is required'), }; } // Preserve raw bytes for accurate digest calculation // Per OCI spec, digest must match the exact bytes sent by client const manifestData = this.toBuffer(body); const contentType = headers?.['content-type'] || headers?.['Content-Type'] || 'application/vnd.oci.image.manifest.v1+json'; // Calculate manifest digest const digest = await this.calculateDigest(manifestData); // Store manifest by digest await this.storage.putOciManifest(repository, digest, manifestData, contentType); // If reference is a tag (not a digest), update tags mapping if (!reference.startsWith('sha256:')) { const tags = await this.getTagsData(repository); tags[reference] = digest; const tagsPath = `oci/tags/${repository}/tags.json`; await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8')); } return { status: 201, headers: { 'Location': `${this.basePath}/${repository}/manifests/${digest}`, 'Docker-Content-Digest': digest, }, body: null, }; } private async deleteManifest( repository: string, digest: string, token: IAuthToken | null ): Promise { if (!digest.startsWith('sha256:')) { return { status: 400, headers: {}, body: this.createError('UNSUPPORTED', 'Must use digest for deletion'), }; } if (!await this.checkPermission(token, repository, 'delete')) { return this.createUnauthorizedResponse(repository, 'delete'); } await this.storage.deleteOciManifest(repository, digest); return { status: 202, headers: {}, body: null, }; } private async getBlob( repository: string, digest: string, token: IAuthToken | null, range?: string, actor?: IRequestActor ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedResponse(repository, 'pull'); } // Try local storage first (streaming) const streamResult = await this.storage.getOciBlobStream(digest); if (streamResult) { return { status: 200, headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': streamResult.size.toString(), 'Docker-Content-Digest': digest, }, body: streamResult.stream, }; } // If not found locally, try upstream let data: Buffer | null = null; const upstream = await this.getUpstreamForRequest(repository, 'blob', 'GET', actor); if (upstream) { this.logger.log('debug', 'getBlob: fetching from upstream', { repository, digest }); const upstreamBlob = await upstream.fetchBlob(repository, digest); if (upstreamBlob) { data = upstreamBlob; // Cache the blob locally (blobs are content-addressable and immutable) await this.storage.putOciBlob(digest, data); this.logger.log('debug', 'getBlob: cached blob locally', { repository, digest, size: data.length, }); } } if (!data) { return { status: 404, headers: {}, body: this.createError('BLOB_UNKNOWN', 'Blob not found'), }; } return { status: 200, headers: { 'Content-Type': 'application/octet-stream', 'Docker-Content-Digest': digest, }, body: data, }; } private async headBlob( repository: string, digest: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedHeadResponse(repository, 'pull'); } const blobSize = await this.storage.getOciBlobSize(digest); if (blobSize === null) { return { status: 404, headers: {}, body: null }; } return { status: 200, headers: { 'Content-Length': blobSize.toString(), 'Docker-Content-Digest': digest, }, body: null, }; } private async deleteBlob( repository: string, digest: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'delete')) { return this.createUnauthorizedResponse(repository, 'delete'); } await this.storage.deleteOciBlob(digest); return { status: 202, headers: {}, body: null, }; } private async uploadChunk( uploadId: string, data: Buffer | Uint8Array | unknown, contentRange: string ): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } const chunkData = this.toBuffer(data); // Write chunk to temp S3 object instead of accumulating in memory const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`; await this.storage.putObject(chunkPath, chunkData); session.chunkPaths.push(chunkPath); session.chunkIndex++; session.totalSize += chunkData.length; session.lastActivity = new Date(); return { status: 202, headers: { 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`, 'Range': `0-${session.totalSize - 1}`, 'Docker-Upload-UUID': uploadId, }, body: null, }; } private async completeUpload( uploadId: string, digest: string, finalData?: Buffer | Uint8Array | unknown ): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } // If there's final data in the PUT body, write it as the last chunk if (finalData) { const buf = this.toBuffer(finalData); const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`; await this.storage.putObject(chunkPath, buf); session.chunkPaths.push(chunkPath); session.chunkIndex++; session.totalSize += buf.length; } // Create a ReadableStream that assembles all chunks from S3 sequentially const chunkPaths = [...session.chunkPaths]; const storage = this.storage; let chunkIdx = 0; const assembledStream = new ReadableStream({ async pull(controller) { if (chunkIdx >= chunkPaths.length) { controller.close(); return; } const result = await storage.getObjectStream(chunkPaths[chunkIdx++]); if (result) { const reader = result.stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; if (value) controller.enqueue(value); } } }, }); // Pipe through hash transform for incremental digest verification const { transform: hashTransform, getDigest } = createHashTransform('sha256'); const hashedStream = assembledStream.pipeThrough(hashTransform); // Consume stream to buffer for S3 upload // (AWS SDK PutObjectCommand requires known content-length for streams; // the key win is chunks are NOT accumulated in memory during PATCH — they live in S3) const blobData = await streamToBuffer(hashedStream); // Verify digest before storing const calculatedDigest = `sha256:${getDigest()}`; if (calculatedDigest !== digest) { await this.cleanupUploadChunks(session); this.uploadSessions.delete(uploadId); return { status: 400, headers: {}, body: this.createError('DIGEST_INVALID', 'Digest mismatch'), }; } // Store verified blob await this.storage.putOciBlob(digest, blobData); // Cleanup temp chunks and session await this.cleanupUploadChunks(session); this.uploadSessions.delete(uploadId); return { status: 201, headers: { 'Location': `${this.basePath}/${session.repository}/blobs/${digest}`, 'Docker-Content-Digest': digest, }, body: null, }; } /** * Delete all temp S3 chunk objects for an upload session. */ private async cleanupUploadChunks(session: IUploadSession): Promise { for (const chunkPath of session.chunkPaths) { try { await this.storage.deleteObject(chunkPath); } catch { // Best-effort cleanup } } } private async getUploadStatus(uploadId: string): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } return { status: 204, headers: { 'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`, 'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0', 'Docker-Upload-UUID': uploadId, }, body: null, }; } private async handleTagsList( repository: string, token: IAuthToken | null, query: Record ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedResponse(repository, 'pull'); } const tags = await this.getTagsData(repository); const tagNames = Object.keys(tags); const response: ITagList = { name: repository, tags: tagNames, }; return { status: 200, headers: { 'Content-Type': 'application/json' }, body: response, }; } private async handleReferrers( repository: string, digest: string, token: IAuthToken | null, query: Record ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedResponse(repository, 'pull'); } const response: IReferrersResponse = { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [], }; return { status: 200, headers: { 'Content-Type': 'application/json' }, body: response, }; } // ======================================================================== // HELPER METHODS // ======================================================================== /** * Detect manifest content type from manifest content. * OCI Image Index has "manifests" array, OCI Image Manifest has "config" object. * Also checks the mediaType field if present. */ private detectManifestContentType(manifestData: Buffer): string { try { const manifest = JSON.parse(manifestData.toString('utf-8')); // First check if manifest has explicit mediaType field if (manifest.mediaType) { return manifest.mediaType; } // Otherwise detect from structure if (Array.isArray(manifest.manifests)) { // OCI Image Index (multi-arch manifest list) return 'application/vnd.oci.image.index.v1+json'; } else if (manifest.config) { // OCI Image Manifest return 'application/vnd.oci.image.manifest.v1+json'; } // Fallback to standard manifest type return 'application/vnd.oci.image.manifest.v1+json'; } catch (e) { // If parsing fails, return default return 'application/vnd.oci.image.manifest.v1+json'; } } /** * Convert any binary-like data to Buffer. * Handles Buffer, Uint8Array (modern cross-platform), string, and objects. * * Note: Buffer.isBuffer(Uint8Array) returns false even though Buffer extends Uint8Array. * This is because Uint8Array is the modern, cross-platform standard while Buffer is Node.js-specific. * Many HTTP frameworks pass request bodies as Uint8Array for better compatibility. */ private toBuffer(data: unknown): Buffer { if (Buffer.isBuffer(data)) { return data; } if (data instanceof Uint8Array) { return Buffer.from(data); } if (typeof data === 'string') { return Buffer.from(data, 'utf-8'); } // Fallback: serialize object to JSON (may cause digest mismatch for manifests) return Buffer.from(JSON.stringify(data)); } private async getTagsData(repository: string): Promise> { const path = `oci/tags/${repository}/tags.json`; const data = await this.storage.getObject(path); return data ? JSON.parse(data.toString('utf-8')) : {}; } private async putTagsData(repository: string, tags: Record): Promise { const path = `oci/tags/${repository}/tags.json`; const data = Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'); await this.storage.putObject(path, data); } private generateUploadId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } private async calculateDigest(data: Buffer): Promise { const crypto = await import('crypto'); const hash = crypto.createHash('sha256').update(data).digest('hex'); return `sha256:${hash}`; } private createError(code: string, message: string, detail?: any): IRegistryError { return { errors: [{ code, message, detail }], }; } /** * Create an unauthorized response with proper WWW-Authenticate header. * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header. */ private createUnauthorizedResponse(repository: string, action: string): IResponse { const realm = this.ociTokens?.realm || `${this.basePath}/token`; const service = this.ociTokens?.service || 'registry'; return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`, }, body: this.createError('DENIED', 'Insufficient permissions'), }; } /** * Create an unauthorized HEAD response (no body per HTTP spec). */ private createUnauthorizedHeadResponse(repository: string, action: string): IResponse { const realm = this.ociTokens?.realm || `${this.basePath}/token`; const service = this.ociTokens?.service || 'registry'; return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`, }, body: null, }; } private startUploadSessionCleanup(): void { this.cleanupInterval = setInterval(() => { const now = new Date(); const maxAge = 60 * 60 * 1000; // 1 hour for (const [uploadId, session] of this.uploadSessions.entries()) { if (now.getTime() - session.lastActivity.getTime() > maxAge) { // Clean up temp S3 chunks for stale sessions this.cleanupUploadChunks(session).catch(() => {}); this.uploadSessions.delete(uploadId); } } }, 10 * 60 * 1000); } public destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } } }