Audit Date: 2026-02-26
Service: apps/media-service (Cloudflare Worker, Hono, R2)
Context: Auditing against new features: workspace logo upload, user avatar upload, notification prefs backend.
Architecture Overview
media-service (port 8789)
├── POST /upload/presign — generates R2 presigned PUT URL, records mediaFiles row
├── GET /files — lists media files for a tenant
└── GET /health — liveness checkThe service runs independently as a Cloudflare Worker. The gateway (apps/gateway) proxies requests to it at /media/*.
✅ What Works Fine
| Area | Status | Notes |
|---|---|---|
| R2 presigned URL generation | ✅ Working | Uses @aws-sdk/s3-request-presigner correctly |
mediaFiles DB record creation |
✅ Working | Inserts on presign, not on actual upload |
| File key structure | ✅ Good | {tenantId}/{service}/{year}/{month}/{nanoid}.{ext} |
| Public file URL construction | ✅ Working | PUBLIC_MEDIA_DOMAIN env var with fallback |
| Health check endpoint | ✅ Working | /health returns "Media Service OK" |
🔴 Critical Gaps
1. No Upload Completion Webhook / Status Update
Problem: The mediaFiles row is inserted with status: "pending_upload" during presign but is never updated to "uploaded". There is no callback endpoint that the client calls after the R2 PUT succeeds.
Impact:
- Every file in the DB is permanently
"pending_upload"regardless of whether it was actually uploaded - Querying
status = "uploaded"will return zero results - Stale "ghost" rows accumulate for failed or abandoned uploads
Fix needed: Add PATCH /upload/complete or POST /upload/confirm endpoint:
// PATCH /upload/complete
// Body: { mediaId: string }
// → updates mediaFiles.status = "uploaded" where id = mediaIdThe frontend should call this after a successful R2 PUT response.
2. Rolls Its Own JWT Verification (Not Using Gateway Auth Headers)
Problem: Both upload.ts and files.ts manually call verifyAccessToken(token, c.env.JWT_PUBLIC_KEY) — raw JWT parsing inside the worker. The rest of the platform uses the gateway middleware pattern where the gateway validates the JWT and injects x-user-id, x-tenant-id, x-user-permissions as headers.
Impact:
- Duplication of auth logic
- If the JWT algorithm or key ever changes, it must be updated in two places
- The media-service is not behind the gateway for auth — it is an orphaned auth island
JWT_PUBLIC_KEYmust be kept in sync as an env var in the media-service separately from the gateway
Fix needed: Remove verifyAccessToken calls. Read x-user-id and x-tenant-id from gateway-injected headers instead, matching the pattern in apps/manager:
const userId = c.req.header("x-user-id")
const tenantId = c.req.header("x-tenant-id")
if (!userId || !tenantId) return c.json({ error: "Unauthorized" }, 401)3. Workspace Logo Upload: Missing tenantId Scope for Non-Workspace Uploads
Problem: The presign endpoint requires payload.tenant_id to exist in the JWT. But the user avatar (/settings/profile) upload also goes through this endpoint — and a user might not have an active tenant context when updating their profile from /settings.
Impact: POST /upload/presign returns 403 No Tenant ID in token when called from the /settings route without a workspace context.
Fix needed: Make tenantId optional for user-level media (avatar), using userId as the scoping key:
// If no tenantId in token, default to user-scoped path
const scope = payload.tenant_id
? `${payload.tenant_id}/${service}`
: `users/${payload.user_id}/${service}`
const key = `${scope}/${year}/${month}/${nanoid()}.${ext}`Also make tenantId nullable in the mediaFiles.insert() call, or use a dedicated users fallback tenant.
4. TypeScript as any Casts in files.ts
Problem: files.ts uses as any in 5 places to bypass Drizzle type checking:
eq(mediaFiles.tenantId as any, tenantId)
.from(mediaFiles as any)
.orderBy(desc(mediaFiles.createdAt as any) as any)Impact: No immediate runtime failure, but TypeScript safety is lost. Any future schema changes to mediaFiles will silently break the query without a compile-time error.
Root Cause: Likely a Drizzle version mismatch or an import issue with the mediaFiles table reference.
Fix needed: Import mediaFiles directly from @repo/core-database and use proper Drizzle v2 .where() syntax without casts.
5. CORS Is Too Permissive
Problem: index.ts applies cors() with no configuration — allowing * origins with all methods.
app.use('/*', cors()) // Allows all originsImpact: Any website can make credentialed requests to the media service. Combined with the self-contained JWT auth (Gap #2), this is a potential upload abuse vector.
Fix needed: Restrict CORS to your own domains:
app.use('/*', cors({
origin: ['https://logicspike.com', 'http://localhost:3000'],
allowMethods: ['GET', 'POST', 'PATCH', 'OPTIONS'],
}))6. No File Listing Pagination
Problem: GET /files accepts a limit query param (default 20) but has no cursor or offset for pagination. For workspaces with many uploads, the endpoint will silently truncate results with no way to fetch the next page.
Fix needed: Add cursor-based pagination:
// Query: ?limit=20&cursor=<mediaId>
// Response: { files, nextCursor }7. No File Deletion Endpoint
Problem: There is no DELETE /files/:id endpoint. Uploaded files (including old workspace logos and old user avatars) can never be cleaned up from either R2 or the mediaFiles table.
Impact: R2 storage grows indefinitely. Every time a user updates their avatar or workspace logo, the old file remains in both R2 and the DB.
Fix needed: Add DELETE /files/:id that:
- Fetches the
mediaFilesrow and gets itskey - Calls
c.env.MEDIA_BUCKET.delete(key)to remove from R2 - Deletes the
mediaFilesrow
📋 Upgrade Priority
| Priority | Gap | Effort |
|---|---|---|
| 🔴 Critical | #3 — Tenant-optional uploads (blocks avatar upload from /settings) |
Medium |
| 🔴 Critical | #1 — Upload completion status (all files stuck in pending_upload) |
Low |
| 🟠 High | #2 — Switch to gateway header auth (remove JWT key duplication) | Medium |
| 🟠 High | #7 — File deletion endpoint (storage leak with logo/avatar changes) | Low |
| 🟡 Medium | #4 — Remove as any casts in files.ts |
Low |
| 🟡 Medium | #5 — Restrict CORS origins | Low |
| 🟢 Low | #6 — Pagination for GET /files |
Medium |
Recommended Implementation Order
- Fix #3 (tenant-optional key scoping) — unblocks avatar uploads from
/settingsright now - Fix #1 (add
PATCH /upload/complete) — makes status tracking actually work; update frontend presign flow to call this after R2 PUT - Fix #7 (add
DELETE /files/:id) — prevents storage leaks from logo/avatar updates - Fix #2 (move to gateway header auth) — cleanup; enables media-service to benefit from gateway-level auth improvements automatically
- Fix #4, #5, #6 — quality-of-life improvements