Skip to content

Slug Aliases & the Redirect Net

Epic 17's one principle: machine URLs get IDs, human URLs get slugs. The feed URL — the one address in podcasting that must never move — is keyed by the podcast UUID (rss.cdn.media/{podcast_id}). Everything humans read ({slug}.show.fm, /p/{slug}/e/{episode-slug}, booking pages) stays a slug, and BECAUSE the feed no longer depends on it, slugs are safely renameable behind this layer.

Authoritative schema: supabase/migrations/20260826103810_epic17_slug_alias_layer.sql. Plan + rulings: docs/planning/epics/epic-17/.

The data layer

  • podcast_slug_aliases (global namespace, shared with podcasts.slug): every previously-live show slug, permanent. podcast_id set = redirect alias; podcast_id NULL = a deleted show's tombstone, reclaimable only by reserved_for_account (its old billing account — delete-and-recreate keeps working; everyone else is blocked forever, because a dead show's residual subscribers still poll its URLs).
  • episode_slug_aliases (scoped per podcast): minted only when an ever-published episode is renamed (a draft's address simply stops existing). Cascades with its owner — the namespace is per podcast, so reuse can only reach the same tenant.
  • slug_rename_events: the 3-per-rolling-30-days rate limit counts rename events, not alias rows (renaming back reuses a row).
  • Renames happen ONLY through rename_podcast_slug() / rename_episode_slug() — SECURITY DEFINER, executable by service_role only, taking the validated user as p_actor. The server layer (settings action / episode actions) owns the session, the mutable-admin gate and the TS-only slug guard; the RPC owns atomicity: namespace advisory locks, cross-table uniqueness, alias minting, the rate limit, and the stored notifications.link rewrite.
  • Write-path triggers on podcasts.slug / episodes.slug reject UUID-shaped slugs (the shape is reserved for ID routing), enforce uniqueness against the alias tables (ERRCODE 23505), and block direct Data API slug changes — the old "frozen in the UI" freeze was bypassable via raw PostgREST.

The redirect net (resolution law: live slug first, alias second)

SurfaceOn an alias hit
rss.cdn.media/{slug}permanent 301 to the ID URL (workers/rss-feedresolveFeedPodcastId), cacheable, monitored forever
Dashboard /p/{slug}, /p/{slug}/e/{episodeSlug}303 to the canonical path (src/lib/server/require-podcast-access.ts, workspace-episode.ts) — stale bookmarks, teammate tabs and stored notification links self-heal
Listen {slug}.show.fm (+ path-style) and episode pages301 (src/lib/server/listen-redirects.ts)
Booking book.show.fm/{podcastSlug}/{linkSlug}301 — already-sent invitations survive
Public API /v1/podcasts/:slug..., embed /latest/:slug301 (workers/public-api); oEmbed retries inline with the canonical slugs

Cache keys moved with the feed: KV feed:{id}, R2 snapshots keyed by ID (dr-replicator and the nightly reconcile follow), feed_requests blob1 = the ID (rollup accepts legacy slug rows during the WAE transition window, resolving them through the alias table so a renamed show keeps its pre-rename traffic).

Rename UX

Save handlers goto() the canonical URL in the same breath (replaceState), so the active tab never lands on a dead route; every other holder of the old URL goes through the net above. The (app) layout re-derives the pp_last_podcast cookie from the route on that navigation — it stays the cookie's only writer.

Episode addresses (GATE-3)

Episodes never had an address surface; the slug was minted from the first title (or a booking's recording-… stamp) and shipped unseen. Now: the Details tab carries the address card, whose change flow opens the shared SidePanel (right-hand slide-over on desktop, bottom sheet on mobile, actions pinned in the sticky footer), the Publishing card shows the address before anything goes live with a one-click use-my-title rename, and recording-… addresses auto-apply the title-derived slug at publish or schedule (at publish, after the status flip, so the outgoing slug is aliased). The apply moment is deliberate: renaming on every title keystroke would thrash the workspace URL and the shell load, so derivation is applied at the go-live decision, never per keystroke.

Internal documentation - Not for public distribution