logicspike/docs

Media Service

Media Service — Vision & Product Spec

Last Updated: 2026-10-12 (storage and security sections); the rest dates from 2026-04-29 Status: Active Service: apps/media-service (Cloudflare Worker, port 8801)

IMPORTANT

Storage and access changed on 2026-10-12 (branch media/private-storage, rollout pending). Only public files live in the public bucket; every other file is in a private bucket and leaves media only through the authenticated content route, a short-lived signed link, or the service binding. Where this spec and 2026-10-12-access-audit.md disagree, the audit is right. Sections 5, 6, 9, 11, 15 and 17 below are updated; the product sections are the original vision.

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_upload → uploaded → archived
visibility text N public (anyone with the URL), tenant (workspace members), private (the uploader), delegated (only the service named in acl_service_code decides)
storage text N Which bucket holds the bytes: public or private. Readers follow this, never visibility (2026-10-12)
listable boolean N false keeps a row out of the Library (form uploads, system blobs)
acl_service_code / acl_ref_id text Y For delegated: the owning service and its subject (mailbox account id, form submission id)
is_public boolean N Legacy flag, kept in step with visibility = 'public'
uploaded_by text Y User ID of the uploader
content_sha256 text Y SHA-256 of the stored bytes, computed by media ("already in your library"); - = couldn't hash (Library v2)
source_sha256 text Y SHA-256 of the file as picked, before the image editor; browser-supplied, matches only the uploader's own files
dhash text Y 64-bit visual fingerprint of an image ("looks similar"), browser-supplied
alt_text text Y Alt text, prefilled where the file is used
width / height integer Y Pixel size read by the browser at upload (display only)
created_at / updated_at timestamp N Auto-set

Also: media_file_tags (media_id, tenant_id, tag) and media_file_favorites (media_id, user_id, tenant_id), both removed with their file. Migration manual-migrations/2026-10-13-media-library-v2.sql.

5.2 Status Machine

[*] → PENDING_UPLOAD
        (presign creates the row)
 
PENDING_UPLOAD → UPLOADED
        (client calls /upload/complete after the R2 PUT; media checks the object
         exists at the declared size)
 
PENDING_UPLOAD → [removed]
        (MediaJanitor: 24h with no completion)
 
UPLOADED → ARCHIVED
        (DELETE /files/:id, bulk delete)
 
ARCHIVED → [removed]
        (permanent delete; or MediaJanitor 30 days after archiving, non-public only)
 
UPLOADED (delegated) → [removed]
        (the owning service releases it: mailbox after sending, forms when the
         submission is deleted; unsent mail attachments after 30 days)

Bytes are always deleted before the row, and a failed byte delete keeps the row. The cleanup runs on a Durable Object alarm (src/do/media-janitor.ts), because the account has no cron slots left; the old scheduled handler never ran.

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

Reading is decided by canReadMedia and managing by canManageMedia (packages/core-media):

  • Read: public → anyone; tenant → members of that workspace; private → the uploader; delegated → only with a capability token from the owning service, bound to the user, workspace and subject (v2 tokens, one signing key per service).
  • Manage (archive, delete, restore): the uploader, inside the file's own workspace; or media:files.admin / system:owner in that workspace. Having uploaded a file to a workspace you've left grants nothing (fixed 2026-10-12).
  • Single-file routes look a file up in the caller's workspace only: another workspace's id reads as 404.
  • Change visibility (PATCH /files/:id/visibility, canChangeMediaVisibility): Media library uploads only (files added by Blog, Social, Newsletter or Collections keep the visibility their feature set; form and mail files are service-managed). The uploader may choose Public, Workspace or Only me; media:files.admin / system:owner may switch a colleague's file Public ↔ Workspace but never into or out of Only me. Crossing public moves the bytes between buckets (copy → switch if unchanged → delete the old copy, or undo). Audit G24.

6.4 SSRF Protection (URL Ingest)

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

  • Hostname deny list: localhost, *.local, *.internal
  • Private IPv4: 0/8, 10/8, 127/8, 100.64/10, 169.254/16, 172.16/12, 192.0.0/24, 192.168/16, 198.18/15, 224+
  • Private IPv6 (brackets stripped first; the old pattern never matched because URL.hostname keeps them): ::, ::1, fe80::/10, fc00::/7, 64:ff9b::/96, and IPv4-mapped ::ffff: addresses judged by the IPv4 inside
  • 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

