# 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
```bash
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()`. `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/ssr` — `createBrowserClient` (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.body` — `transform: 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.push`es to `/list/{id}` on success (no in-form invite-code panel). List header is a centered vertical stack: name (`text-2xl sm:text-3xl`), uppercase "JOIN CODE" eyebrow + mono chip, settings cog. 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: `` in `(app)` header → `POST /api/auth/signout` → `queryClient.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 `` 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.
- **CSP** — `img-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
- **Sentry** — `beforeSend` 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
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
```