logicspike/docs

Media Service

Media Service — Vision & Product Spec

Last Updated: 2026-04-29 Status: Active Service: apps/media-service (Cloudflare Worker, port 8789)

The Media Service is LogicSpike's centralized file storage platform — the single layer through which every image, video, document, and asset in the system flows. It is to media what the Manager is to identity: the authoritative source of truth.


1. Purpose (The Why)

Every LogicSpike service produces or consumes media:

Service Media Use
Blog Cover images, inline images, OG share thumbnails
Content Engine Social media post images, video attachments
AI Chatbot Incoming customer media, product images in responses
Newsletter Campaign hero images, embedded graphics
Store (future) Product photos, gallery images, variant swatches
User Profile Avatar photos, workspace logos

Without a centralized media layer, each service would implement its own upload logic, storage keys, access control, and CDN delivery — duplicating security surface and fragmenting the user's asset library.

The Media Service exists to:

  1. Unify storage — One R2 bucket, one key namespace, one DB table.
  2. Centralize access control — PBAC-guarded uploads and deletions, gateway-verified identity.
  3. Provide a shared asset library — Users upload once, use everywhere. A blog cover image can be reused in a newsletter without re-uploading.
  4. Deliver fast — Cloudflare R2 with public read access via CDN, zero-egress costs.

2. Target Audience

Actor Role in Media
Workspace Owner Manages the full media library. Uploads, browses, deletes. Monitors storage usage.
Team Member (Editor) Uploads images while writing blog posts or scheduling content. Browses existing assets for reuse.
System (Internal Services) Content Engine resolves mediaId → public URL for social publishing. Blog service fetches cover image URLs.
Anonymous Customer Views public media via CDN URLs (never interacts with the service directly).

3. Core Features & Value Props

3.1 Current State (v1 — Shipped)

Feature Status Notes
Presigned upload (client-side PUT) ✅ Live 3-step flow: presign → R2 PUT → complete
Server-side URL ingest ✅ Live Mirror external images into R2 (markdown import)
File listing with pagination ✅ Live Cursor-based, filterable by service
File deletion (single) ✅ Live R2 + DB cleanup, ownership-checked
SSRF-protected ingest ✅ Live Private hostname/IP deny list, redirect re-validation
PBAC permissions ✅ Live media:files.read, media:files.write, system:owner bypass
Internal resolve API ✅ Live Service binding for mediaId → URL batch resolution
Inline MediaPicker in blog editor ✅ Live Upload + recent files grid in a side drawer

3.2 Target State (v2 — This Build)

Feature Priority Description
Media Library page 🔴 P0 Full-page file browser: grid/list view, search, type filters, multi-select
Media Overview dashboard 🔴 P0 Stats: total files, storage used, breakdown by type, recent activity
Inline delete 🔴 P0 Delete from MediaPicker and Library with confirmation
Bulk delete 🟠 P1 Multi-select → batch delete from Library
Filename search 🟠 P1 ILIKE search on GET /files
File detail view 🟠 P1 Side sheet: full preview, metadata, copy URL, download
MediaPicker upgrade 🟠 P1 Search, delete, pagination, type filters in the existing picker
Storage stats endpoint 🟡 P2 GET /files/stats — aggregated counts and byte totals
Stale upload cleanup 🟡 P2 Cron: garbage-collect pending_upload records older than 24h
Gateway key verification 🟡 P2 Verify x-gateway-key header on all endpoints

3.3 Future (v3+)

Feature Description
Image transformation On-the-fly resize, crop, format conversion via CF Image Resizing. Variants: thumb_200, og_1200x630, avatar_96
Video thumbnail extraction Auto-generate poster frame from first keyframe for video files
Storage quotas per plan Enforce plan-based limits. Free: 500 MB / 100 files. Pro: 10 GB / unlimited. Enforced at presign time
Folder/tag organization User-defined folders or tags for grouping assets. Flat namespace with virtual folder paths
Duplicate detection SHA-256 hash computed at upload-complete. Dedup prompt before re-uploading identical bytes
Drag-and-drop reorder Manual sort order for media galleries (Store product images)
CDN cache purge API Programmatic PURGE /cdn/:key to invalidate stale assets after overwrite
Audit log integration Emit media.uploaded, media.deleted events to the platform audit log
Virus/malware scanning ClamAV or Cloudflare R2 event notifications to scan uploads before marking uploaded
Webhook notifications POST to tenant-configured URL on upload/delete events for external system sync
Private file support Signed URLs with TTL for isPublic: false files (e.g., invoices, internal docs)

