Slice 1 sketch · runs in your browser

Playback, accounts and creator stats that actually work

A working model of the three hard parts of a creator-first streaming backend: a signed Mux playback URL with the key kept server-side, a watch-time ledger that can split revenue by creator later, and the review to live content flow driven by Mux webhooks. Stack: TypeScript, Next.js API routes, Supabase, Mux, Vercel.

1. Signed playback: the key never reaches the frontend

The browser calls GET /api/playback/:episodeId with its Supabase session. The route checks the viewer, the episode status and the kids-profile rule, then signs a short-lived JWT with the private key from the Vercel environment. Only the token goes back.

// app/api/playback/[episodeId]/route.ts  (server only)
import Mux from '@mux/mux-node'
const mux = new Mux({ jwtSigningKey: process.env.MUX_SIGNING_KEY_ID,
                      jwtPrivateKey: process.env.MUX_SIGNING_PRIVATE_KEY })
export async function GET(req, { params }) {
  const user = await requireUser(req)                     // Supabase Auth
  const ep = await db.episode(params.episodeId)
  if (!ep || ep.status !== 'live') return json(404)
  if (user.profile.kind === 'kid' && !ep.kids_safe) return json(403)
  const token = await mux.jwt.signPlaybackId(ep.mux_playback_id,
                      { type: 'video', expiration: '2h', params: { user: user.id } })
  return json({ url: `https://stream.mux.com/${ep.mux_playback_id}.m3u8?token=${token}` })
}
Press the first button. The demo generates a throwaway RSA key pair inside this tab and plays the role of the server.

2. Content status: review to live, driven by Mux webhooks

-- states: draft → processing → review → live   (+ errored)
create table episodes (
  id uuid primary key default gen_random_uuid(),
  series_id uuid references series(id),
  mux_upload_id text, mux_asset_id text unique, mux_playback_id text,
  status text not null default 'draft'
    check (status in ('draft','processing','review','live','errored')),
  updated_at timestamptz default now());
-- webhook: verify Mux-Signature header (HMAC) → idempotent by event id
-- video.asset.ready   → status = 'review'   (creator/admin then flips to 'live')
-- video.asset.errored → status = 'errored'  + reason stored
status: draft

3. Watch time you can split revenue on later

Store facts, not totals. One row per heartbeat (user, episode, seconds, day), with the creator id copied onto the row at write time so a later ownership change never rewrites history. Earnings stay a placeholder until payouts exist; the split is just a query.

create table watch_events (
  id bigserial primary key,
  user_id uuid not null, profile_id uuid not null,
  episode_id uuid not null, creator_id uuid not null,   -- denormalised on purpose
  seconds int not null check (seconds between 1 and 60),
  session_id uuid not null, seq int not null,
  day date not null default current_date,
  unique (session_id, seq));                            -- retries never double count
-- creator dashboard
select creator_id, count(distinct session_id) views, sum(seconds)/3600.0 hours
from watch_events where day >= now() - interval '30 days' group by 1;
-- revenue split for a pool P:  P * sum(seconds of creator) / sum(seconds of all)
CreatorViewsWatch time, minShareEst. earnings, EUR

What Slice 1 covers

Auth and profilesSupabase Auth, profiles with kind viewer / kid / creator, row level security on every table
Catalogseries and episodes tied to Mux playback ids
Playbacksigned endpoint above, keys only in server env
Webhooksasset ready / errored, signature checked, idempotent
Eventswatch_events ledger, batched from the player every 30 s
Creator APIviews, watch time, earnings placeholder; content status flow
Frontendplugs into the existing preview through typed route handlers, no UI work from us

GuardLabs demo. Browser-only model with generated keys and fake data; nothing is sent anywhere.