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