4. Architecture Overview

4.1 System Context

4.2 Upload Flow (Critical Path)

4.3 R2 Key Namespace

All files follow a deterministic path structure:

Tenant-scoped (workspace content):
  {tenantId}/{serviceCode}/{YYYY}/{MM}/{nanoid}.{ext}
  └── ls_abc123/blog/2026/04/xK9mQ2pL.webp
 
User-scoped (personal, no workspace context):
  users/{userId}/{serviceCode}/{YYYY}/{MM}/{nanoid}.{ext}
  └── users/user_789/avatar/2026/04/aB3nR7kW.jpg

5. Data Model

5.1 media_files Table

Column Type Nullable Description
id text PK N media_{nanoid}
tenant_id text FK → tenants Y Null for user-scoped uploads (avatar)
service_code text FK → services N Origin service: blog, content, avatar, etc.
file_name text N Original filename from the client
file_size integer N Size in bytes
mime_type text N Validated MIME type
key text N R2 object key (full path)
status text N pending_uploaduploaded
is_public boolean N Default true. Public files are CDN-readable
uploaded_by text Y User ID of the uploader
created_at timestamp N Auto-set on insert

5.2 Status Machine

[*] → PENDING_UPLOAD
        (presign creates the row)
 
PENDING_UPLOAD → UPLOADED
        (client calls /upload/complete after R2 PUT)
 
PENDING_UPLOAD → [garbage collected]
        (stale cleanup cron: 24h with no completion)
 
UPLOADED → [deleted]
        (user or system calls DELETE /files/:id)

5.3 Allowed MIME Types

Category MIME Types Max Size
Images image/jpeg, image/png, image/webp, image/gif, image/svg+xml, image/avif 10 MB
Videos video/mp4, video/webm 10 MB
Documents application/pdf 10 MB

6. Security Model

6.1 Authentication Chain

Browser → Gateway (JWT verify + subscription guard)
       → x-user-id / x-tenant-id / x-user-permissions headers
       → Media Service (reads headers, trusts gateway)

The media service does not verify JWTs. Identity is delegated to the gateway.

6.2 Permission-Based Access Control (PBAC)

Permission Grants
media:files.read List files, view file details
media:files.write Upload (presign + complete), delete, ingest URL
system:owner Universal bypass — can do anything

6.3 Ownership Enforcement

Beyond PBAC, the assertMediaAccess() function enforces:

  • Uploader match: record.uploadedBy === userId
  • Tenant match: record.tenantId === tenantId

A user must be the uploader OR a member of the same workspace to modify a file. system:owner bypasses this entirely.

6.4 SSRF Protection (URL Ingest)

The /upload/ingest-url endpoint fetches external URLs server-side. Guards:

  • Hostname deny list: localhost, *.local, *.internal
  • Private IP deny list: 127.*, 10.*, 192.168.*, 172.16-31.*, 169.254.*, ::1, fe80:, fc00:, fd00:
  • Redirect re-validation: Each redirect hop is re-checked (max 3 hops)
  • Known gap: No DNS rebinding protection (documented, acceptable for now)

6.5 Multi-Tenant Isolation Invariant

IMPORTANT

Every database query MUST include a WHERE tenant_id = ? clause. There are zero exceptions. Files uploaded by Tenant A must never appear in Tenant B's file list, even if they share the same team member.

R2 key isolation is structural — the {tenantId}/ prefix in every key makes cross-tenant object access impossible via path traversal. The DB layer reinforces this with the tenant_id filter on every query.

