How to Add a New API Route
Use this guide when adding any new backend endpoint in show.fm.
Non-Negotiable Rules
- All backend APIs must live under
src/api/routes/and be mounted insrc/api/index.ts. - Do not create SvelteKit
+server.tsfiles for API endpoints. - Use shared auth/permission middleware from
src/api/middleware/. - Do not add
SUPABASE_SERVICE_ROLE_KEYfallback logic in new route code.
Required Middleware Chain
Use the smallest chain that satisfies the endpoint's security needs.
requireAuth()for authenticated endpointszValidator(...)for body/query/param validationrequirePodcastRole(...)or resolver-based variant for podcast-scoped authorization- Handler function
Example ordering:
.post(
'/',
requireAuth(),
zValidator('json', createSchema),
requirePodcastRole('admin'),
async (c) => {
// handler logic
}
)Permission Patterns
requireAuth()
Use when endpoint needs a logged-in user but no podcast/episode scope.requirePodcastRole('member' | 'admin' | 'owner')
Use whenpodcastId(orpodcast_id) is available in params/query/body.requirePodcastRoleByResolver(minRole, resolver)
Use when podcast scope must be looked up from another resource ID first.requirePodcastRoleOrEpisodeRoster(episodeResolver)(#293)
The roster-aware guard for episode-scoped reads that Co-hosts reach: passesadmin+, or amemberholding an ACTIVEepisode_peopleroster row on the resolved episode. A missing episode 404s; setspodcastRole,podcastId, andepisodeIdon context and preserves the lifecycle block.requireEpisodeAccess()/requireEpisodePermission(...)
Use for episode-scoped authorization.
Error Response Format
Keep errors consistent and machine-readable:
- Prefer throwing
HTTPExceptionfor auth/permission/not-found checks in middleware/resolvers. - Return explicit handler errors as
c.json({ error: 'message' }, statusCode). - Use consistent status codes:
400invalid input401unauthenticated403unauthorized404not found409conflict500server error
Correct Route Example
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { requireAuth, requirePodcastRoleByResolver, type PermissionEnv } from '$api/middleware';
type ExampleEnv = {
Bindings: PermissionEnv['Bindings'];
Variables: PermissionEnv['Variables'];
};
const updateSchema = z.object({
name: z.string().min(1).max(100)
});
const resolvePodcastId = async ({
c,
supabase
}: {
c: { req: { param: (key: string) => string } };
supabase: PermissionEnv['Variables']['supabase'];
}) => {
const resourceId = c.req.param('id');
const { data, error } = await supabase
.from('some_resources')
.select('podcast_id')
.eq('id', resourceId)
.single();
if (error || !data) {
throw new HTTPException(404, { message: 'Resource not found' });
}
return data.podcast_id;
};
export const exampleRoutes = new Hono<ExampleEnv>().put(
'/:id',
requireAuth(),
zValidator('json', updateSchema),
requirePodcastRoleByResolver('admin', resolvePodcastId),
async (c) => {
const { name } = c.req.valid('json');
const supabase = c.get('supabase');
const id = c.req.param('id');
const { error } = await supabase.from('some_resources').update({ name }).eq('id', id);
if (error) {
return c.json({ error: 'Failed to update resource' }, 500);
}
return c.json({ success: true });
}
);Mounting and Type Safety
- Export route group from
src/api/routes/<route>.ts. - Mount it in
src/api/index.tswith.route('/<route>', <routeGroup>). - Keep API client usage on the frontend as
client.api...(notclient...).
Test Checklist
Add tests adjacent to the route code (for example, src/api/routes/<group>/__tests__/index.test.ts):
- Missing token returns
401 - Insufficient role returns
403 - Cross-tenant access is denied
- Validation failures return
400 - Happy path succeeds
Documentation Checklist
When adding a route:
- Add/Update route reference in
docs-internal/src/api/ - Update this guide if a new middleware pattern is introduced
- Keep examples aligned with current middleware exports in
src/api/middleware/index.ts