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:
- Unify storage — One R2 bucket, one key namespace, one DB table.
- Centralize access control — PBAC-guarded uploads and deletions, gateway-verified identity.
- Provide a shared asset library — Users upload once, use everywhere. A blog cover image can be reused in a newsletter without re-uploading.
- 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.jpg5. 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:ownerin 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:ownermay 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.hostnamekeeps 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:
- Client-declared
contentType— Checked against the allowlist at presign time - R2
Content-Typeheader — Set explicitly in the presigned PUT URL viaPutObjectCommand({ 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 |