COA Storage Plan: Medusa Admin File Upload & Management¶
This document defines the plan for implementing Certificate of Analysis (COA) file upload, storage, and management within the Medusa admin dashboard. It builds on the existing Product Compliance Module's COA data model and adds the missing file upload infrastructure.
1. Current State¶
What Exists¶
The Product Compliance Module already provides a complete data model and API layer for COAs:
| Component | Status | Location |
|---|---|---|
| COA data model (MikroORM) | Done | app/src/modules/product-compliance/models/coa.ts |
| Lot data model | Done | app/src/modules/product-compliance/models/lot.ts |
| Purity Record data model | Done | app/src/modules/product-compliance/models/purity-record.ts |
| Database migration | Done | app/src/modules/product-compliance/migrations/ |
| Admin API: create COA | Done | POST /admin/compliance/lots/[id]/coa |
| Admin API: get COA | Done | GET /admin/compliance/coa/[id] |
| Store API: public COA | Done | GET /store/compliance/coa/[id] |
| Store API: lot lookup | Done | GET /store/compliance/lots/[lot_number] |
| Validators (Zod) | Done | app/src/api/admin/compliance/lots/validators.ts |
| Admin lot list page | Done | app/src/admin/routes/compliance/lots/page.tsx |
| Admin lot detail page | Done | app/src/admin/routes/compliance/lots/[id]/page.tsx |
| Storefront COA card | Done | storefront/src/components/compliance/coa-card.tsx |
| Event subscriber | Done | app/src/subscribers/coa-verified.ts |
| Seed data | Done | app/src/scripts/seed-compliance.ts |
What's Missing¶
The current implementation expects COA files to be pre-uploaded with their URLs provided at COA creation time. There is no mechanism to:
- Upload COA PDF files through the admin dashboard
- Store files in cloud object storage (S3/R2)
- Delete or replace COA files
- Validate uploaded file types and sizes
- Generate pre-signed/temporary URLs for secure file access
The existing COA model fields file_url and file_key are in place and ready to receive values from an upload pipeline.
2. Architecture Decision: Storage Backend¶
Recommended: Cloudflare R2¶
Given the existing Cloudflare infrastructure (DNS, CDN, Pages), Cloudflare R2 is the natural choice for file storage:
| Factor | Cloudflare R2 | AWS S3 | MinIO (self-hosted) |
|---|---|---|---|
| S3-compatible API | Yes | Yes (native) | Yes |
| Egress fees | $0 | $0.09/GB | N/A (self-hosted) |
| Storage cost | $0.015/GB/mo | $0.023/GB/mo | Disk cost only |
| CDN integration | Native (via custom domain) | Requires CloudFront | Manual |
| Existing relationship | Already using Cloudflare | New vendor | Requires server resources |
| Medusa S3 plugin | Compatible | Native support | Compatible |
| Ops complexity | Low (managed) | Low (managed) | Medium (self-managed) |
Decision: Use Cloudflare R2 with the Medusa S3 file provider plugin, which supports any S3-compatible API.
Storage Layout¶
rr-bizops-files/ # R2 bucket name
├── coa/ # Certificate of Analysis PDFs
│ ├── {lot_number}/ # Organized by lot
│ │ ├── {lot_number}-coa.pdf # Primary COA document
│ │ └── {lot_number}-coa-v2.pdf # Revision (if re-analyzed)
│ └── ...
├── sds/ # Safety Data Sheets (future)
│ └── {product-handle}.pdf
└── products/ # Product images (future)
└── {product-handle}/
├── thumb.jpg
└── ...
Key naming convention: coa/{lot_number}/{lot_number}-coa.pdf
- Example:
coa/BPC157-2026-001/BPC157-2026-001-coa.pdf - The
file_keyfield on the COA record stores this path - The
file_urlfield stores the full public URL:https://cdn.research-relay.com/coa/BPC157-2026-001/BPC157-2026-001-coa.pdf
3. Implementation Plan¶
Phase 1: S3-Compatible File Provider Setup¶
Goal: Configure Medusa's file storage to use Cloudflare R2.
3.1 Install the S3 file provider¶
3.2 Configure R2 credentials¶
Add to app/.env (and .env.template):
# Cloudflare R2 (S3-compatible file storage)
S3_FILE_URL=https://cdn.research-relay.com
S3_ACCESS_KEY_ID=<r2-access-key>
S3_SECRET_ACCESS_KEY=<r2-secret-key>
S3_REGION=auto
S3_BUCKET=rr-bizops-files
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
3.3 Register the file provider in Medusa config¶
Update app/medusa-config.ts to add the S3 file provider module:
{
resolve: "@medusajs/medusa/file",
options: {
providers: [
{
resolve: "@medusajs/file-s3",
id: "s3",
options: {
file_url: process.env.S3_FILE_URL,
access_key_id: process.env.S3_ACCESS_KEY_ID,
secret_access_key: process.env.S3_SECRET_ACCESS_KEY,
region: process.env.S3_REGION,
bucket: process.env.S3_BUCKET,
endpoint: process.env.S3_ENDPOINT,
additional_client_config: {
forcePathStyle: true,
},
},
},
],
},
},
3.4 Set up Cloudflare R2¶
- Create R2 bucket
rr-bizops-filesin Cloudflare dashboard - Create R2 API token with read/write permissions scoped to the bucket
- Configure custom domain
cdn.research-relay.compointing to the R2 bucket (Cloudflare dashboard > R2 > bucket settings > custom domains) - Set CORS policy on the bucket to allow uploads from the admin domain
R2 CORS configuration:
[
{
"AllowedOrigins": [
"https://api.research-relay.com",
"http://localhost:9000"
],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
Phase 2: COA Upload API Endpoint¶
Goal: Add an admin API endpoint for uploading COA PDF files.
3.5 Create upload route¶
Create app/src/api/admin/compliance/coa/upload/route.ts:
// POST /admin/compliance/coa/upload
// Accepts multipart/form-data with a PDF file
// Returns { file_url, file_key } for use when creating a COA record
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fileService = req.scope.resolve("file")
const files = req.files as Express.Multer.File[]
if (!files || files.length === 0) {
return res.status(400).json({ message: "No file provided" })
}
const file = files[0]
// Validate file type
if (file.mimetype !== "application/pdf") {
return res.status(400).json({ message: "Only PDF files are accepted" })
}
// Validate file size (max 25MB)
const MAX_SIZE = 25 * 1024 * 1024
if (file.size > MAX_SIZE) {
return res.status(400).json({ message: "File must be under 25MB" })
}
// Get the lot_number from the request body for file naming
const lotNumber = req.body?.lot_number as string
if (!lotNumber) {
return res.status(400).json({ message: "lot_number is required" })
}
// Construct the file key
const fileKey = `coa/${lotNumber}/${lotNumber}-coa.pdf`
// Upload to R2 via Medusa's file service
const uploaded = await fileService.uploadProtected({
filename: fileKey,
mimeType: file.mimetype,
content: file.buffer,
})
res.json({
file_url: uploaded.url,
file_key: uploaded.key || fileKey,
})
}
3.6 Add file upload middleware¶
Update app/src/api/middlewares.ts to configure multer for the upload endpoint:
import { defineMiddlewares } from "@medusajs/medusa"
import multer from "multer"
const upload = multer({ storage: multer.memoryStorage() })
export default defineMiddlewares({
routes: [
{
matcher: "/admin/compliance/coa/upload",
method: "POST",
middlewares: [upload.array("files")],
},
// ... existing middleware entries
],
})
3.7 Create delete route¶
Create app/src/api/admin/compliance/coa/[id]/file/route.ts:
// DELETE /admin/compliance/coa/:id/file
// Deletes the COA file from storage and clears file_url/file_key
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { PRODUCT_COMPLIANCE_MODULE } from "../../../../../modules/product-compliance"
import type ProductComplianceModuleService from "../../../../../modules/product-compliance/service"
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const fileService = req.scope.resolve("file")
const complianceService: ProductComplianceModuleService = req.scope.resolve(
PRODUCT_COMPLIANCE_MODULE
)
const { id } = req.params
const coa = await complianceService.retrieveCoa(id)
if (coa.file_key) {
await fileService.delete({ fileKey: coa.file_key })
}
await complianceService.updateCoas(id, {
file_url: "",
file_key: "",
})
res.json({ success: true })
}
Phase 3: Admin Dashboard UI — COA Upload Widget¶
Goal: Add file upload capability to the lot detail page in the Medusa admin.
3.8 Add upload form to lot detail page¶
Enhance app/src/admin/routes/compliance/lots/[id]/page.tsx with:
- Upload button in the COA section header
- File input that accepts
.pdffiles only - Upload progress indicator
- COA creation form that appears after upload, pre-filled with
file_urlandfile_key
The form flow:
1. Admin clicks "Add COA" on lot detail page
2. Modal/drawer opens with:
a. File upload zone (drag & drop or click to browse)
b. Fields: purity_percentage, analysis_date, lab_name, lab_reference
c. Optional purity records table (add rows)
3. Admin selects a PDF file
→ File uploads to /admin/compliance/coa/upload
→ file_url and file_key are stored in form state
4. Admin fills remaining fields
5. Admin clicks "Save"
→ POST /admin/compliance/lots/{id}/coa with file_url, file_key, and other fields
6. COA appears in the lot detail page
3.9 Add COA verification action¶
Add a "Verify" button on each COA card in the admin that:
- Sets
verified: true - Sets
verified_byto the current admin user ID - Sets
verified_atto the current timestamp - Calls
POST /admin/compliance/coa/{id}(update endpoint — needs to be created)
3.10 Create COA update endpoint¶
Create app/src/api/admin/compliance/coa/[id]/route.ts:
// POST /admin/compliance/coa/:id — Update COA fields (including verification)
// GET /admin/compliance/coa/:id — Get COA details (already exists)
Phase 4: Public COA Access¶
Goal: Ensure storefront customers can view and download COA PDFs.
3.11 Public access strategy¶
COA PDFs should be publicly accessible for transparency. The cdn.research-relay.com custom domain on R2 serves files publicly. The existing store endpoints already return file_url which points to the CDN.
No additional work needed if R2 is configured with a public custom domain. If private access is needed in the future:
- Use Medusa's file service
getPresignedDownloadUrl()to generate time-limited signed URLs - Update the store COA endpoint to return a signed URL instead of the direct
file_url
3.12 Storefront COA card updates¶
The existing storefront/src/components/compliance/coa-card.tsx already renders a download link using file_url. No changes needed — it will work once real files are uploaded.
4. Data Model Summary¶
No schema changes are required. The existing COA model already has the necessary fields:
Coa
├── id (PK)
├── lot_id (FK → Lot)
├── file_url (TEXT) ← populated by upload endpoint
├── file_key (TEXT) ← populated by upload endpoint
├── purity_percentage (FLOAT, nullable)
├── analysis_date (DATETIME)
├── lab_name (TEXT, nullable)
├── lab_reference (TEXT, nullable)
├── verified (BOOLEAN, default false)
├── verified_by (TEXT, nullable)
├── verified_at (DATETIME, nullable)
├── metadata (JSONB, nullable)
└── purity_records (HasMany → PurityRecord)
5. Admin Workflow: Adding a COA¶
Complete workflow for an admin user adding a COA to a lot:
1. Navigate to Compliance > Lots in admin sidebar
2. Click on a lot (e.g., BPC157-2026-001)
3. In the "Certificates of Analysis" section, click "Add COA"
4. Upload the COA PDF from the supplier/lab
→ System uploads to R2: coa/BPC157-2026-001/BPC157-2026-001-coa.pdf
→ System returns file_url and file_key
5. Fill in COA metadata:
- Purity %: 98.7
- Analysis date: 2026-01-18
- Lab name: Analytical Testing Services Inc.
- Lab reference: ATS-2026-00142
6. (Optional) Add purity records:
| Parameter | Method | Result | Unit | Spec | Pass? |
|-----------|--------|--------|------|------|-------|
| Purity | HPLC | 98.7 | % | >98% | Yes |
| Identity | MS | 1419.5 | m/z | ±0.5 | Yes |
| Endotoxin | LAL | 0.05 | EU/mg| <0.1 | Yes |
7. Click "Save" → COA record created
8. Review the COA details, then click "Verify"
→ Sets verified=true, verified_by=current admin, verified_at=now
9. COA is now visible on the storefront product page
6. File Validation Rules¶
| Rule | Value | Rationale |
|---|---|---|
| Allowed MIME types | application/pdf |
COAs are always PDF documents |
| Max file size | 25 MB | Typical COAs are 100KB-2MB; 25MB allows for high-res scans |
| File naming | {lot_number}-coa.pdf |
Consistent, human-readable naming |
| Storage path | coa/{lot_number}/ |
Organized by lot for easy browsing |
| Duplicate handling | Overwrite with timestamp suffix | BPC157-2026-001-coa-v2.pdf for revisions |
7. Security Considerations¶
| Concern | Mitigation |
|---|---|
| Unauthorized uploads | Upload endpoint requires admin authentication (Medusa's built-in auth middleware) |
| File type spoofing | Validate MIME type server-side; consider PDF magic byte validation |
| Large file DoS | Enforce 25MB limit in multer middleware |
| Public file access | COAs are intentionally public (transparency for research customers) |
| R2 credentials | Stored in server env vars and 1Password; scoped to single bucket |
| File deletion | Soft-delete in database; hard-delete from R2 only on explicit admin action |
8. Environment Variables¶
New variables required for the file storage integration:
# Add to app/.env.template
S3_FILE_URL=https://cdn.research-relay.com
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=auto
S3_BUCKET=rr-bizops-files
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
Add to the production environment file at /etc/rr-bizops/.env on the server.
9. Implementation Checklist¶
-
Infrastructure
- Create R2 bucket
rr-bizops-filesin Cloudflare dashboard - Create R2 API token (read/write, scoped to bucket)
- Configure custom domain
cdn.research-relay.com→ R2 bucket - Set CORS policy on R2 bucket
- Add R2 credentials to production
.env - Add R2 credentials to development
.env
- Create R2 bucket
-
Backend
- Install
@medusajs/file-s3package - Add S3 file provider to
medusa-config.ts - Add S3 env vars to
.env.template - Create
POST /admin/compliance/coa/uploadendpoint - Add multer middleware for upload route
- Create
POST /admin/compliance/coa/:idupdate endpoint - Create
DELETE /admin/compliance/coa/:id/fileendpoint - Add integration tests for upload, create, update, delete
- Install
-
Admin UI
- Add "Add COA" button to lot detail page
- Build COA upload form (file input + metadata fields)
- Build purity records inline editor
- Add "Verify" action on COA cards
- Add "Delete" action on COA cards (with confirmation)
- Add upload progress indicator
- Add error handling and validation feedback
-
Testing
- Unit tests for file validation logic
- Integration tests for upload → create → verify flow
- Integration tests for file deletion
- Manual test: upload, view on storefront, download PDF
10. Dependencies & Ordering¶
This plan can be implemented independently and slots into the existing project phases:
Phase B (Product Compliance Module) ← already done
└── COA Storage Plan (this document) ← implement next
├── Phase 1: R2 + S3 provider setup
├── Phase 2: Upload API endpoints
├── Phase 3: Admin UI upload widget
└── Phase 4: Storefront integration (already works)
No blocking dependencies. The S3 file provider setup (Phase 1) is a prerequisite for everything else. Phases 2-4 can be built incrementally.