CLAUDE.md 12 KB

MovieDice

Keep this file under 100 lines. Prefer terse descriptions. Don't duplicate what's in PROJECT_SCOPE.md or derivable from code. Stack versions in research/TECHFILE.md.

Development

npm install && npm run build && npm run dev

Docker: docker compose up --build. Supabase Studio at localhost:3000 (dev only — restricted to 127.0.0.1 in production).

Supabase first-run: Replace ALL default secrets before first docker compose up (JWT_SECRET, POSTGRES_PASSWORD, ANON_KEY, SERVICE_ROLE_KEY, DASHBOARD_USERNAME, DASHBOARD_PASSWORD). ANON_KEY + SERVICE_ROLE_KEY must be regenerated from JWT_SECRET as a lockstep set.

API Routes

TMDB Proxy:  /api/tmdb/search, /api/tmdb/* (server-side only — TMDB_API_KEY never NEXT_PUBLIC_; v4 read-access JWT, ALWAYS `Authorization: Bearer`, never `?api_key=` query form — silently 401s)
Auth:        /api/auth/bootstrap (anon onboard, server-side admin.createUser → public.users + user_sessions + cookie; per-IP rate-limited, daily circuit-breaker via BOOTSTRAP_DAILY_CAP). /api/auth/recovery/{generate,claim}, /api/auth/touch, /api/auth/me, /api/auth/signout (POST), /logout (GET/POST → redirect /). Browser code never calls signInAnonymously().
Groups:      /api/groups, /api/groups/join (rate-limited, server-side via service role key)
Health:      /api/health
Admin:       /admin (TOTP login, iron-session v8) — /api/admin/logout is SEPARATE from user signout

All TMDB calls set include_adult=false and server-side filter by adult field.

Database (Supabase self-hosted Postgres)

Tables: users, groups, group_members, movies, landing_reel_posters

  • users.id = Supabase Auth UID from signInAnonymously(). Recovery codes hashed with Argon2id (memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes) in public.recovery_codes.
  • users.last_active_at — updated on writes, throttled to 1x/24h. 12-month inactivity = auto-delete.
  • movies.added_by — FK with ON DELETE SET NULL. trailer_url validated against allowlist (youtube.com, themoviedb.org, imdb.com).
  • movies.genres stores TMDB genre names (e.g. "Action"), not numeric IDs. Genre filtering must accept both — use filterByGenresAndEmotionsStructured (matches IDs + names), not the legacy string tokenizer.
  • movies.metadata_refreshed_at — for monthly TMDB metadata refresh (post-MVP).
  • Invite codes: WORD-WORD format (2,000+ words, 3-8 chars, offensive terms filtered, case-insensitive).
  • admin_sessions — iron-session v8 encrypted cookies, no DB table.
  • RLS on all tables with WITH CHECK clauses. movies INSERT enforces added_by = auth.uid(). movies UPDATE policy MUST NOT self-reference public.movies in subqueries (recursion 42P17) — added_by immutability is enforced by movies_added_by_immutable trigger (00005). group_members UPDATE prevents role escalation. Group join is server-side (service role key).
  • Migrations: supabase migration new only. No ad-hoc SQL in production.

Frontend (Next.js App Router)

  • @supabase/ssrcreateBrowserClient (browser) / createServerClient with SUPABASE_INTERNAL_URL (server). All server-side createServerClient MUST set cookieOptions: { name: getSupabaseCookieName() } (src/lib/supabase/cookie-name.ts) so the cookie name is derived from NEXT_PUBLIC_SUPABASE_URL, not the internal URL — otherwise hostname-based project-ref derivation diverges from the browser and getUser/signOut silently no-op.
  • TanStack Query with explicit staleTime. Offline: persistQueryClient + IndexedDB (3 packages: react-query-persist-client, query-async-storage-persister, idb-keyval).
  • TMDB posters: native sized URLs from TMDB CDN (w342 grid, w185 reel, w500 panel). No next/image for posters. loading="lazy" + meaningful alt text on all images. Reel posters aria-hidden.
  • Real-time: subscribe on mount, unsubscribe on unmount (one list at a time). Home page counts via polling.
  • Accessibility: aria-live="polite" for roll results, filter changes, watched toggles. prefers-reduced-motion on both animations.
  • Home roll teaser renders in place — do not router.push() from a home-page roll.
  • Landing has two distinct movie pools: carousel reads landing_reel_posters via /api/tmdb/reel-posters; "Roll the Dice" hits /api/tmdb/popular?page=N (random 1–50, ~1000-movie pool). Don't conflate.
  • PINNED_REEL_POSTERS in src/app/api/tmdb/reel-posters/route.ts always-include list (deduped by tmdb_id); edit there to add/remove pins.
  • Landing roll result emerges in carousel center: snap math lands a poster gap exactly at viewport center, posters spread ±SPREAD_AMOUNT, card pops in. Card stays settled until next roll (no auto-resume).
  • Modals descended from animate-emerge (or any transformed ancestor) MUST createPortal to document.bodytransform: scale(1) from fill-mode: both establishes a containing block and clamps fixed inset-0 to the ancestor's box.
  • Terminology: UI says "List", code/DB/routes say "group" (legacy). "Create List" button → /create-group; list detail → /list/[id] (reads groups table); list settings → /list/[id]/settings (mounts SettingsPanel). Don't add a /create route — point new "Create List" CTAs at /create-group.
  • Create-list form router.pushes to /list/{id} on success (no in-form invite-code panel). List header is a 3-col grid: back arrow (→ /home) left, centered name (text-2xl sm:text-3xl) + "JOIN CODE" eyebrow + mono chip, settings cog right. /create-group mirrors the back-arrow + centered title pattern. Destructive actions (SettingsPanel + per-movie Watched/Delete in ListMoreInfoModal) use shake-to-arm (animate-shake, 4s auto-disarm) — no window.confirm. Admin delete with other members shows an inline successor picker; selection runs transfer + leave atomically. Solo-admin delete and leave both router.push("/").
  • List grid (/home) and movie cards: PosterCard is a clickable button (whole-card target) that opens ListMoreInfoModal — no ExpandedPanel. Watched movies show a green-circle checkmark badge top-left; added-by avatar dot top-right; decorative "i" glyph bottom-right. Both list-page and /home rolls render ListRollCarousel (horizontal poster strip, dice-emerge entrance → spin → snap-to-gap → ±110px spread + emerge teaser w/ gold glow). movies table doesn't store TMDB overview — ListMoreInfoModal fetches /api/tmdb/movie/[id] on open.

Auth

  • Users: Supabase Anonymous Sign-In via GoTrue (signInAnonymously()). After sign-in, all session validation is local: see Trust Boundary below.
  • Signout: <SignOutButton /> in (app) header → POST /api/auth/signoutqueryClient.clear() → hard nav to /. Linkable variant: GET /logout.
  • Recovery: 24-char alphanumeric code (128-bit entropy), Argon2id-hashed in public.recovery_codes with a peppered HMAC prefix index for O(1) lookup (RECOVERY_CODE_PEPPER env). Generate route uses requireUser() for auth, rate-limits per uid, idempotent (409 if already present). Claim atomically deletes via DELETE...RETURNING, mints a fresh HS256 JWT (JWT_SECRET, kid: "v1", iss: "moviedice", aud: "authenticated", nbf: -10s), inserts a user_sessions row, writes the @supabase/ssr cookie. Sessions: 1h access TTL, re-mint via POST /api/auth/touch (driven by <SessionKeeper /> in the (app) layout, fires every 5 min and on 401 hard-clears cache + nav to /); absolute cap 30d via iat_original claim. Revoke by setting user_sessions.revoked_at. Recovery page (/recovery) uses useQuery keyed recovery-code-generate with staleTime/gcTime: Infinity so StrictMode double-mount doesn't lose the response.
  • Trust boundary: every server caller uses requireUser(req) / getCurrentUser(req) from src/lib/auth/current-user.ts; every browser caller uses useCurrentUser() from src/hooks/use-current-user.ts (TanStack Query against /api/auth/me). Do NOT call supabase.auth.getUser() or supabase.auth.getSession() anywhere else — GoTrue rejects our minted JWTs (their session_id is not in auth.sessions). PostgREST and Realtime accept them via HS256 signature alone, which is sufficient for RLS. CI guard at src/__tests__/guards/no-raw-getuser.test.ts enforces. The admin-API form admin.auth.getUser(bearerToken) is a different surface (Bearer-token validation) and is allowed.
  • All users remain is_anonymous=true in auth.users — that flag has NO semantic meaning under this design. Do not gate any policy or UI on auth.jwt()->>'is_anonymous'. CI guard at src/__tests__/guards/no-is-anonymous-policy.test.ts enforces in migrations.
  • Admin: username + TOTP (otplib), iron-session v8 (HttpOnly, Secure, SameSite=Strict, 8h expiry).
  • GoTrue config: GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true, all other auth methods disabled.

Security

  • RLS — authorization layer; anon key is public by design. WITH CHECK on INSERT/UPDATE.
  • CSPimg-src image.tmdb.org, connect-src 'self' wss://[domain] (self-hosted, not *.supabase.co)
  • HSTS — in Caddyfile only (not next.config.js). Short max-age during testing, 2yr for production.
  • Network — Kong (8000/8443) and Postgres (5432) internal to Docker only. Studio 127.0.0.1 only. Only Caddy exposed.
  • Env validation — t3-env (@t3-oss/env-nextjs) enforces server/client split at build time
  • SentrybeforeSend strips UUID path segments. Never call Sentry.setUser().
  • Rate limiting — join endpoint (5-10/15min per IP), recovery claim (5/15min per IP)
  • Trailer URLs — domain allowlist (youtube.com, themoviedb.org, imdb.com), rel="noopener noreferrer"
  • Argon2 — Dockerfile builder stage needs python3 make g++ (or use @node-rs/argon2 for pre-compiled binaries)

Docker

docker-compose orchestrates: Next.js app (node:22-slim, standalone, non-root, tini), self-hosted Supabase stack (Postgres, GoTrue, Realtime, PostgREST, Kong, Studio), Caddy (HTTPS, persistent volumes for /data and /config), Node.js cron container (node:22-alpine + node-cron for reel/trailer/metadata refresh), pg_dump backup container (daily, 7-day retention). Log rotation on all containers (max-size: 10m, max-file: 5). Disk encryption recommended on host (LUKS or cloud equivalent).

Realtime bootstrap (cold start): supabase-realtime-schema-init creates _realtime schema before realtime starts (otherwise Ecto crashes: no schema has been selected). supabase-realtime-tenant-seed (runs supabase/init/seed-realtime-tenant.sh) POSTs /api/tenants to seed external_id=supabase-realtime (the slug Kong's upstream Host rewrites to — built-in SEED_SELF_HOST seeds the wrong slug realtime-dev). Both are idempotent and restart: "no". Without these, all WS connects fail with TenantNotFound and no postgres_changes propagate.

Env Vars

TMDB_API_KEY                              # server-side only
NEXT_PUBLIC_SUPABASE_URL                  # public Supabase URL (browser client)
NEXT_PUBLIC_SUPABASE_ANON_KEY             # public anon key (browser client)
SUPABASE_INTERNAL_URL                     # Docker internal Kong URL (server-side)
SUPABASE_SERVICE_ROLE_KEY                 # server-side admin ops
MASTER_ADMIN_USERNAME / _TOTP_SECRET      # admin auth
IRON_SESSION_SECRET                       # 32+ chars
JWT_SECRET                                # HS256 signing key for minted access tokens (shared with GoTrue)
RECOVERY_CODE_PEPPER                      # 32+ chars; HMAC pepper for recovery_codes prefix index
BOOTSTRAP_DAILY_CAP                       # int, default 10000; daily anon-user creation circuit breaker
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED   # must be true