← Index

"--- title: The Universal Music Vault - Implementation Plan author: Chloe date: 2026-05-10 category: Dev tags: documentation, proposal,

Universal Music Vault — Implementation Plan

Context

Extending the existing platform at /Users/byegge/Documents/GitHub/WEBSITES/platform into a full Universal Music Vault — a centralized music catalog, publishing system, and streaming portal powering multiple artist sites (Yegge, Angershade, The Corruptive) with a shared backend.

The core audio streaming platform is already built and compiles cleanly. This plan adds:


Phase 1 — Foundation

1a. Install packages

cd platform
npm install react-markdown remark-gfm @tailwindcss/typography

1b. Schema additions — prisma/schema.prisma

New enums (append after existing enums):

enum ReviewStatus { PENDING  APPROVED  REJECTED }
enum EntityType   { TRACK  ALBUM  BLOG_POST  MEDIA_FILE }

New fields on existing models:

New models:

BlogPost      — id, artistId, slug, title, content (markdown), embedUrl?, coverKey?,
                seoTitle?, seoDescription?, visibility, publishedAt?, createdBy,
                reviewStatus @default(PENDING), createdAt, updatedAt
                @@unique([artistId, slug])  @@index([artistId, visibility, publishedAt])

BlogTag       — id, name, slug @unique

BlogPostTag   — @@id([blogPostId, tagId])  (join table)

MediaFile     — id, artistId, filename, objectKey, mimeType, sizeBytes BigInt,
                alt?, caption?, createdBy, createdAt
                @@index([artistId, createdAt])

ReviewQueueItem — id, entityType, entityId, artistId, submittedBy, submittedAt @default(now()),
                  status @default(PENDING), reviewedBy?, reviewedAt?, notes?, targetVisibility?
                  @@index([status, submittedAt])  @@index([artistId, status])

Back-relations to add:

Migration:

npx prisma migrate dev --name add_universal_music_vault
# Edit generated SQL to add backfill before running:
UPDATE "Track" SET "reviewStatus" = 'APPROVED' WHERE "visibility" IN ('PUBLIC','LISTED');
UPDATE "Album" SET "reviewStatus" = 'APPROVED' WHERE "visibility" IN ('PUBLIC','LISTED');
npx prisma generate

1c. src/lib/r2.ts — append 4 helpers:

getMediaSignedPutUrl(objectKey, contentType, expiresIn=300)   // uses masters bucket
getMediaSignedGetUrl(objectKey, expiresIn=3600)               // uses masters bucket
blogCoverKey(artistId, postId, ext)  → `blog-covers/{artistId}/{postId}.{ext}`
mediaFileKey(artistId, fileId, filename) → `media/{artistId}/{fileId}/{filename}`

1d. src/lib/permissions.ts — append:

export function requiresReview(role?: string): boolean
  // true if role === "COLLABORATOR"

export async function createReviewQueueItem(
  entityType, entityId, artistId, submittedBy, targetVisibility?, tx?
): Promise<void>
  // upsert-safe: only creates if no existing PENDING item for same entity

1e. src/lib/utils.ts — append:

// BigInt serializer for JSON responses containing sizeBytes
export const bigIntReplacer = (_: string, v: unknown) =>
  typeof v === "bigint" ? v.toString() : v;

// Embed URL allowlist check
const ALLOWED_EMBED_HOSTS = ["youtube.com","youtu.be","vimeo.com","spotify.com","soundcloud.com","open.spotify.com","player.vimeo.com","www.youtube.com"];
export function isAllowedEmbedUrl(url: string): boolean

1f. src/middleware.ts — add ADMIN_DOMAIN check before existing /admin check:

const adminDomain = process.env.ADMIN_DOMAIN?.toLowerCase();
if (adminDomain && domain === adminDomain) {
  return NextResponse.next(); // admin layout handles its own auth
}

1g. tailwind.config.tsplugins: [require("@tailwindcss/typography")]

1h. .env.example — add ADMIN_DOMAIN=vault.yegge.com


Phase 2 — API Routes

All new routes follow existing conventions: auth()assertArtistAccess() → Zod parse → Prisma → NextResponse.json().

Blog routes

Method Path Auth Notes
GET /api/artists/[artistId]/blog public sees PUBLIC+APPROVED filtered by role
POST /api/artists/[artistId]/blog write if Collaborator: visibility held PRIVATE, reviewStatus=PENDING, queue item created
GET /api/artists/[artistId]/blog/[postId] public only if PUBLIC+APPROVED
PATCH /api/artists/[artistId]/blog/[postId] write Collaborator visibility changes → queue; Admin → direct
DELETE /api/artists/[artistId]/blog/[postId] admin delete R2 coverKey too
POST /api/artists/[artistId]/blog/[postId]/publish write body: {visibility}; Collaborator queues, Admin publishes directly
POST /api/artists/[artistId]/blog/[postId]/upload-cover write returns {uploadUrl, objectKey}
GET/POST /api/artists/[artistId]/blog/tags GET: open; POST: admin upsert-on-name

Media routes

