prism gets auth, goes to production, and fights safari
Prism is an interview practice tool — you pick a topic (elocution, system design, etc.), get matched with AI panelists who have distinct personas, and do push-to-talk voice sessions that get transcribed and critiqued. Today was about hardening it for real users.
sign in with everything#
Added OAuth for Google, GitHub, and Apple. Google and GitHub are standard OAuth2 flows. Apple is its own thing:
- The client secret is a short-lived ES256 JWT you sign with a
.p8private key — not a static string - The callback is
POST(form_post), notGET - User info comes from the
id_tokenJWT, not a userinfo endpoint - Apple only sends the user’s name on the first authorization — subsequent logins only have email + sub
Generated a fresh client secret per request to avoid stale-secret bugs. Used ParseUnverified on the id_token since it comes server-to-server from Apple’s token endpoint over TLS.
the proxy problem#
Frontend is on Vercel, backend on Railway. Vercel rewrites proxy HTTP requests (/api/*, /auth/*) to the backend so cookies stay same-origin. This works great until:
-
OAuth state cookies — the redirect chain through Vercel’s proxy doesn’t reliably round-trip cookies. Replaced cookie-based CSRF state with HMAC-signed state parameters:
base64(nonce + hmac(nonce, secret)). Stateless, no cookies needed, verified by recomputing the HMAC. -
WebSockets — Vercel can’t proxy WebSocket connections. Added
NEXT_PUBLIC_WS_URLpointing directly to Railway. But cross-origin WS connections don’t send cookies, so added a/api/auth/ws-tokenendpoint — frontend fetches the JWT through the proxy, then passes it as?token=on the direct WS connection. Auth middleware checks both cookie and query param.
safari and MediaRecorder#
Push-to-talk uses the MediaRecorder API. Safari reports audio/mp4 as supported, but its implementation has quirks:
start(timeslice)with a timeslice parameter doesn’t fireondataavailable— it buffers everything silently- The
ondataavailableproperty-based handler is unreliable — events may not fire, or fire afteronstop
First fix was skipping the timeslice. Still got 0 chunks after 6+ seconds of recording. The real fix: use addEventListener("dataavailable", ...) instead of the property assignment, and for Safari’s stop path, capture the blob directly from the dataavailable event rather than accumulating chunks. Bypasses the ordering issue entirely.
what shipped#
- Three OAuth providers (Google, GitHub, Apple) with HMAC-signed state
- Cross-origin WebSocket auth via token query parameter
- Dashboard CTA that’s actually visible for new users
- Safari-compatible push-to-talk audio recording
- Idempotent database migrations (
IF NOT EXISTSon all CREATE statements — learned that one the hard way)