Episode People API
Unified episode people roster and attribution management. Separates "active on episode" (participation) from "credited in RSS" (attribution).
Source: src/api/routes/episode-people/index.ts
Overview
The Episode People API provides a unified interface for managing who participates in an episode and who appears in the RSS feed credits. It replaces the need to manage episode_credits directly by introducing an episode_people roster that bridges participation and attribution.
Core Concepts
Two surfaces, one table (2026-08-10). episode_people answers two different questions and the UI now asks them in two different places:
| Question | Surface | Reads |
|---|---|---|
| Who has ACCESS to this episode? | Collaborators tab (Guests, Team members) | roster rows; for a member this IS the grant |
| Who is CREDITED in the feed? | Publish tab (RSS credits) | the credit_id bridge to episode_credits |
Both surfaces share one client store (src/lib/components/episode-people/episode-people-store.svelte.ts), created by the episode page and passed to each. Do not give either its own copy: a team member added in Collaborators has to appear in the Publish credit list without a reload.
The split is a security boundary, not just layout. Writing an episode_people row with source_type = 'team' is what makes an episode visible to a Co-host, so the Publish tab deliberately cannot create one: its "Credit someone else" dialog is external-only, and everyone else reaches the credits from the roster they are already on.
Participation vs. Attribution:
| State | is_active | credit_id | Meaning |
|---|---|---|---|
| Active, not credited | true | null | On the roster but not in RSS |
| Active and credited | true | UUID | On the roster and in RSS feed |
| Inactive | false | null | Removed from active roster |
Source Types:
| Source | user_id | guest_id | Use Case |
|---|---|---|---|
team | Required | null | Podcast team members from podcast_members |
guest | null | Required | Episode guests from episode_guests |
external | null | null | Manual external contributors |
Authentication
All endpoints require Authorization: Bearer <supabase_access_token> via requireAuth() middleware. Podcast access is resolved from the episode ID.
| Endpoint | Guard (#293) |
|---|---|
| GET (list roster) | admin+, or member with an active roster row on the episode (requirePodcastRoleOrEpisodeRoster) |
| POST/PUT/DELETE (mutate roster) | admin (requirePodcastRoleByResolver) |
| PUT/DELETE (credit operations) | admin |
| PUT (reorder credits) | admin |
#293 behaviour notes: the duplicate roster POST is idempotent (returns the existing row with 200 and alreadyExisted: true, not 409); a successful TEAM add publishes episode.team_added (in-app + email, skipped on self-add) and re-syncs the session's Google event attendees; a TEAM delete removes the member from the event best-effort.
Endpoints
All routes are mounted at /api/episodes via app.route('/episodes', episodePeopleRoutes).
List Roster and Candidates
GET /api/episodes/:id/peopleReturns the episode's people roster with enriched credit data, plus candidate lists for adding new people.
Auth: requirePodcastRoleByResolver('member', ...)
Response 200:
{
"success": true,
"data": {
"people": [
{
"id": "uuid",
"episodeId": "uuid",
"sourceType": "team",
"userId": "uuid",
"guestId": null,
"name": "Jane Host",
"roleLabel": "Host",
"avatarUrl": "https://...",
"externalUrl": null,
"isActive": true,
"creditId": "uuid",
"credited": {
"id": "uuid",
"name": "Jane Host",
"roleLabel": "Host",
"avatarUrl": "https://...",
"externalUrl": null,
"displayOrder": 0
},
"createdAt": "2026-02-16T00:00:00.000Z",
"updatedAt": "2026-02-16T00:00:00.000Z"
}
],
"candidates": {
"teamCandidates": [
{
"userId": "uuid",
"name": "Bob Producer",
"role": "admin",
"avatarUrl": null
}
],
"guestCandidates": [
{
"guestId": "uuid",
"name": "Alice Guest",
"email": "[email protected]",
"status": "active"
}
]
}
}
}Candidate Resolution: Team candidates are podcast_members not already on the roster (filtered by user_id). Guest candidates are episode_guests not already on the roster (filtered by guest_id). This prevents duplicate entries.
Add Person to Roster
POST /api/episodes/:id/peopleAdds a person to the episode roster from one of three sources.
Auth: requirePodcastRoleByResolver('admin', ...)
Request Body:
{
"sourceType": "team",
"userId": "uuid",
"name": "Jane Host",
"roleLabel": "Host",
"avatarUrl": "https://...",
"externalUrl": "https://..."
}| Field | Type | Required | Description |
|---|---|---|---|
sourceType | 'team' | 'guest' | 'external' | Yes | Person origin |
userId | UUID | If team | Must be a podcast_members user |
guestId | UUID | If guest | Must be an episode_guests record for this episode |
name | string (1-200) | Yes | Display name |
roleLabel | string (1-100) | No | Default: 'Participant' |
avatarUrl | URL | No | Avatar image URL |
externalUrl | URL | No | Link to website or social profile |
Response 201:
{
"success": true,
"data": {
"id": "uuid",
"episodeId": "uuid",
"sourceType": "team",
"userId": "uuid",
"guestId": null,
"name": "Jane Host",
"roleLabel": "Host",
"avatarUrl": null,
"externalUrl": null,
"isActive": true,
"creditId": null,
"createdAt": "2026-02-16T00:00:00.000Z",
"updatedAt": "2026-02-16T00:00:00.000Z"
}
}Auto-credit (2026-08-10): the handler creates the episode_credits row itself for team and external sources, through the shared ensurePersonCredit helper, and queues the RSS and public-API invalidations. creditId in the response is that new credit.
sourceType | Credited here? | Why |
|---|---|---|
team | Yes | Adding a team member is the host asserting they were on the episode. Removing the credit is the explicit action |
external | Yes | An external person has no account and no calendar identity; being credited is the only reason the row exists |
guest | No | A guest can still decline. ensure_guest_credit_on_activation owns their credit and fires when they go active |
The roster row is the episode ACCESS grant, so a failure in the credit half must never undo it: the response is still 201 and carries a warning string, which the UI surfaces as a non-blocking toast. ensurePersonCredit is shared with PUT .../credit so the two paths cannot drift on metadata resolution, display order, or orphan cleanup.
role_label is derived, not chosen (2026-08-10). The roster label describes PARTICIPATION, so the handler sets it rather than trusting the caller:
sourceType | Roster role_label |
|---|---|
team | defaultCreditRoleForPodcastRole(podcast_members.role): owner to Host, admin to Producer, else Co-host |
guest | Always 'Guest' |
external | The caller's, validated against CREDIT_ROLE_LABELS (400 otherwise) |
A client-supplied roleLabel on a team add is IGNORED. The mapping is the same one auto_add_team_on_episode_create uses, so the manual and the automatic path can never describe the same person differently. Per-episode variation belongs to the credit, not the roster: see "Credited as" below.
Validation Rules:
teamsource requiresuserIdthat is a member of the episode's podcastguestsource requiresguestIdthat belongs to this episodeexternalsource requires neitheruserIdnorguestId- Duplicate team members (same
userId) or guests (sameguestId) return the existing row with200andalreadyExisted: true; the side-effect pipeline does not re-fire
Error Responses:
| Code | Condition |
|---|---|
400 | Missing name, team source without userId, guest source without guestId, userId not a podcast member, guestId not on this episode |
500 | Database insert error on the roster row (a credit failure does not fail the request, see above) |
Update Person Metadata
PUT /api/episodes/:id/people/:personIdUpdates a person's display metadata or active state.
Auth: requirePodcastRoleByResolver('admin', ...)
Request Body (all fields optional, at least one required):
{
"name": "Updated Name",
"roleLabel": "Co-host",
"avatarUrl": "https://...",
"externalUrl": "https://...",
"isActive": false
}| Field | Type | Description |
|---|---|---|
name | string (1-200) | Display name |
roleLabel | string (1-100) | Role label |
avatarUrl | URL | null | Avatar URL (set null to clear) |
externalUrl | URL | null | External link (set null to clear) |
isActive | boolean | Active state toggle |
Response 200:
{
"success": true,
"data": {
"id": "uuid",
"episodeId": "uuid",
"sourceType": "team",
"name": "Updated Name",
"roleLabel": "Co-host",
"isActive": true,
"creditId": null,
"...": "..."
}
}Error Responses:
| Code | Condition |
|---|---|
400 | No fields provided |
404 | Person not found on this episode |
Remove Person from Roster
DELETE /api/episodes/:id/people/:personIdRemoves a person from the episode roster. If the person has an associated credit, the credit row is also deleted (cascading cleanup).
Auth: requirePodcastRoleByResolver('admin', ...)
Response 200:
{
"success": true,
"data": {
"id": "uuid",
"name": "Jane Host"
}
}Side Effects:
- If the person has a
credit_id, the linkedepisode_creditsrow is deleted - RSS invalidation is queued if a credit was removed
Error Responses:
| Code | Condition |
|---|---|
404 | Person not found on this episode |
Include in RSS Credits
PUT /api/episodes/:id/people/:personId/creditCreates or updates an RSS credit for this person. If the person already has a credit, it updates the existing credit metadata. If not, it creates a new episode_credits row and links it via credit_id.
Auth: requirePodcastRoleByResolver('admin', ...)
Request Body (all fields optional, defaults to person's metadata):
{
"name": "Display Name Override",
"roleLabel": "Producer",
"avatarUrl": "https://...",
"externalUrl": "https://..."
}Credit metadata defaults to the person's current values but can be overridden. This allows the RSS credit to display differently from the roster entry.
"Credited as": roleLabel (2026-08-10)
roleLabel is a z.enum(CREDIT_ROLE_LABELS): Host, Co-host, Producer, Guest, Editor, Composer. It is closed because workers/rss-feed/src/rss/podcast2.ts writes the stored value verbatim into podcast:person role="...", so free text would put typos and non-taxonomy roles in front of podcast apps. Add entries deliberately, in src/lib/constants/credit-roles.ts, and never widen it back to a string.
Two rules make the override behave the way a host expects.
It wins over the derived label, in both directions. A Co-host can be credited as a Guest for one episode, and a booked guest as a Co-host. 'Guest' is the DEFAULT for source_type = 'guest', not a constraint (symmetric by ruling; this replaced a hard force).
It sticks. On an existing credit role_label is written ONLY when the caller names one:
if (overrides?.roleLabel !== undefined) {
updatePayload.role_label = overrides.roleLabel;
}That conditional is load-bearing. ensurePersonCredit used to recompute role_label from the roster on every write, so a host's per-episode choice was silently reverted the next time anything touched the credit: re-crediting, a metadata refresh, or a remove-and-re-add. Once the credit exists, episode_credits.role_label is the feed's own label and nothing resyncs it from episode_people. Pinned by "does not touch role_label when refreshing an existing credit" in the route tests.
Why the derived label is normalised, not copied
episode_people.role_label is TEXT NOT NULL DEFAULT 'Participant' with no CHECK constraint, so a legacy row, a direct insert, or the column default itself can hold a value outside CREDIT_ROLE_LABELS. Crediting such a row with an empty body used to copy it straight into episode_credits, and the feed worker emits whatever it finds: a fourth path around the closed set.
toCreditRoleLabel(value, sourceType) closes it. A valid label passes through; anything else falls back by source (team to Co-host, everything else to Guest) and the host can correct it with "Credited as". The four ways a credit role can be set are now all constrained:
| Path | Constrained by |
|---|---|
PUT .../credit override | z.enum(CREDIT_ROLE_LABELS) |
POST /people external label | isCreditRoleLabel check in the handler (400) |
| Derived from the roster row | toCreditRoleLabel |
| SQL triggers | Literals, all in the set |
Since 20260810160000, episode_credits.role_label also carries a CHECK constraint (episode_credits_role_label_check), so the set is closed at the schema level as well: a future code path that forgets the rule fails loudly at the database rather than shipping a bad role to every podcast app. That migration normalises any out-of-set values before adding the constraint, so it lands whatever a database happens to hold.
episode_people.role_label is deliberately left unconstrained. It is internal participation data that no feed reads, toCreditRoleLabel already normalises it on the way to becoming a credit, and it defaults to the legacy 'Participant', so constraining it would mean changing a column default other paths may rely on. Covered by supabase/tests/credit_role_constraint.test.sql.
Response 201 (new credit) or 200 (updated existing):
{
"success": true,
"data": {
"personId": "uuid",
"credit": {
"id": "uuid",
"name": "Display Name Override",
"roleLabel": "Producer",
"avatarUrl": null,
"externalUrl": null,
"displayOrder": 0
}
}
}Behavior:
- New credits are assigned the next
display_ordervalue (appended to end) - If the credit insert succeeds but linking fails, the orphaned credit is cleaned up
- RSS invalidation is queued on success
Error Responses:
| Code | Condition |
|---|---|
404 | Person not found on this episode |
422 | Person is inactive (is_active: false) |
Exclude from RSS Credits
DELETE /api/episodes/:id/people/:personId/creditRemoves a person's RSS credit. The person remains on the roster but is no longer included in the RSS feed.
Auth: requirePodcastRoleByResolver('admin', ...)
Response 200:
{
"success": true,
"data": {
"personId": "uuid",
"name": "Jane Host"
}
}Side Effects:
- The
episode_creditsrow is deleted - The
episode_people.credit_idis set tonull - RSS invalidation is queued
Error Responses:
| Code | Condition |
|---|---|
404 | Person not found on this episode |
422 | Person is not currently credited |
Reorder RSS Credits
PUT /api/episodes/:id/credits/reorderSets the display order of all RSS credits for an episode.
Auth: requirePodcastRoleByResolver('admin', ...)
Request Body:
{
"creditIds": ["uuid-1", "uuid-2", "uuid-3"]
}| Field | Type | Required | Description |
|---|---|---|---|
creditIds | UUID[] | Yes | All credit IDs in desired order |
Validation Rules:
- Must include all credits for the episode (complete set)
- Must not contain duplicates
- All IDs must belong to this episode
Response 200:
{
"success": true,
"data": {
"episodeId": "uuid",
"order": ["uuid-1", "uuid-2", "uuid-3"]
}
}Error Responses:
| Code | Condition |
|---|---|
400 | Empty array, duplicates, missing credits, or invalid IDs |
Database Schema
episode_people Table
Migration: supabase/migrations/20260216120000_episode_people.sql
CREATE TABLE episode_people (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
episode_id UUID NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
source_type TEXT NOT NULL CHECK (source_type IN ('team', 'guest', 'external')),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
guest_id UUID REFERENCES episode_guests(id) ON DELETE SET NULL,
name TEXT NOT NULL,
role_label TEXT NOT NULL DEFAULT 'Participant',
avatar_url TEXT,
external_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
credit_id UUID REFERENCES episode_credits(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);Indexes:
| Index | Type | Purpose |
|---|---|---|
idx_episode_people_team_unique | Unique (partial) | One team member per episode (WHERE user_id IS NOT NULL) |
idx_episode_people_guest_unique | Unique (partial) | One guest per episode (WHERE guest_id IS NOT NULL) |
idx_episode_people_episode | Standard | Roster lookup by episode |
idx_episode_people_credit | Partial | Credit cascading (WHERE credit_id IS NOT NULL) |
RLS Policies: All operations require team membership on the episode's podcast (get_podcast_role(e.podcast_id) IS NOT NULL). Admin-level authorization is enforced at the API layer via middleware.
Relationship to episode_credits
The episode_people table acts as the management layer, while episode_credits remains the RSS output table:
Writers outside this API
Three database triggers write roster rows and credits without ever passing through the endpoints above. Change any rule here and you have to change them too.
| Trigger | Fires on | Writes |
|---|---|---|
trigger_auto_add_team_on_episode_create | AFTER INSERT ON episodes | Roster row + credit for every member with auto_add_to_new_episodes = TRUE |
trigger_auto_credit_episode_host | AFTER INSERT ON episodes | Roster row + credit for created_by (the Host label is owned here) |
trigger_auto_credit_active_guests | episode_guests status changes | Roster row + credit when a guest reaches active |
Three things about them are load-bearing:
Firing order. Same-event triggers fire alphabetically, so trigger_auto_add_team_on_episode_create runs BEFORE trigger_auto_credit_episode_host. Auto-add therefore skips the creator's credit entirely and leaves it to the host trigger, which owns the 'Host' label. Without that exclusion, a flagged owner would be credited under their podcast role and then relabelled on the roster only, leaving the two rows disagreeing.
Imported episodes never gain a show.fm-authored credit. Both episode triggers short-circuit their credit block on NEW.origin = 'import', and auto-add also suppresses its notification there (a 200-episode back catalogue would otherwise deliver 200 notifications per flagged member). An import is historical data: the enclosure URL is the only thing we rewrite.
Note that the importer DOES supply created_by (workers/podcast-import-executor/src/processItem.ts passes importRow.created_by into the episodes INSERT in db.ts). The created_by IS NULL early-return in ensure_host_credit_on_episode_create is the BOOKING RPC path, not the import path. An earlier comment claimed both, and that wrong half is why the import credit leak survived review.
Roster rows are still written on import. They are internal participation, not feed content, and listEpisodeTeamRecipients (src/lib/notifications/integrations.ts) reads the roster and nothing else. Suppressing them would silently stop collaboration notifications on imported episodes.
Covered by supabase/tests/import_credit_guard.test.sql.
RSS Cache Invalidation
All credit mutations (create, update, delete, reorder) queue an RSS cache invalidation message:
await env.RSS_INVALIDATION_QUEUE.send({
type: 'episode.credits.updated',
podcast_id: podcast.id,
podcast_slug: podcast.slug,
episode_id: episodeId,
timestamp: new Date().toISOString()
});The queue message is best-effort: failures are logged but don't break the operation.
RPC Client Usage
import { createApiClient } from '$api/client';
const client = createApiClient(fetch);
const token = session.access_token;
const headers = { Authorization: `Bearer ${token}` };
// List roster and candidates
const res = await client.api.episodes[':id'].people.$get({ param: { id: episodeId } }, { headers });
// Add team member to roster
const res = await client.api.episodes[':id'].people.$post(
{
param: { id: episodeId },
json: {
sourceType: 'team',
userId: userId,
name: 'Jane Host',
roleLabel: 'Host'
}
},
{ headers }
);
// Include person in RSS credits
const res = await client.api.episodes[':id'].people[':personId'].credit.$put(
{
param: { id: episodeId, personId: personId },
json: { roleLabel: 'Producer' }
},
{ headers }
);
// Reorder credits
const res = await client.api.episodes[':id'].credits.reorder.$put(
{
param: { id: episodeId },
json: { creditIds: [creditId1, creditId2] }
},
{ headers }
);Related Documentation
- Multi-Tenancy Model - Multi-table permission model (security boundaries + participation layer)
- RSS Feed Worker - How credits appear in RSS feeds
- Hono API Overview - API architecture and patterns