Method Path Auth Notes
GET /api/artists/[artistId]/media write list MediaFiles
POST /api/artists/[artistId]/media/upload-url write body: {filename, contentType}; returns {uploadUrl, objectKey, fileId}
POST /api/artists/[artistId]/media/confirm write body: {fileId, objectKey, filename, mimeType, sizeBytes, alt?, caption?}; creates MediaFile; 409 on duplicate fileId
GET /api/artists/[artistId]/media/[mediaId]/url write returns short-lived signed GET URL
DELETE /api/artists/[artistId]/media/[mediaId] write deletes R2 object + DB record

Review Queue routes

Method Path Auth Notes
GET /api/admin/review-queue admin all PENDING items across all artists
POST /api/admin/review-queue/[itemId]/approve admin body: {targetVisibility?}; tx: approve item + update entity visibility
POST /api/admin/review-queue/[itemId]/reject admin body: {notes?}; tx: reject item + set entity reviewStatus=REJECTED

Phase 3 — UI Components

New UI primitives

New admin components

New public components


Phase 4 — Admin Pages

New pages

Modified pages


Phase 5 — Public Pages


Phase 6 — Branding


Critical Edge Cases

  1. BigInt in JSON: Use bigIntReplacer in all routes returning MediaFile.sizeBytes
  2. Review visibility hold: Collaborator publish sets visibility = PRIVATE + reviewStatus = PENDING; approve uses targetVisibility stored in ReviewQueueItem
  3. Queue deduplication: createReviewQueueItem checks for existing PENDING item on same entity before inserting
  4. iframe allowlist: embedUrl validated server-side against ALLOWED_EMBED_HOSTS in isAllowedEmbedUrl()
  5. Tag upsert: BlogTag slug is globally unique; POST tags endpoint upserts-on-slug instead of erroring
  6. Migration backfill: Existing PUBLIC/LISTED tracks and albums must be set to reviewStatus = APPROVED in migration SQL
  7. Admin domain auth: vault.yegge.com middleware passes through; admin layout handles auth via its own auth() check (already present in (admin)/admin/layout.tsx)
  8. Cover key cleanup on delete: DELETE blog post endpoint must call deleteMasterObject(post.coverKey) if set

Files to Create

src/app/api/artists/[artistId]/blog/route.ts
src/app/api/artists/[artistId]/blog/[postId]/route.ts
src/app/api/artists/[artistId]/blog/[postId]/publish/route.ts
src/app/api/artists/[artistId]/blog/[postId]/upload-cover/route.ts
src/app/api/artists/[artistId]/blog/tags/route.ts
src/app/api/artists/[artistId]/media/route.ts
src/app/api/artists/[artistId]/media/upload-url/route.ts
src/app/api/artists/[artistId]/media/confirm/route.ts
src/app/api/artists/[artistId]/media/[mediaId]/route.ts
src/app/api/artists/[artistId]/media/[mediaId]/url/route.ts
src/app/api/admin/review-queue/route.ts
src/app/api/admin/review-queue/[itemId]/approve/route.ts
src/app/api/admin/review-queue/[itemId]/reject/route.ts
src/app/(admin)/admin/review-queue/page.tsx
src/app/(admin)/admin/artists/[id]/blog/page.tsx
src/app/(admin)/admin/artists/[id]/blog/new/page.tsx
src/app/(admin)/admin/artists/[id]/blog/[postId]/edit/page.tsx
src/app/(admin)/admin/artists/[id]/media/page.tsx
src/app/(artist)/blog/page.tsx
src/app/(artist)/blog/[slug]/page.tsx
src/components/ui/Textarea.tsx
src/components/ui/Select.tsx
src/components/ui/Tabs.tsx
src/components/admin/ReviewQueuePanel.tsx
src/components/admin/BlogPostEditor.tsx
src/components/admin/MediaLibraryGrid.tsx
src/components/artist/BlogCard.tsx
src/components/artist/BlogContent.tsx

Files to Modify

prisma/schema.prisma             — additive only (enums, fields, models)
src/middleware.ts                — ADMIN_DOMAIN check
src/lib/r2.ts                   — 4 new helpers
src/lib/permissions.ts          — requiresReview, createReviewQueueItem
src/lib/utils.ts                — bigIntReplacer, isAllowedEmbedUrl
src/app/layout.tsx              — title
src/app/(admin)/admin/layout.tsx — async, pending count query
src/app/(admin)/admin/artists/[id]/page.tsx — Tabs component
src/components/admin/Sidebar.tsx — Review Queue nav + badge
src/app/(artist)/layout.tsx     — Albums/Blog nav links
tailwind.config.ts              — typography plugin
.env.example                    — ADMIN_DOMAIN
package.json                    — new deps

Verification

  1. npx prisma migrate dev runs clean — no errors
  2. npm run build passes with zero TypeScript errors
  3. Seed DB → visit localhost → see Yegge artist page with Albums + Blog nav
  4. Create a blog post as Admin → appears at /blog
  5. Create a blog post as Collaborator → stays hidden at /blog, appears in review queue
  6. Admin approves from review queue → post appears at /blog
  7. Upload media file → appears in admin media library grid
  8. vault.yegge.com (add to /etc/hosts for local test) → loads admin panel directly

Brian Yegge