Skip to Content
ProvidersAWS S3

AWS S3

Category: Storage
Integration type: Platform-level (single S3 bucket; enterprise tenants may bring their own)
External SDK: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner


Purpose

AWS S3 is the platform’s file storage layer. All binary assets that are too large or ephemeral for the database go to S3:

Asset typeS3 key patternRetention
Email attachments (reports, invoices)attachments/{tenantId}/{type}/{date}/{filename}90 days
Contract PDFscontracts/{tenantId}/{contractId}.pdfPermanent
Blog featured imagesmedia/{tenantId}/blog/{postId}/{filename}Permanent
Knowledge base file uploadsrag/{tenantId}/{datasetId}/{filename}Until deleted
Agent output exportsexports/{tenantId}/{activityId}/{filename}30 days
Tenant logo / brand assetsbrand/{tenantId}/{filename}Permanent

Config Structure

Platform config (env vars)

AWS_ACCESS_KEY_ID=AKIAxxxxxxxxxxxxxxxx AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx AWS_REGION=ap-south-1 S3_BUCKET=leadmetrics-platform S3_CDN_BASE_URL=https://cdn.leadmetrics.io # CloudFront distribution in front of S3

Enterprise tenant bring-your-own S3

Enterprise on-premise deployments can configure their own S3 bucket (or compatible object storage like MinIO):

interface S3Config { accessKeyId: string; secretAccessKey: string; region: string; bucket: string; endpoint?: string; // Override for MinIO or other S3-compatible stores cdnBaseUrl?: string; // Optional CDN in front of bucket }

Integration Pattern

S3 utility (packages/storage/src/s3.ts)

import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; class S3Storage { private client: S3Client; constructor(private cfg: S3Config) { this.client = new S3Client({ region: cfg.region, credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey, }, endpoint: cfg.endpoint, // undefined = use default AWS endpoint }); } async upload(options: { key: string; body: Buffer | ReadableStream; contentType: string; metadata?: Record<string, string>; }): Promise<{ key: string; url: string }> { await this.client.send(new PutObjectCommand({ Bucket: this.cfg.bucket, Key: options.key, Body: options.body, ContentType: options.contentType, Metadata: options.metadata, })); return { key: options.key, url: `${this.cfg.cdnBaseUrl ?? `https://${this.cfg.bucket}.s3.${this.cfg.region}.amazonaws.com`}/${options.key}`, }; } async download(key: string): Promise<Buffer> { const response = await this.client.send(new GetObjectCommand({ Bucket: this.cfg.bucket, Key: key, })); const stream = response.Body as NodeJS.ReadableStream; const chunks: Buffer[] = []; for await (const chunk of stream) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks); } async presignedUrl(key: string, expiresIn = 3600): Promise<string> { return getSignedUrl( this.client, new GetObjectCommand({ Bucket: this.cfg.bucket, Key: key }), { expiresIn }, ); } async delete(key: string): Promise<void> { await this.client.send(new DeleteObjectCommand({ Bucket: this.cfg.bucket, Key: key, })); } }

Pre-signed URL usage

For time-limited download links (contract PDFs, report exports), the platform uses pre-signed URLs:

// In report delivery handler const reportKey = `exports/${tenantId}/${activityId}/report-march-2026.pdf`; const downloadUrl = await s3.presignedUrl(reportKey, 60 * 60 * 24 * 7); // 7 days await notifyTenant(tenantId, 'monthly_report_ready', { reportUrl: downloadUrl, month: 'March 2026', });

Knowledge base uploads

Files uploaded to the Knowledge Base (CSV, DOCX, PDF) are stored in S3 before being queued for RAG ingestion:

// POST /api/dashboard/knowledge-base/datasets/:id/files const key = `rag/${tenantId}/${datasetId}/${uuid()}-${filename}`; await s3.upload({ key, body: req.file.buffer, contentType: req.file.mimetype }); // Enqueue for ingestion await ragIngestionQueue.add('ingest-file', { tenantId, datasetId, key });

Lifecycle Rules

S3 lifecycle rules (configured in AWS Console or IaC) handle automatic cleanup:

PrefixActionAfter
attachments/Delete90 days
exports/Delete30 days
AllTransition to Glacier1 year

Permanent assets (contracts/, rag/, media/, brand/) have no expiry rule.


Test Cases

Unit tests (packages/storage/src/s3.test.ts)

TestApproach
upload() calls PutObjectCommand with correct paramsMock S3Client.send; assert Bucket, Key, ContentType
download() reads stream and returns BufferMock GetObjectCommand returns readable stream; assert Buffer
presignedUrl() returns URL with expiryMock getSignedUrl; assert URL returned
delete() calls DeleteObjectCommandAssert correct Key
Throws on access denied (403)Mock S3Client.send throws AccessDenied; assert propagated
Uses custom endpoint for MinIOAssert endpoint passed to S3Client constructor

Integration tests

TestApproach
Upload and download round-tripUpload buffer; download by key; assert content matches
Pre-signed URL is accessibleGenerate URL; fetch via axios.get; assert 200
Delete removes objectUpload then delete; try download; assert 404

Local development with MinIO

# docker-compose.dev.yml minio: image: minio/minio command: server /data --console-address ":9001" ports: - "9000:9000" # S3 API - "9001:9001" # Web console environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin

Set S3_ENDPOINT=http://minio:9000 and S3_BUCKET=leadmetrics-dev in .env.dev. Create the bucket via MinIO console on first run.


© 2026 Leadmetrics — Internal use only