logicspike/docs

Media Service

Media Service — Compatibility Audit

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 check

The 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 = mediaId

The 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_KEY must 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 origins

Impact: 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:

  1. Fetches the mediaFiles row and gets its key
  2. Calls c.env.MEDIA_BUCKET.delete(key) to remove from R2
  3. Deletes the mediaFiles row

📋 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

  1. Fix #3 (tenant-optional key scoping) — unblocks avatar uploads from /settings right now
  2. Fix #1 (add PATCH /upload/complete) — makes status tracking actually work; update frontend presign flow to call this after R2 PUT
  3. Fix #7 (add DELETE /files/:id) — prevents storage leaks from logo/avatar updates
  4. Fix #2 (move to gateway header auth) — cleanup; enables media-service to benefit from gateway-level auth improvements automatically
  5. Fix #4, #5, #6 — quality-of-life improvements
Media Service