6.6 Content-Type Validation Defense

The service validates MIME types at two layers to prevent content-type confusion attacks:

  1. Client-declared contentType — Checked against the allowlist at presign time
  2. R2 Content-Type header — Set explicitly in the presigned PUT URL via PutObjectCommand({ ContentType }), so even if the client lies about the body, browsers will interpret the served file using the declared type

This prevents an attacker from uploading a .html file disguised as image/jpeg (stored XSS via CDN).

6.7 Rate Limiting

Scope Limit Window Action on Breach
Presign requests (per tenant) 60 1 minute 429 RATE_LIMITED — prevents upload flooding
Ingest-URL requests (per tenant) 20 1 minute 429 RATE_LIMITED — prevents SSRF abuse
File listing (per user) 120 1 minute 429 — standard API protection
Bulk delete (per tenant) 10 1 minute 429 — prevents accidental mass deletion
R2 direct PUT (per presigned URL) 1 lifetime URL expires after single use (1h TTL)

6.8 CORS Lockdown

// Production CORS — restrict to known origins only
cors({
    origin: [
        'https://app.logicspike.com',
        'https://logicspike.com',
        process.env.NODE_ENV === 'development' && 'http://localhost:3000',
    ].filter(Boolean),
    allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
    maxAge: 86400,
})

7. Error Handling Contract

Every error response follows the platform-wide error envelope:

{
  "error": "Human-readable message",
  "code": "MACHINE_READABLE_CODE",
  "status": 400
}

7.1 Error Codes

