move all music per-user state into the sidecar; platform = auth + proxy only

Favorites / now-playing / playlists were being served by the platform router
straight from Postgres, which violated the intended split (officer = auth +
proxy; officer-music = the whole /api/music/* contract). Move them into the
sidecar so it owns ALL music endpoints — library AND user state.

- sidecar (index.ts): serves /favorites, /now-playing, /playlists[/:id[/items]]
  backed by Postgres (the same officerdb queries other sidecars already use).
  The authenticated user id arrives in X-Officer-User; the sidecar is loopback-
  only, so it trusts the header (401 if absent). HTTP-contract comment updated.
- platform (router.ts): reduced to a pure auth+proxy catch-all — it now injects
  X-Officer-User from the authenticated ctx user and forwards the request body
  (favorites/now-playing/playlist writes carry JSON) in addition to Range/query.

No schema change — the tables are unchanged, only WHERE they're served moves.
Verified live: booted the real sidecar against the live DB and exercised the
endpoints with the X-Officer-User header — 401-without-header, favorites round-
trip, and full playlist CRUD with ownership scoping all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 01:05:52 +00:00
co-authored by Claude Opus 4.8
parent 7a966c780e
commit e2c9905885
2 changed files with 182 additions and 166 deletions
+24 -164
View File
@@ -1,172 +1,18 @@
import { createRouter } from '../../create-router';
import { getMusicServerUrl } from './sidecar-server';
import {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
type FavoriteKind,
} from 'officerdb';
// Thin reverse-proxy for /api/music/*. Auth is handled upstream by userMiddleware (this router mounts
// under the protected /api tree, so the media `?token=` path works). Everything else — path resolution,
// byte-range streaming, ffprobe duration, indexing — is done by the officer-music sidecar's audio
// server. We only forward the subpath + query + Range and stream the response back.
// Thin reverse-proxy for /api/music/*. The platform's ONLY job here is AUTH + FORWARDING. userMiddleware
// (upstream — this router mounts under the protected /api tree, so the media `?token=` path also works)
// authenticates; we forward the subpath + query + body + Range to the officer-music sidecar, which OWNS
// the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites, now-playing,
// playlists) backed by Postgres. We inject the authenticated user id as `X-Officer-User` so the sidecar
// can serve that per-user state — the sidecar is loopback-only, so it trusts the header.
//
// This is a catch-all, so it lists no routes: the full /api/music/* HTTP contract (stream, manifest,
// meta, cover, reindex, reindex/stream + their SSE/response shapes) is documented at the top of the
// sidecar's fetch handler — src/servers/sidecar/music/index.ts.
// This is a catch-all with no routes of its own: the full /api/music/* HTTP contract (paths, methods,
// SSE/response shapes) is documented at the top of the sidecar's fetch handler — src/servers/sidecar/music/index.ts.
export const musicRouter = createRouter();
// ── Per-user music state (favorites + currently-playing) ─────────────────────────────────────────
// These are USER data, not library data, so the platform serves them from Postgres directly — they are
// NOT proxied to the sidecar (which is stateless about users). Registered before the catch-all proxy
// below so they win; still under /api/music, so the music-app account gate permits them.
const FAVORITE_KINDS = new Set<FavoriteKind>(['track', 'album', 'artist']);
const isKind = (k: unknown): k is FavoriteKind => typeof k === 'string' && FAVORITE_KINDS.has(k as FavoriteKind);
// GET /favorites → { tracks, albums, artists } (arrays of keys, newest first).
musicRouter.get('/favorites', async (ctx) => {
return ctx.json(await getMusicFavorites(ctx.get('user').id));
});
// POST /favorites { kind, key } → add (idempotent).
musicRouter.post('/favorites', async (ctx) => {
const { kind, key } = (await ctx.req.json().catch(() => ({}))) as { kind?: unknown; key?: unknown };
if (!isKind(kind) || typeof key !== 'string' || !key) return ctx.json({ error: 'kind and key required' }, 400);
await addMusicFavorite(ctx.get('user').id, kind, key);
return ctx.json({ ok: true });
});
// DELETE /favorites?kind=&key= → remove (query params so any HTTP client can send it).
musicRouter.delete('/favorites', async (ctx) => {
const kind = ctx.req.query('kind');
const key = ctx.req.query('key');
if (!isKind(kind) || !key) return ctx.json({ error: 'kind and key required' }, 400);
await removeMusicFavorite(ctx.get('user').id, kind, key);
return ctx.json({ ok: true });
});
// GET /now-playing → the snapshot, or null.
musicRouter.get('/now-playing', async (ctx) => {
return ctx.json(await getNowPlaying(ctx.get('user').id));
});
// PUT /now-playing { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } → upsert.
musicRouter.put('/now-playing', async (ctx) => {
const b = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
if (typeof b.homePath !== 'string' || !b.homePath) return ctx.json({ error: 'homePath required' }, 400);
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
await setNowPlaying(ctx.get('user').id, {
homePath: b.homePath,
dir: str(b.dir),
title: str(b.title),
artist: str(b.artist),
album: str(b.album),
durationSec: num(b.durationSec),
positionSec: num(b.positionSec),
});
return ctx.json({ ok: true });
});
// DELETE /now-playing → clear.
musicRouter.delete('/now-playing', async (ctx) => {
await clearNowPlaying(ctx.get('user').id);
return ctx.json({ ok: true });
});
// ── Named playlists ──────────────────────────────────────────────────────────────────────────────
// Playlist item `key`s are opaque track homePaths, same contract as favorites. Every route is scoped to
// the caller's user id (a playlist id that isn't theirs reads/writes as 404).
const MAX_NAME = 200;
const cleanName = (v: unknown): string | null => {
if (typeof v !== 'string') return null;
const n = v.trim();
return n && n.length <= MAX_NAME ? n : null;
};
const asKeys = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null;
// GET /playlists → [{ id, name, count, createdAt, updatedAt }] (most-recently-updated first).
musicRouter.get('/playlists', async (ctx) => {
return ctx.json(await getPlaylists(ctx.get('user').id));
});
// POST /playlists { name } → create; 409 if the name is already taken.
musicRouter.post('/playlists', async (ctx) => {
const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown };
const n = cleanName(name);
if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400);
const row = await createPlaylist(ctx.get('user').id, n);
if (!row) return ctx.json({ error: 'a playlist with that name already exists' }, 409);
return ctx.json(row, 201);
});
// GET /playlists/:id → { id, name, items: string[], createdAt, updatedAt }; 404 if not the user's.
musicRouter.get('/playlists/:id', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const pl = await getPlaylist(ctx.get('user').id, id);
return pl ? ctx.json(pl) : ctx.json({ error: 'not found' }, 404);
});
// PATCH /playlists/:id { name } → rename; 404 if not the user's, 409 on a name collision.
musicRouter.patch('/playlists/:id', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const { name } = (await ctx.req.json().catch(() => ({}))) as { name?: unknown };
const n = cleanName(name);
if (!n) return ctx.json({ error: 'name required (1-200 chars)' }, 400);
const userId = ctx.get('user').id;
// Distinguish "not yours" (404) from "name collides" (409): confirm ownership first.
if (!(await getPlaylist(userId, id))) return ctx.json({ error: 'not found' }, 404);
const ok = await renamePlaylist(userId, id, n);
return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'a playlist with that name already exists' }, 409);
});
// DELETE /playlists/:id → delete (items cascade); 404 if not the user's.
musicRouter.delete('/playlists/:id', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const ok = await deletePlaylist(ctx.get('user').id, id);
return ok ? ctx.json({ ok: true }) : ctx.json({ error: 'not found' }, 404);
});
// POST /playlists/:id/items { keys: string[] } → append to the end. Returns { count }.
musicRouter.post('/playlists/:id/items', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown };
const ks = asKeys(keys);
if (!ks) return ctx.json({ error: 'keys[] required' }, 400);
const count = await addPlaylistItems(ctx.get('user').id, id, ks);
return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count });
});
// PUT /playlists/:id/items { keys: string[] } → replace the whole ordered list (reorder / remove).
musicRouter.put('/playlists/:id/items', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'bad id' }, 400);
const { keys } = (await ctx.req.json().catch(() => ({}))) as { keys?: unknown };
const ks = asKeys(keys);
if (!ks) return ctx.json({ error: 'keys[] required' }, 400);
const count = await setPlaylistItems(ctx.get('user').id, id, ks);
return count === null ? ctx.json({ error: 'not found' }, 404) : ctx.json({ count });
});
const PREFIX = '/api/music';
musicRouter.all('/*', async (ctx) => {
@@ -189,12 +35,26 @@ musicRouter.all('/*', async (ctx) => {
}
}
const method = ctx.req.method;
const headers: Record<string, string> = {};
const range = ctx.req.header('range');
if (range) headers['Range'] = range;
const contentType = ctx.req.header('content-type');
if (contentType) headers['Content-Type'] = contentType;
// Forward the authenticated user id so the sidecar can serve its per-user state routes (favorites /
// now-playing / playlists). The sidecar binds loopback only, so this header is trusted.
headers['X-Officer-User'] = String(ctx.get('user').id);
// Forward the request body for mutating methods (favorites/now-playing/playlist writes). Streaming +
// reindex are GET/bodyless POST, so this is a no-op there.
const hasBody = method !== 'GET' && method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method: ctx.req.method,
headers: range ? { Range: range } : {},
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
} catch (err) {
console.error('[music] proxy fetch failed', { target, error: String(err) });