CLAUDE.md 5.5 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_)
Auth:        Supabase GoTrue (signInAnonymously) — no custom auth routes
Groups:      /api/groups, /api/groups/join (rate-limited, server-side via service role key)
Health:      /api/health
Admin:       /admin (TOTP login, iron-session v8)

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).
  • 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(). 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)
  • 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.

Auth

  • Users: Supabase Anonymous Sign-In → JWT via GoTrue → cookie-based sessions via @supabase/ssr
  • Recovery: 24-char alphanumeric (128-bit entropy), Argon2id hashed, single-use, claim rate-limited (5/15min per IP)
  • 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).

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
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED   # must be true