Only public files are in the public bucket (logicspike-media-local), served at:

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

There is no custom domain (media.vlozi.app has no DNS); moving off r2.dev is gap G6 in the audit. Every other file is in vlozi-media-private, which has no public access: a browser gets it through /files/:id/content, a third party through a presigned link (1 hour by default, up to 7 days).

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 Archive a file (permanent delete is /files/:id/permanent)
Gateway MEDIA_SERVICE PATCH /files/:id/visibility, POST /files/bulk-visibility Change who can see Library files (moves bytes across buckets when public changes)
Gateway MEDIA_SERVICE GET /files?type=&sort=&tag=&favorites=1 List with server filters and keyset paging (opaque cursor)
Gateway MEDIA_SERVICE PATCH /files/:id Rename (extension kept valid for the type) / alt text
Gateway MEDIA_SERVICE POST /files/match "Already in your library?" by fingerprint; similar[] by dHash
Gateway MEDIA_SERVICE POST /files/:id/share-link 1- or 7-day link to a Workspace/Only-me file
Gateway MEDIA_SERVICE POST /files/:id/replace Swap in a new version; previous version archived
Gateway MEDIA_SERVICE PUT /files/:id/tags, GET /files/tags, PUT/DELETE /files/:id/favorite Tags and per-person stars
Gateway MEDIA_SERVICE GET /files/:id/usage-terms What "Used in" searches for (id + storage key; the gateway 404s this route to browsers); the gateway's GET /media/files/:id/usage then asks blog, newsletter, communication (sender settings), content-engine and collections
Content Engine MEDIA_SERVICE POST /internal/resolve mediaIds → links: CDN URL for public files, a signed link (6 h) otherwise
Forms MEDIA_SERVICE POST /internal/upload, /internal/resolve, /internal/release Store a form upload (delegated to the submission); 7-day / 1-hour links for webhooks and MCP; release on submission delete
Mailbox MEDIA_SERVICE POST /internal/upload, /internal/read, /internal/release Stage an attachment; read its bytes at send time (own files, sendable mailboxes only); release once stored with the message

NOTE

/internal/* requires x-internal-token (INTERNAL_SERVICE_TOKEN, comma-separated for rotation, constant-time compare), and media has no workers.dev URL. The token is shared by every caller, so the caller's own claims (tenant, user, service) are trusted: gap G1 in the audit.


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, and a Workspace / Only me / Public switch (asks before making a file public or breaking its public link)
  • Infinite scroll — Cursor-based pagination, loads more on scroll
  • Upload zone — Drag & drop at the top of the page, with a Workspace / Only me / Public switch (starts at Workspace every visit)

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_soon → stable 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

The current gap log is in 2026-10-12-access-audit.md §7. The table below is the April list; rows marked ✅ are done.

Issue Severity Resolution Phase
✅ No stale pending_upload cleanup 🔴 Critical MediaJanitor Durable Object (2026-10-12) Done
✅ No x-gateway-key verification 🔴 Critical gateway-key middleware, constant-time, rotation list Done
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 Required at presign, signed into the PUT, checked by HEAD at /complete Done
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. The janitor removes it (and any partial object) after 24h.
R2 PUT succeeds but /complete call fails Row stays pending_upload with the object in R2. The janitor deletes the object first, then the row, after 24h: no untracked bytes.
Upload a different size than declared R2 refuses the PUT (the size is signed in); if an object somehow lands at another size, /complete deletes it and the row (400 SIZE_MISMATCH).
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
Private bucket binding missing Every non-public read and write answers 500 (fails closed); media never falls back to the public bucket. Re-add the binding and redeploy. < 15 min
Leaked signed link Valid until it expires (≤ 7 days). Only rotating the R2 API key (R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY) revokes every outstanding link at once. Manual
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
Access audit + private storage (Oct 2026) 2026-10-12-access-audit.md Current storage/security model, rollout runbook, gap log
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