Code HTTP When
UNAUTHORIZED 401 Missing x-user-id header (gateway didn't inject identity)
FORBIDDEN 403 PBAC check failed — user lacks required permission
MEDIA_NOT_FOUND 404 mediaId does not exist in the database
OWNERSHIP_DENIED 403 User is not the uploader and not in the same tenant
UNSUPPORTED_MIME 400 File type not in the MIME allowlist
FILE_TOO_LARGE 400 File exceeds 10 MB limit
MISSING_FIELDS 400 Required body fields absent
SSRF_BLOCKED 400 Ingest URL points to a private/internal host
REDIRECT_LOOP 400 Source URL exceeded 3 redirect hops
RATE_LIMITED 429 Request rate exceeded for this tenant/user
QUOTA_EXCEEDED 402 Tenant storage quota exhausted (v3)
R2_UNAVAILABLE 502 R2 binding failed or credentials missing
DB_ERROR 500 Drizzle query failed — logged, not exposed

7.2 Client-Side Error Recovery

The frontend useMediaUpload hook implements retry logic:

Step Retry Strategy Max Retries
Presign (POST /upload/presign) Exponential backoff (1s, 2s, 4s) 3
R2 PUT (direct upload) Retry on network error only 2
Complete (PATCH /upload/complete) Retry on 5xx only 3
Delete (DELETE /files/:id) No retry (destructive) 0

8. Observability & Monitoring

8.1 Structured Logging

Every request emits a structured JSON log line:

{
  "level": "info",
  "event": "media.upload.presign",
  "tenantId": "ls_abc123",
  "userId": "user_789",
  "mediaId": "media_xK9mQ2pL",
  "mimeType": "image/webp",
  "fileSize": 245000,
  "durationMs": 42,
  "status": 200
}

8.2 Key Metrics (Cloudflare Analytics)

Metric Source Alert Threshold
Upload success rate status=200 on /upload/complete < 95% over 5 min
Presign latency p99 Worker duration > 500ms
R2 PUT error rate Client-reported failures > 5% over 5 min
Stale upload ratio pending_upload count vs uploaded > 20% daily
Storage growth rate SUM(file_size) daily delta > 1 GB/day (anomaly)
Delete operations DELETE /files/:id count Spike > 3x baseline

8.3 Health Check

GET /health → 200 { "status": "ok", "service": "media", "version": "1.0.0" }

The gateway's health aggregator polls this every 30 seconds.


9. CDN & Caching Strategy

9.1 R2 Public Bucket Delivery

Public media is served directly from R2's public access URL:

https://pub-a2acd43000854b76860fa1b2f86aa406.r2.dev/{key}

In production, this is fronted by a custom domain (media.logicspike.com) via Cloudflare DNS with automatic SSL.

9.2 Cache Headers

File Type Cache-Control Rationale
Images (jpeg, png, webp, avif) public, max-age=31536000, immutable Keys contain nanoid — content-addressed, never overwritten
Videos (mp4, webm) public, max-age=31536000, immutable Same — unique keys
PDFs public, max-age=86400 May be replaced (invoices) — shorter TTL
Presigned URLs No caching (ephemeral, 1h TTL) Security — unique per request

9.3 Why immutable?

Every uploaded file gets a unique nanoid-based key (xK9mQ2pL.webp). Files are never overwritten — updating a blog cover image creates a new key and deletes the old one. This means CDN caches never serve stale content without explicit purge, and immutable tells browsers to skip conditional requests entirely.


10. Cost Model

10.1 Cloudflare R2 Pricing (Current)

Resource Price Free Tier
Storage $0.015 / GB / month 10 GB
Class A ops (PUT, POST, LIST) $4.50 / million 1 million
Class B ops (GET, HEAD) $0.36 / million 10 million
Egress $0.00 (free) Unlimited

10.2 Projected Costs at Scale

Scale Files Storage Monthly Writes Monthly Reads Estimated Cost
100 tenants × 50 files 5K ~2 GB 1K 50K $0 (free tier)
1K tenants × 200 files 200K ~80 GB 10K 500K ~$1.05/mo
10K tenants × 500 files 5M ~2 TB 100K 5M ~$30/mo

NOTE

R2's zero egress pricing is why we chose it over S3. At 10K tenants serving images globally, S3 egress alone would cost ~$200/mo. R2 costs $0.

10.3 Neon Postgres Costs

The media_files table is lightweight (each row ~500 bytes). At 5M rows, the table occupies ~2.5 GB including indexes — well within Neon's free tier (3 GB storage).


11. Inter-Service Communication

Caller Binding Endpoint Purpose
Gateway MEDIA_SERVICE POST /upload/presign Client-facing upload flow
Gateway MEDIA_SERVICE PATCH /upload/complete Confirm upload completion
Gateway MEDIA_SERVICE POST /upload/ingest-url Server-side URL mirroring
Gateway MEDIA_SERVICE GET /files List files for dashboard
Gateway MEDIA_SERVICE DELETE /files/:id Delete file from R2 + DB
Content Engine MEDIA_SERVICE POST /internal/resolve Batch resolve mediaIds → public URLs

NOTE

/internal/resolve has no authentication. It is only reachable via Cloudflare service bindings (never over the public internet). This is the correct zero-trust pattern for Worker-to-Worker calls.


12. Frontend Vision

12.1 Media Module (New — /dashboard/media)

The Media module is a first-class service in the dashboard sidebar, equal in stature to Blog or Content Engine. It provides a centralized asset management experience.

Overview Page (/dashboard/media)

The landing page surfaces key metrics and recent activity:

  • Hero header — "Your media, organized." with total file count badge
  • Stats bar — 4 cells: Total Files, Storage Used, Images, Videos/Docs
  • Recent uploads — Last 8 files in a responsive grid
  • Quick upload zone — Drag & drop or click to upload

Library Page (/dashboard/media/files)

The full-power media browser:

  • Filter bar — MIME type groups (All / Images / Videos / Documents) + search input
  • View toggle — Grid view (thumbnails) / List view (table with metadata)
  • Grid view — Responsive masonry: grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5
  • File cards — Thumbnail preview, filename, size, date, service badge, hover actions
  • Multi-select — Checkbox mode for bulk operations. Sticky action bar at bottom.
  • Detail sheet — Click a file → side sheet with full preview, all metadata, copy URL, delete
  • Infinite scroll — Cursor-based pagination, loads more on scroll
  • Upload zone — Drag & drop at the top of the page

12.2 Enhanced MediaPicker (Existing — Side Drawer)

The MediaPicker component (used by blog editor, cover image picker, etc.) gets upgraded from a basic "upload + 20 thumbnails" to a mini media browser:

Before (v1) After (v2)
No search Search by filename
No delete Delete button on each file (with confirm)
No pagination (hardcoded 20) "Load more" with cursor
No type filters Tabs: All / Images / Videos
No metadata on hover Tooltip: name, size, date
240px scroll area 400px scroll area
Broken API URL (missing /media prefix) Fixed: uses Next.js API proxy routes

12.3 Navigation

Dashboard Sidebar
├── ...
├── Media               ← NEW (was "coming_soon", now "stable")
│   ├── Overview         /dashboard/media
│   └── Library          /dashboard/media/files
├── ...

13. Implementation Phases

Phase 1 — Backend Hardening (Day 1)

Task Files Effort
Fix inconsistent PUBLIC_MEDIA_DOMAIN defaults upload.ts, files.ts 15 min
Fix dynamic import in DELETE handler files.ts 5 min
Add mediaFilesRelations to schema schema.ts 10 min
Add GET /files/stats endpoint files.ts 30 min
Add POST /files/bulk-delete endpoint files.ts 30 min
Add ?search= param to GET /files files.ts 20 min

Phase 2 — Dashboard API Routes (Day 1)

Task Files Effort
GET /api/media/files proxy app/api/media/files/route.ts 10 min
GET /api/media/files/stats proxy app/api/media/files/stats/route.ts 10 min
DELETE /api/media/files/[id] proxy app/api/media/files/[id]/route.ts 10 min
POST /api/media/files/bulk-delete proxy app/api/media/files/bulk-delete/route.ts 10 min
POST /api/media/upload/presign proxy app/api/media/upload/presign/route.ts 10 min

Phase 3 — Media Module Frontend (Day 2–3)

Task Files Effort
API layer (media.api.ts) modules/media/api/ 30 min
State hook (useMediaLibrary.ts) modules/media/hooks/ 45 min
Media Overview page modules/media/pages/MediaOverview.tsx 2 hr
Media Files page (Library) modules/media/pages/MediaFiles.tsx 3 hr
6 components (Grid, Card, Stats, Upload, Detail, Delete) modules/media/components/ 3 hr
Dashboard routes + ServiceGuard wiring app/dashboard/media/ 15 min
Service registry: coming_soonstable lib/service-registry.ts 5 min

Phase 4 — MediaPicker Upgrade (Day 3)

Task Files Effort
Add search, delete, pagination, type filters components/media/MediaPicker.tsx 2 hr
Widen MediaDrawer for better browsing components/media/MediaDrawer.tsx 10 min

Phase 5 — Security Hardening (Day 4)

Task Files Effort
Verify x-gateway-key on all endpoints middleware/ 30 min
Add stale pending_upload cleanup cron index.ts + wrangler.toml 45 min

14. Design Language

The Media module follows the established LogicSpike dashboard aesthetic:

Element Style
Corners rounded-none (sharp edges throughout)
Text labels text-[10px] uppercase tracking-[0.22em] text-muted-foreground
Section headings Numbered: 01 — Overview, 02 — Library
Numbers tabular-nums, zero-padded: 04, 12
Buttons rounded-none, uppercase tracking, h-8 / h-9
Cards Border-grid: border-t border-l border-border with border-r border-b per card
Hover Cursor spotlight gradient, left accent strip, background tint
Empty states Dashed border, diagonal stripe pattern, CTA button
Animations animate-in fade-in duration-500 on page mount

15. Known Gaps & Technical Debt

WARNING

These issues exist in the current codebase and should be addressed during or after the v2 build.

Issue Severity Resolution Phase
No stale pending_upload cleanup 🔴 Critical Add cron job (24h TTL sweep) Phase 5
No x-gateway-key verification 🔴 Critical Add middleware to verify shared secret from gateway Phase 5
Inconsistent PUBLIC_MEDIA_DOMAIN defaults 🟠 High Unify to single env var with no fallback in prod Phase 1
Dynamic import in DELETE handler 🟡 Medium Replace with static import at module top Phase 1
Missing mediaFilesRelations in schema 🟡 Medium Add to enable Drizzle relational queries Phase 1
No updatedAt column on media_files 🟡 Medium Add column + trigger for audit trail Phase 1
size defaults to 0 if client omits it 🟢 Low Backfill via R2 HEAD requests; enforce at presign Future
No rate limiting on upload endpoints 🟢 Low Implement CF Rate Limiting rules (see §6.7) Future
MIME allowlist is hardcoded 🟢 Low Move to env var or per-service config table Future
No DNS rebinding protection in SSRF guard 🟢 Low Add post-resolve IP check or CF egress rules Future
No Content-Disposition on downloads 🟢 Low Set attachment; filename="original.jpg" header Future

16. Edge Cases & Error Paths

Scenario System Behavior
User closes tab during R2 PUT Row stays pending_upload. Stale cleanup cron garbage-collects after 24h. No orphan bytes in R2 (PUT was never completed).
R2 PUT succeeds but /complete call fails Client retries /complete up to 3 times. If all fail, row stays pending_upload but file exists in R2. Cron deletes the DB row; R2 bytes remain (acceptable — no metadata leak).
Presigned URL used after 1h expiry R2 returns 403 Forbidden. Client shows "Upload expired, please try again." and re-triggers presign.
Two users upload identical filename Each gets a unique nanoid key. No collision possible. Both files coexist in the library.
Tenant deleted / workspace removed Cascade: all media_files rows for that tenant_id are soft-deleted. R2 cleanup runs as a background job to reclaim storage.
R2 bucket binding missing in dev MEDIA_BUCKET check returns 500 R2_UNAVAILABLE. Dev sees clear error in console.
Malicious SVG with embedded script SVG is served with Content-Type: image/svg+xml. CSP headers on the CDN domain prevent script execution. Dashboard renders SVGs in sandboxed <img> tags (no inline execution).
Concurrent bulk delete + file listing Listing uses cursor-based pagination. Deleted files may appear in already-fetched pages but 404 on click. Acceptable eventual consistency.
Ingest URL returns 10MB+ after lying about Content-Length safeFetch reads into buffer, checks buf.byteLength > MAX_FILE_SIZE_BYTES, rejects with FILE_TOO_LARGE.

17. Disaster Recovery

Failure Mode Recovery Strategy RTO
Neon Postgres outage Neon has automatic failover + point-in-time recovery (PITR). Media service returns 500 on DB calls; uploads queue on client with retry. < 5 min
R2 regional outage R2 is globally distributed with automatic replication. Cloudflare's SLA guarantees 99.9% availability. CDN serves cached assets during outage. < 10 min
Accidental bulk deletion R2 does not support versioning (yet). Recovery requires Neon PITR to restore media_files rows + re-upload from source. Recommendation: Enable R2 object lifecycle rules for 30-day soft-delete. Manual
Media service Worker crash Cloudflare auto-restarts Workers. Zero-downtime deploys via Wrangler. Gateway health check detects and routes to fallback. < 1 min
Database schema migration failure Drizzle migrations are idempotent. Rollback via drizzle-kit drop + re-apply. Media service stays up with old schema until migration succeeds. < 15 min

18. Cross-References

Document Link Relevance
Media Service Audit (Feb 2026) media-service-audit.md Original compatibility audit; most gaps resolved in v1
System Architecture system-architecture.md Full platform service map showing media in context
Documentation Standards documentation-standards.md Formatting and diagram rules this doc follows
Engineering Standards engineering-standards.md Code quality patterns and review checklist
Gateway Proxy Routes media.proxy.ts How dashboard requests reach the media service
Core Database Schema schema.ts media_files table definition
Blog MediaPicker MediaPicker.tsx Primary consumer of the media upload API
Service Registry service-registry.ts Dashboard sidebar navigation config
Media Service