Browse Source

[Feat] PG-13 cert filter, home/list UI polish, TMDB + legal footers

Major batch covering content policy, home/list polish, and compliance posture.

- PG-13 multi-country cert allowlist applied across all TMDB endpoints
  (discover, popular, search, movie/[id], movie/[id]/videos, reel-posters)
  with belt-and-suspenders post-filter on search keyword path. Pinned reel
  poster (tmdb_id 615) is a documented editorial override.
- Migration 00007 truncates landing_reel_posters so cron repopulates with
  cert-filtered titles. Cron mirrors the allowlist + pin set.
- Home page: roll teaser fully clickable, RollBar hidden on empty pool,
  Create/Join row equal-width side-by-side mirroring roll-bar pattern,
  search bar restyled with centered placeholder + gold pulse-glow, larger
  invite-code header with "Invite Friends to Add Movies!" eyebrow.
- List settings cleanup: 3-col header grid, inline regenerate+copy actions
  (no separate Invite Code section), "Rename List" copy.
- LegalFooter + TMDBFooter composed into all 4 segment layouts (app, auth,
  admin, public). CCPA + GDPR anchors added to /privacy.
- New CI guard: kong-auth-denylist.test.ts enforces non-anonymous GoTrue
  routes are 404'd at Kong before the catch-all.
- ONBOARDING.md added.
- CLAUDE.md notes the Create/Join row contract; PROJECT_SCOPE.md changelog
  rolled to 2026-05-23.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User 1 month ago
parent
commit
7ac26b11c7
42 changed files with 1510 additions and 977 deletions
  1. 1 0
      .gitignore
  2. 7 4
      CLAUDE.md
  3. 75 0
      ONBOARDING.md
  4. 187 65
      PROJECT_SCOPE.md
  5. 177 21
      cron/index.ts
  6. 172 0
      cron/package-lock.json
  7. 33 11
      docker-compose.yml
  8. 2 0
      package.json
  9. 72 585
      research/COMPLIANCE.md
  10. 78 0
      src/__tests__/guards/kong-auth-denylist.test.ts
  11. 41 3
      src/app/(app)/home/page.tsx
  12. 2 0
      src/app/(app)/layout.tsx
  13. 53 54
      src/app/(app)/list/[id]/page.tsx
  14. 23 4
      src/app/(app)/list/[id]/settings/page.tsx
  15. 6 2
      src/app/(auth)/layout.tsx
  16. 4 2
      src/app/(public)/privacy/page.tsx
  17. 8 1
      src/app/admin/layout.tsx
  18. 8 4
      src/app/api/auth/bootstrap/route.ts
  19. 20 23
      src/app/api/auth/signout/route.ts
  20. 3 3
      src/app/api/tmdb/discover/route.ts
  21. 7 6
      src/app/api/tmdb/movie/[id]/route.ts
  22. 18 8
      src/app/api/tmdb/movie/[id]/videos/route.ts
  23. 6 1
      src/app/api/tmdb/popular/route.ts
  24. 6 1
      src/app/api/tmdb/reel-posters/route.ts
  25. 13 2
      src/app/api/tmdb/search/route.ts
  26. 49 6
      src/app/globals.css
  27. 16 19
      src/app/logout/route.ts
  28. 12 10
      src/components/dice/list-roll-carousel.tsx
  29. 56 47
      src/components/groups/settings-panel.tsx
  30. 1 17
      src/components/home/empty-state.tsx
  31. 16 4
      src/components/home/join-list-button.tsx
  32. 17 22
      src/components/home/roll-section.tsx
  33. 13 9
      src/components/landing/teaser-card.tsx
  34. 8 6
      src/components/movies/movie-list-client.tsx
  35. 11 4
      src/components/movies/search-bar.tsx
  36. 29 0
      src/components/shared/legal-footer.tsx
  37. 25 28
      src/components/shared/tmdb-footer.tsx
  38. 77 0
      src/lib/tmdb/certification.ts
  39. 40 5
      src/lib/tmdb/client.ts
  40. 18 0
      src/types/tmdb.ts
  41. 83 0
      supabase/kong/kong.yml
  42. 17 0
      supabase/migrations/00007_landing_reel_posters_cert_backfill.sql

+ 1 - 0
.gitignore

@@ -2,6 +2,7 @@
 
 
 # dependencies
 # dependencies
 /node_modules
 /node_modules
+/cron/node_modules
 /.pnp
 /.pnp
 .pnp.*
 .pnp.*
 .yarn/*
 .yarn/*

+ 7 - 4
CLAUDE.md

@@ -5,7 +5,8 @@
 ## Development
 ## Development
 
 
 ```bash
 ```bash
-npm install && npm run build && npm run dev
+npm install && npm run build && npm run dev   # `predev` auto-boots the Supabase stack (db, auth, rest, realtime, kong, meta)
+npm run dev:down                              # tear down docker stack when done
 ```
 ```
 
 
 Docker: `docker compose up --build`. Supabase Studio at `localhost:3000` (dev only — restricted to 127.0.0.1 in production).
 Docker: `docker compose up --build`. Supabase Studio at `localhost:3000` (dev only — restricted to 127.0.0.1 in production).
@@ -16,7 +17,7 @@ Docker: `docker compose up --build`. Supabase Studio at `localhost:3000` (dev on
 
 
 ```
 ```
 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)
 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().
+Auth:        /api/auth/bootstrap (anon onboard, server-side `signInAnonymously()` → public.users + user_sessions + cookie; per-IP rate-limited, daily circuit-breaker via BOOTSTRAP_DAILY_CAP — count failures log + treat-as-zero so transient PostgREST blips don't 500 the user). /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)
 Groups:      /api/groups, /api/groups/join (rate-limited, server-side via service role key)
 Health:      /api/health
 Health:      /api/health
 Admin:       /admin (TOTP login, iron-session v8) — /api/admin/logout is SEPARATE from user signout
 Admin:       /admin (TOTP login, iron-session v8) — /api/admin/logout is SEPARATE from user signout
@@ -45,7 +46,7 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 - 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`.
 - 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.
 - 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.
 - 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.
+- Home roll teaser renders in place — do not router.push() from a home-page roll. Create/Join row lives in `home/page.tsx` (NOT in `RollSection`) so it survives when groups exist but movies don't; roll buttons gate on `hasGroups && hasMovies`, Create/Join only on `hasGroups`. `JoinListButton` root carries `sm:flex-1` — render as direct flex sibling of Create Link, no wrapper div.
 - 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.
 - 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.
 - `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).
 - 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).
@@ -62,7 +63,7 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 - **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.
 - **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.
 - 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).
 - 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.
+- GoTrue config: `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true`. Non-anonymous surfaces (`magiclink`, `recover`, `otp`, `resend`, `sso`, `sso/saml`) are denied at Kong via `request-termination` 404 routes ordered before the catch-all `/auth/v1/` in `supabase/kong/kong.yml`. Public `/signup` remains reachable but is unconfirmable (no SMTP, `MAILER_AUTOCONFIRM=false`); `GOTRUE_EXTERNAL_EMAIL_ENABLED=true` is required because v2.170.0 gates anonymous-sign-in user creation on the email provider being enabled.
 
 
 ## Security
 ## Security
 
 
@@ -80,6 +81,8 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 
 
 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).
 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).
 
 
+**Kong env-var interp (Kong 2.8.1):** Kong 2.x has no native env-var interpolation in declarative config (env vault is 3.x+). The `supabase-kong` service uses a custom entrypoint that seds `${ANON_KEY}` / `${SERVICE_ROLE_KEY}` from `/etc/kong-template/kong.yml` into `/tmp/kong.yml` at startup. `${...}` in `kong.yml` must be `$$` -escaped in `docker-compose.yml` to survive compose parsing.
+
 **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.
 **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
 ## Env Vars

+ 75 - 0
ONBOARDING.md

@@ -0,0 +1,75 @@
+# Welcome to MovieDice
+
+## How We Use Claude
+
+Based on User's usage over the last 30 days:
+
+Work Type Breakdown:
+Plan / Design ██████████░░░░░░░░░░ 50%
+Build Feature ████░░░░░░░░░░░░░░░░ 21%
+Debug / Fix ███░░░░░░░░░░░░░░░░░ 14%
+
+Top Skills & Commands:
+/usage ████████████████████ 14x/month
+/clear ██████░░░░░░░░░░░░░░ 4x/month
+/effort ██████░░░░░░░░░░░░░░ 4x/month
+/model ████░░░░░░░░░░░░░░░░ 3x/month
+/remote-control ████░░░░░░░░░░░░░░░░ 3x/month
+/permissions ████░░░░░░░░░░░░░░░░ 3x/month
+/batch ███░░░░░░░░░░░░░░░░░ 2x/month
+/compact ███░░░░░░░░░░░░░░░░░ 2x/month
+
+Top MCP Servers:
+_None configured_
+
+## Your Setup Checklist
+
+### Codebases
+
+- [ ] moviedice — https://gogs.altsol.dev/claude/moviedice
+
+### MCP Servers to Activate
+
+- [ ] _None configured for this team_
+
+### Skills to Know About
+
+- `/usage` — check your Claude usage / quota. The team checks this often.
+- `/effort` — adjust how much thinking effort Claude spends on a task.
+- `/model` — switch between Opus / Sonnet / Haiku for the current session.
+- `/remote-control` — drive Claude Code from another device or session.
+- `/permissions` — review and edit what Claude is allowed to do without prompting.
+- `/batch` — queue up multiple prompts to run in sequence.
+- `/compact` — compress conversation history when context gets long.
+- `/clear` — start a fresh conversation when switching tasks.
+- `@pm` — invoke the PM agent for project-status questions ("where are we?", scope checks, feature feasibility). Used heavily on this project.
+
+## Team Tips
+
+_TODO_
+
+## Get Started
+
+_TODO_
+
+<!-- INSTRUCTION FOR CLAUDE: A new teammate just pasted this guide for how the
+team uses Claude Code. You're their onboarding buddy — warm, conversational,
+not lecture-y.
+
+Open with a warm welcome — include the team name from the title. Then: "Your
+teammate uses Claude Code for [list all the work types]. Let's get you started."
+
+Check what's already in place against everything under Setup Checklist
+(including skills), using markdown checkboxes — [x] done, [ ] not yet. Lead
+with what they already have. One sentence per item, all in one message.
+
+Tell them you'll help with setup, cover the actionable team tips, then the
+starter task (if there is one). Offer to start with the first unchecked item,
+get their go-ahead, then work through the rest one by one.
+
+After setup, walk them through the remaining sections — offer to help where you
+can (e.g. link to channels), and just surface the purely informational bits.
+
+Don't invent sections or summaries that aren't in the guide. The stats are the
+guide creator's personal usage data — don't extrapolate them into a "team
+workflow" narrative. -->

File diff suppressed because it is too large
+ 187 - 65
PROJECT_SCOPE.md


+ 177 - 21
cron/index.ts

@@ -3,6 +3,8 @@ import { createClient } from "@supabase/supabase-js";
 
 
 const TMDB_API_BASE_URL = "https://api.themoviedb.org/3";
 const TMDB_API_BASE_URL = "https://api.themoviedb.org/3";
 const REEL_POSTER_COUNT = 20;
 const REEL_POSTER_COUNT = 20;
+// Over-fetch discover so cert-rejections / dedupe don't starve the final set.
+const DISCOVER_FETCH_PAGES = 2;
 
 
 // Mirror of PINNED_REEL_POSTERS in src/app/api/tmdb/reel-posters/route.ts.
 // Mirror of PINNED_REEL_POSTERS in src/app/api/tmdb/reel-posters/route.ts.
 // Keep in sync if either side changes.
 // Keep in sync if either side changes.
@@ -14,6 +16,81 @@ const PINNED_REEL_POSTERS: ReelPoster[] = [
   },
   },
 ];
 ];
 
 
+// Pinned entries are an explicit editorial policy override: they bypass
+// the PG-13-or-better cert filter at every layer they touch. The route
+// layer already serves these unconditionally; this set is used in the
+// cron to skip per-movie cert checks for the same titles.
+const PINNED_TMDB_IDS = new Set<number>(PINNED_REEL_POSTERS.map((p) => p.tmdb_id));
+
+// Inlined from src/lib/tmdb/certification.ts — the cron container builds
+// standalone (cron/Dockerfile copies only index.ts), so we cannot import
+// from src/. Keep in sync with that file.
+const ALLOWED_CERTIFICATIONS: Record<string, string[]> = {
+  US: ["G", "PG", "PG-13"],
+  GB: ["U", "PG", "12", "12A"],
+  DE: ["0", "6", "12"],
+  FR: ["U", "10", "12"],
+  AU: ["G", "PG", "M"],
+  CA: ["G", "PG", "14A"],
+  NL: ["AL", "6", "9", "12"],
+  ES: ["A", "7", "12"],
+  IT: ["T", "6+", "12+"],
+  JP: ["G", "PG12"],
+  KR: ["ALL", "12"],
+  BR: ["L", "10", "12"],
+  MX: ["AA", "A", "B"],
+  IE: ["G", "PG", "12A"],
+  SE: ["Btl", "7", "11"],
+};
+
+const DISCOVER_CERT_PARAMS: Record<string, string> = {
+  certification_country: "US",
+  "certification.lte": "PG-13",
+  include_adult: "false",
+};
+
+interface TMDBReleaseDate {
+  certification: string;
+  iso_639_1: string;
+  note: string;
+  release_date: string;
+  type: number;
+}
+
+interface TMDBReleaseDatesCountry {
+  iso_3166_1: string;
+  release_dates: TMDBReleaseDate[];
+}
+
+interface TMDBReleaseDatesResponse {
+  results: TMDBReleaseDatesCountry[];
+}
+
+function isMovieAllowedByCert(releaseDates: TMDBReleaseDatesResponse | undefined | null): boolean {
+  if (!releaseDates?.results?.length) return false;
+
+  let sawRecognizedCert = false;
+  let sawPositiveMatch = false;
+
+  for (const country of releaseDates.results) {
+    const allow = ALLOWED_CERTIFICATIONS[country.iso_3166_1];
+    if (!allow) continue;
+
+    for (const rd of country.release_dates ?? []) {
+      const cert = (rd.certification ?? "").trim();
+      if (!cert) continue;
+      sawRecognizedCert = true;
+      if (allow.includes(cert)) {
+        sawPositiveMatch = true;
+      } else {
+        return false;
+      }
+    }
+  }
+
+  return sawRecognizedCert && sawPositiveMatch;
+}
+
 interface ReelPoster {
 interface ReelPoster {
   tmdb_id: number;
   tmdb_id: number;
   poster_path: string;
   poster_path: string;
@@ -27,10 +104,18 @@ interface TMDBMovie {
   adult: boolean;
   adult: boolean;
 }
 }
 
 
-interface TMDBPopularResponse {
+interface TMDBDiscoverResponse {
   results: TMDBMovie[];
   results: TMDBMovie[];
 }
 }
 
 
+interface TMDBMovieDetails {
+  id: number;
+  title: string;
+  poster_path: string | null;
+  adult: boolean;
+  release_dates?: TMDBReleaseDatesResponse;
+}
+
 const SUPABASE_URL = process.env.SUPABASE_URL;
 const SUPABASE_URL = process.env.SUPABASE_URL;
 const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
 const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
 const TMDB_API_KEY = process.env.TMDB_API_KEY;
 const TMDB_API_KEY = process.env.TMDB_API_KEY;
@@ -46,41 +131,106 @@ function requireEnv(): { supabaseUrl: string; serviceKey: string; tmdbKey: strin
   };
   };
 }
 }
 
 
-async function fetchTMDBPopular(tmdbKey: string, page: number): Promise<TMDBMovie[]> {
-  const params = new URLSearchParams({
-    language: "en-US",
-    page: String(page),
-    include_adult: "false",
-  });
-  const res = await fetch(`${TMDB_API_BASE_URL}/movie/popular?${params.toString()}`, {
+async function tmdbGet<T>(
+  tmdbKey: string,
+  path: string,
+  params: Record<string, string>,
+): Promise<T> {
+  const qs = new URLSearchParams(params);
+  const res = await fetch(`${TMDB_API_BASE_URL}${path}?${qs.toString()}`, {
     headers: {
     headers: {
       Authorization: `Bearer ${tmdbKey}`,
       Authorization: `Bearer ${tmdbKey}`,
       Accept: "application/json",
       Accept: "application/json",
     },
     },
   });
   });
   if (!res.ok) {
   if (!res.ok) {
-    throw new Error(`TMDB popular request failed: ${res.status}`);
+    throw new Error(`TMDB ${path} request failed: ${res.status}`);
   }
   }
-  const data = (await res.json()) as TMDBPopularResponse;
+  return (await res.json()) as T;
+}
+
+async function fetchTMDBDiscover(tmdbKey: string, page: number): Promise<TMDBMovie[]> {
+  const data = await tmdbGet<TMDBDiscoverResponse>(tmdbKey, "/discover/movie", {
+    language: "en-US",
+    page: String(page),
+    sort_by: "popularity.desc",
+    ...DISCOVER_CERT_PARAMS,
+  });
   // Server-side adult filter (defense in depth alongside include_adult=false).
   // Server-side adult filter (defense in depth alongside include_adult=false).
   return data.results.filter((m) => !m.adult && m.poster_path);
   return data.results.filter((m) => !m.adult && m.poster_path);
 }
 }
 
 
+/**
+ * Per-movie cert check via /movie/{id}?append_to_response=release_dates.
+ * Returns true iff TMDB confirms the title is PG-13-or-better.
+ * Individual fetch failures are treated as "not allowed" (log + skip),
+ * so a transient TMDB blip never lets a movie slip past the policy.
+ */
+async function isAllowedByCertCheck(tmdbKey: string, tmdbId: number): Promise<boolean> {
+  // Pinned entries are an explicit editorial override — bypass the cert filter.
+  if (PINNED_TMDB_IDS.has(tmdbId)) {
+    console.log(`[cron] policy override: pinned tmdb_id=${tmdbId}`);
+    return true;
+  }
+  try {
+    const details = await tmdbGet<TMDBMovieDetails>(tmdbKey, `/movie/${tmdbId}`, {
+      append_to_response: "release_dates",
+    });
+    if (details.adult) return false;
+    return isMovieAllowedByCert(details.release_dates);
+  } catch (err) {
+    console.warn(
+      `[${new Date().toISOString()}] Cert check failed for tmdb_id=${tmdbId}, skipping:`,
+      err instanceof Error ? err.message : err,
+    );
+    return false;
+  }
+}
+
+async function filterByCertConcurrent<T extends { tmdb_id: number }>(
+  tmdbKey: string,
+  items: T[],
+): Promise<T[]> {
+  const CONCURRENCY = 6;
+  const results = new Array<boolean>(items.length);
+  let cursor = 0;
+
+  async function worker(): Promise<void> {
+    while (true) {
+      const i = cursor++;
+      if (i >= items.length) return;
+      results[i] = await isAllowedByCertCheck(tmdbKey, items[i].tmdb_id);
+    }
+  }
+
+  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker()));
+  return items.filter((_, i) => results[i]);
+}
+
 async function refreshReelPosters(): Promise<void> {
 async function refreshReelPosters(): Promise<void> {
   const { supabaseUrl, serviceKey, tmdbKey } = requireEnv();
   const { supabaseUrl, serviceKey, tmdbKey } = requireEnv();
 
 
-  const popular = await fetchTMDBPopular(tmdbKey, 1);
+  // 1. Pinned entries are an explicit editorial policy override and bypass
+  //    the cert filter (see PINNED_TMDB_IDS in isAllowedByCertCheck). They
+  //    are always inserted; each override is logged at filter time.
+  const allowedPins = await filterByCertConcurrent(tmdbKey, PINNED_REEL_POSTERS);
+
+  // 2. Fetch popular via discover with cert pre-filter, then belt-and-suspenders
+  //    per-movie cert check (mirrors fetchAndFilterByCert in src/lib/tmdb/client.ts).
+  const discoverMovies: TMDBMovie[] = [];
+  for (let page = 1; page <= DISCOVER_FETCH_PAGES; page++) {
+    discoverMovies.push(...(await fetchTMDBDiscover(tmdbKey, page)));
+  }
+  const discoverPosters: ReelPoster[] = discoverMovies.map((m) => ({
+    tmdb_id: m.id,
+    poster_path: m.poster_path!,
+    title: m.title,
+  }));
+  const allowedDiscover = await filterByCertConcurrent(tmdbKey, discoverPosters);
 
 
   const seen = new Set<number>();
   const seen = new Set<number>();
   const posters: ReelPoster[] = [];
   const posters: ReelPoster[] = [];
-  for (const p of [
-    ...PINNED_REEL_POSTERS,
-    ...popular.map((m) => ({
-      tmdb_id: m.id,
-      poster_path: m.poster_path!,
-      title: m.title,
-    })),
-  ]) {
+  for (const p of [...allowedPins, ...allowedDiscover]) {
     if (seen.has(p.tmdb_id)) continue;
     if (seen.has(p.tmdb_id)) continue;
     seen.add(p.tmdb_id);
     seen.add(p.tmdb_id);
     posters.push(p);
     posters.push(p);
@@ -128,14 +278,20 @@ cron.schedule("0 3 1,15 * *", async () => {
 // Refresh trailer URLs — daily at 4:00 AM UTC
 // Refresh trailer URLs — daily at 4:00 AM UTC
 cron.schedule("0 4 * * *", async () => {
 cron.schedule("0 4 * * *", async () => {
   console.log(`[${new Date().toISOString()}] Trailer refresh: starting`);
   console.log(`[${new Date().toISOString()}] Trailer refresh: starting`);
-  // TODO: re-validate trailer URLs for movies missing trailers
+  // TODO: re-validate trailer URLs for movies missing trailers.
+  // When implemented: this stub does not touch TMDB. If it ever fetches new
+  // movie metadata from TMDB, gate every result with isMovieAllowedByCert
+  // (use append_to_response=release_dates as in refreshReelPosters).
   console.log(`[${new Date().toISOString()}] Trailer refresh: complete`);
   console.log(`[${new Date().toISOString()}] Trailer refresh: complete`);
 });
 });
 
 
 // Refresh TMDB metadata — monthly on the 1st at 5:00 AM UTC
 // Refresh TMDB metadata — monthly on the 1st at 5:00 AM UTC
 cron.schedule("0 5 1 * *", async () => {
 cron.schedule("0 5 1 * *", async () => {
   console.log(`[${new Date().toISOString()}] Metadata refresh: starting`);
   console.log(`[${new Date().toISOString()}] Metadata refresh: starting`);
-  // TODO: refresh metadata for movies where metadata_refreshed_at > 30 days
+  // TODO: refresh metadata for movies where metadata_refreshed_at > 30 days.
+  // When implemented: re-check isMovieAllowedByCert on each refreshed title;
+  // if a movie has fallen out of the allowlist, surface it to admin review
+  // rather than silently keeping stale metadata.
   console.log(`[${new Date().toISOString()}] Metadata refresh: complete`);
   console.log(`[${new Date().toISOString()}] Metadata refresh: complete`);
 });
 });
 
 

+ 172 - 0
cron/package-lock.json

@@ -0,0 +1,172 @@
+{
+  "name": "moviedice-cron",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "moviedice-cron",
+      "version": "0.1.0",
+      "dependencies": {
+        "@supabase/supabase-js": "^2.45.0",
+        "node-cron": "^3.0.3"
+      },
+      "devDependencies": {
+        "@types/node": "^20",
+        "typescript": "^5"
+      }
+    },
+    "node_modules/@supabase/auth-js": {
+      "version": "2.106.1",
+      "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz",
+      "integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "2.8.1"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@supabase/functions-js": {
+      "version": "2.106.1",
+      "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz",
+      "integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "2.8.1"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@supabase/phoenix": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
+      "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
+      "license": "MIT"
+    },
+    "node_modules/@supabase/postgrest-js": {
+      "version": "2.106.1",
+      "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz",
+      "integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "2.8.1"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@supabase/realtime-js": {
+      "version": "2.106.1",
+      "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz",
+      "integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==",
+      "license": "MIT",
+      "dependencies": {
+        "@supabase/phoenix": "^0.4.2",
+        "tslib": "2.8.1"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@supabase/storage-js": {
+      "version": "2.106.1",
+      "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz",
+      "integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==",
+      "license": "MIT",
+      "dependencies": {
+        "iceberg-js": "^0.8.1",
+        "tslib": "2.8.1"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@supabase/supabase-js": {
+      "version": "2.106.1",
+      "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz",
+      "integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==",
+      "license": "MIT",
+      "dependencies": {
+        "@supabase/auth-js": "2.106.1",
+        "@supabase/functions-js": "2.106.1",
+        "@supabase/postgrest-js": "2.106.1",
+        "@supabase/realtime-js": "2.106.1",
+        "@supabase/storage-js": "2.106.1"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.41",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
+      "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/iceberg-js": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+      "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/node-cron": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
+      "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
+      "license": "ISC",
+      "dependencies": {
+        "uuid": "8.3.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "deprecated": "uuid@10 and below is no longer supported.  For ESM codebases, update to uuid@latest.  For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    }
+  }
+}

+ 33 - 11
docker-compose.yml

@@ -89,21 +89,33 @@ services:
 
 
       GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
       GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
       GOTRUE_URI_ALLOW_LIST: ""
       GOTRUE_URI_ALLOW_LIST: ""
-      # Paired with GOTRUE_EXTERNAL_EMAIL_ENABLED below: the recovery
-      # synthetic-identity design (<uid>@moviedice.invalid + HKDF password)
-      # requires signInWithPassword, so email login must be on. Public signups
-      # are disabled so the email path stays admin-only (admin.updateUserById
-      # in /api/auth/recovery/generate).
-      GOTRUE_DISABLE_SIGNUP: "true"
+      # Must be false for signInAnonymously() (used by /api/auth/bootstrap)
+      # to work in GoTrue v2.170.0 — DISABLE_SIGNUP=true overrides the
+      # ANONYMOUS_USERS_ENABLED flag in this version. Public email signup
+      # is still neutralized in practice: no SMTP is configured and
+      # MAILER_AUTOCONFIRM=false, so any account created via public /signup
+      # remains unconfirmable. The recovery flow (admin.updateUserById in
+      # /api/auth/recovery/generate) uses the service-role admin endpoint,
+      # unaffected by this flag.
+      GOTRUE_DISABLE_SIGNUP: "false"
 
 
       GOTRUE_JWT_SECRET: ${JWT_SECRET}
       GOTRUE_JWT_SECRET: ${JWT_SECRET}
       GOTRUE_JWT_EXP: "3600"
       GOTRUE_JWT_EXP: "3600"
       GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
       GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
 
 
-      # Anonymous auth enabled — core requirement
+      # Anonymous auth enabled — core requirement. Newer GoTrue (v2.157+)
+      # reads GOTRUE_ANONYMOUS_USERS_ENABLED; the older EXTERNAL_-prefixed
+      # name is kept for backward compat / belt-and-braces.
+      GOTRUE_ANONYMOUS_USERS_ENABLED: "true"
       GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true"
       GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true"
 
 
-      # Disable all other auth methods
+      # Non-anonymous auth surfaces are denied at Kong (see supabase/kong/kong.yml
+      # request-termination routes for /auth/v1/magiclink, /recover, /otp,
+      # /resend, /sso, /sso/saml). Public /signup remains reachable but is
+      # neutralized: no SMTP is configured and MAILER_AUTOCONFIRM=false, so
+      # any account created there is unconfirmable. EMAIL_ENABLED stays true
+      # only because GoTrue v2.170.0 ties anonymous sign-in's user creation
+      # path to the email provider being enabled.
       GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
       GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
       GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
       GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
       GOTRUE_MAILER_AUTOCONFIRM: "false"
       GOTRUE_MAILER_AUTOCONFIRM: "false"
@@ -221,15 +233,25 @@ services:
       - supabase-realtime
       - supabase-realtime
     environment:
     environment:
       KONG_DATABASE: "off"
       KONG_DATABASE: "off"
-      KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+      # Kong 2.x has no native env-var interpolation in declarative config
+      # (env vault is 3.x+). The entrypoint below seds ANON_KEY /
+      # SERVICE_ROLE_KEY into a writable copy before kong starts.
+      KONG_DECLARATIVE_CONFIG: /tmp/kong.yml
       KONG_DNS_ORDER: LAST,A,CNAME
       KONG_DNS_ORDER: LAST,A,CNAME
-      KONG_PLUGINS: request-transformer,cors,key-auth,acl
+      KONG_PLUGINS: request-transformer,cors,key-auth,acl,request-termination
       KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
       KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
       KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
       KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
       ANON_KEY: ${ANON_KEY}
       ANON_KEY: ${ANON_KEY}
       SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
       SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
     volumes:
     volumes:
-      - ./supabase/kong/kong.yml:/var/lib/kong/kong.yml:ro
+      - ./supabase/kong/kong.yml:/etc/kong-template/kong.yml:ro
+    entrypoint: ["/bin/sh", "-c"]
+    command:
+      - |
+        sed -e 's|$${ANON_KEY}|'"$$ANON_KEY"'|g' \
+            -e 's|$${SERVICE_ROLE_KEY}|'"$$SERVICE_ROLE_KEY"'|g' \
+            /etc/kong-template/kong.yml > /tmp/kong.yml \
+        && exec /docker-entrypoint.sh kong docker-start
     networks:
     networks:
       - internal
       - internal
 
 

+ 2 - 0
package.json

@@ -3,7 +3,9 @@
   "version": "0.1.0",
   "version": "0.1.0",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
+    "predev": "docker compose up -d supabase-db supabase-auth supabase-rest supabase-realtime-schema-init supabase-realtime supabase-realtime-tenant-seed supabase-kong supabase-meta",
     "dev": "next dev",
     "dev": "next dev",
+    "dev:down": "docker compose down",
     "build": "next build",
     "build": "next build",
     "start": "next start",
     "start": "next start",
     "lint": "eslint",
     "lint": "eslint",

+ 72 - 585
research/COMPLIANCE.md

@@ -1,675 +1,162 @@
-# Compliance Review -- MovieDice
+# Compliance Review — MovieDice (UI + Filter Batch)
 
 
-Reviewed: 2026-04-05 | Reviewer: Claude (automated)
-Scope: Pre-implementation architecture review of PROJECT_SCOPE.md | Standard: General (TMDB ToS, GDPR, WCAG 2.1 AA, PWA, Docker)
-
-**Note:** This is a pre-implementation review. No source code exists yet. All findings are based on the architectural design and project scope document. Line references point to PROJECT_SCOPE.md.
+Reviewed 2026-05-21. Scope: uncommitted batch on master (cert filter, layout/footer refactor, carousel button conversion, settings refresh, privacy/legal footer).
 
 
 ---
 ---
 
 
 ## CRITICAL
 ## CRITICAL
 
 
-### 1. TMDB API Key Exposed to Client-Side Code
-
-`PROJECT_SCOPE.md:259` -- The tech stack specifies TMDB API integration with client-side search (debounced queries from the browser). The scope does not specify server-side proxying of TMDB API calls. If the TMDB API key is used directly in client-side fetch calls, it will be exposed in browser network requests and potentially in bundled JavaScript. TMDB's terms require that API keys not be publicly accessible. This also enables abuse by third parties who extract the key.
-
-**Fix:** All TMDB API calls must be routed through Next.js API routes (or Server Actions). The `TMDB_API_KEY` environment variable must only be accessible server-side. Create a `/api/tmdb/search`, `/api/tmdb/popular`, and `/api/tmdb/trailer` proxy layer. Mark the env var in `next.config.js` without the `NEXT_PUBLIC_` prefix to ensure it is never bundled into client code.
-
-**Implementation Risk:** Adds a network hop (client -> Next.js server -> TMDB), which slightly increases latency for search queries. Mitigate with TanStack Query caching and appropriate `Cache-Control` headers on the proxy responses.
-
----
-
-### 2. TMDB Attribution Requirement Not Addressed
-
-`PROJECT_SCOPE.md` (entire document) -- TMDB's Terms of Use require visible attribution on any application using their API. The scope document makes no mention of displaying the TMDB logo, a link to themoviedb.org, or the required disclaimer text ("This product uses the TMDB API but is not endorsed or certified by TMDB"). Omitting attribution violates the API terms and risks having the API key revoked.
-
-**Fix:** Add a mandatory implementation item: display the TMDB attribution logo and text in the app footer on every page. The logo must link to https://www.themoviedb.org/. Include this in Phase 1 (Foundation) as a hard requirement, not a polish item.
-
-**Implementation Risk:** Minimal. This is a UI addition only. Ensure the logo meets TMDB's brand guidelines for size and placement.
-
----
-
-### 3. No Privacy Policy or Terms of Service Specified
-
-`PROJECT_SCOPE.md:313-319` -- The Privacy section notes "no personal data beyond display name is stored," but this is incorrect from a legal standpoint. The app collects and stores: UUIDs (personal data under GDPR), display names, avatar color preferences, recovery codes (hashed), group membership data, movie preferences (which movies a user added), and IP addresses in server logs. Even with anonymous auth, GDPR and similar privacy laws (CCPA, LGPD) require a privacy policy explaining what data is collected, how it is used, how long it is retained, and how users can request deletion.
-
-**Fix:** Create a privacy policy page accessible from the landing page footer. Document: (a) what data is collected (UUID, display name, avatar color, group membership, movie additions, IP addresses in logs), (b) purpose of each data point, (c) retention period, (d) how to request account deletion, (e) cookie/local storage usage, (f) third-party data sharing (Supabase as processor, TMDB API calls). Add this as a Phase 1 implementation item.
-
-**Implementation Risk:** Requires legal review for completeness. A template-based approach is acceptable for MVP but should be reviewed by a legal professional before public launch.
-
----
-
-### 4. Recovery Code Security -- Insufficient Specification
-
-`PROJECT_SCOPE.md:34,269` -- Recovery codes are described as "a longer alphanumeric code shown once after account creation" with recovery codes "hashed before storage." However, the scope does not specify: (a) the entropy/length of recovery codes, (b) the hashing algorithm (must be bcrypt/argon2, not SHA-256), (c) rate limiting on recovery code attempts, (d) brute-force protection. A weak recovery code or fast hash function could allow an attacker to take over accounts by guessing recovery codes.
-
-**Fix:** Mandate the following in the implementation: (a) Recovery codes must be at least 128 bits of entropy (e.g., 24 alphanumeric characters or a mnemonic phrase). (b) Hash with bcrypt (cost factor 12+) or argon2id. (c) Rate limit recovery code attempts to 5 per minute per IP. (d) Lock out after 10 failed attempts with a cooldown period. (e) Log all recovery code attempts for security monitoring.
-
-**Implementation Risk:** Rate limiting requires server-side state tracking (Redis, Supabase table, or in-memory with caveats). Bcrypt hashing adds ~250ms per attempt, which is acceptable for a security-critical operation.
-
----
-
-### 5. Invite Code as Sole Access Control -- Brute Force Risk
-
-`PROJECT_SCOPE.md:35-36,315` -- Invite codes are described as "short human-readable" (e.g., WOLF-42) and serve as the sole access control for group membership. A code like WOLF-42 has extremely low entropy (a word from a list + a 2-digit number). An attacker could enumerate valid invite codes and join arbitrary groups, accessing their movie lists. There is no mention of rate limiting on join attempts.
-
-**Fix:** (a) Increase invite code entropy: use at least 3 words + 3 digits (e.g., WOLF-RAIN-42X) or a longer random string. (b) Rate limit join attempts to 5 per minute per IP/user. (c) Lock out after 15 failed attempts. (d) Consider adding optional group passwords for sensitive groups. (e) The invite code regeneration feature (already in scope) is good -- ensure it is prominently surfaced to admins.
-
-**Implementation Risk:** Longer codes reduce shareability (harder to type/remember). Balance security with UX by testing code formats with users. Rate limiting adds server-side complexity.
-
----
-
-### 6. Master Admin TOTP Secret in Environment Variable -- Rotation and Backup Gaps
-
-`PROJECT_SCOPE.md:329-330` -- The TOTP secret is stored as an environment variable. This means: (a) rotating the TOTP secret requires redeployment, (b) there is no backup/recovery mechanism if the authenticator app is lost, (c) the TOTP secret is visible to anyone with access to the deployment environment, (d) there is no session expiry specified for admin sessions.
-
-**Fix:** (a) Document the TOTP secret rotation procedure (generate new secret, update env var, redeploy, re-enroll authenticator). (b) Generate and store backup codes for the master admin (encrypted, stored separately from the TOTP secret). (c) Specify admin session expiry (recommend 1 hour max, 15 minutes idle timeout). (d) Implement CSRF protection on admin actions. (e) Log all admin actions with timestamps for audit trail.
-
-**Implementation Risk:** Adding backup codes increases the attack surface slightly but prevents lockout. Session expiry may be annoying for extended admin sessions but is necessary for security.
-
----
-
-## CODE QUALITY
-
-### 7. No Linting, Formatting, or Type Checking Standards Specified
-
-`PROJECT_SCOPE.md` (entire document) -- The scope specifies no code quality tooling. For a Next.js/TypeScript project, the following should be established before any code is written: ESLint configuration, Prettier configuration, TypeScript strict mode, import ordering rules, and a pre-commit hook to enforce them. Without these, code quality will drift from the first commit, especially if multiple developers contribute.
-
-**Fix:** Add a Phase 0 or Phase 1.0 task: (a) Initialize with TypeScript strict mode (`"strict": true` in tsconfig.json). (b) Configure ESLint with `next/core-web-vitals` and `next/typescript` presets. (c) Configure Prettier with consistent rules (single quotes, trailing commas, 100-char line width). (d) Add `lint-staged` + `husky` for pre-commit enforcement. (e) Add `.editorconfig` for cross-editor consistency.
-
-**Implementation Risk:** None. This is a one-time setup cost that saves significant time later.
-
----
-
-### 8. No Testing Strategy Defined
-
-`PROJECT_SCOPE.md:406-421` -- Phase 9 describes manual QA and cross-device testing, but there is no mention of automated testing at any level: no unit tests, no integration tests, no end-to-end tests, no API tests. For a real-time collaborative app with complex state management (group membership, watched state, cross-list rolls), manual testing alone is insufficient and will lead to regressions.
-
-**Fix:** Add a testing mandate to Phase 1: (a) Set up Vitest for unit tests (utility functions, emotion-to-genre mapping, invite code generation). (b) Set up React Testing Library for component tests. (c) Set up Playwright for E2E tests (at minimum: onboarding flow, add movie, roll dice, real-time sync). (d) Require tests for all business logic before merge. (e) Add CI pipeline (GitHub Actions) that runs tests on every PR.
-
-**Implementation Risk:** Adds development time but significantly reduces bug density. Start with critical path E2E tests and expand coverage over time.
-
----
-
-### 9. No CI/CD Pipeline Specified
-
-`PROJECT_SCOPE.md:322-330` -- Deployment is described as "Vercel auto-deploys from main branch" (and Docker for the user's primary case), but there is no mention of: CI checks before deployment, automated testing gates, build verification, security scanning, or environment promotion (staging -> production).
-
-**Fix:** Establish a CI pipeline before development begins: (a) GitHub Actions workflow running on every PR: lint, type-check, test, build. (b) Block merges to main if any step fails. (c) For Docker: add a workflow that builds the Docker image and runs smoke tests against it. (d) Add dependency vulnerability scanning (npm audit, Dependabot/Renovate). (e) Consider a staging environment for pre-production validation.
-
-**Implementation Risk:** Minimal. GitHub Actions free tier is sufficient for this project size. The main risk is CI flakiness from E2E tests, which can be mitigated with retry logic.
-
----
-
-### 10. Emotion-to-Genre Mapping Lacks Extensibility Pattern
-
-`PROJECT_SCOPE.md:452-469` -- The emotion-to-genre mapping is defined as a static table in the scope document. The implementation should use a configuration-driven approach (JSON file or database table) rather than hardcoded if-else/switch statements, to allow easy updates without code changes.
-
-**Fix:** Implement the mapping as a typed constant object (e.g., `EMOTION_GENRE_MAP`) in a dedicated configuration file. Include the mapping table in the scope as a data contract. Consider moving this to the database post-MVP to allow runtime updates.
-
-**Implementation Risk:** Minimal. A configuration file is slightly more complex than inline constants but much more maintainable.
-
----
-
-## DOCUMENTATION GAPS
-
-### 11. No API Route Documentation Standard
-
-`PROJECT_SCOPE.md` (entire document) -- The scope defines several API-like interactions (TMDB search, group join, movie add/remove, admin actions) but does not specify an API documentation standard. For a project with both client-side and server-side routes, undocumented APIs lead to integration errors and make onboarding new developers difficult.
-
-**Fix:** Establish a documentation requirement: every Next.js API route and Server Action must include a JSDoc header documenting: HTTP method, path, request body/query parameters with types, response shape with status codes, authentication requirements, and error responses. Consider generating an OpenAPI spec from these annotations.
+### 1. STRICT cert walker silently hides legitimate titles in search
 
 
----
-
-### 12. No Error Code or Error Response Standard
+`src/lib/tmdb/certification.ts:51-77`, `src/app/api/tmdb/search/route.ts:32-39` — The walker requires `sawRecognizedCert && sawPositiveMatch`. A title is rejected if **no recognized country reports any certification at all** — extremely common for older, foreign-language, and indie titles (TMDB lets contributors leave `certification` empty). `/api/tmdb/search` post-filters with the same walker, so users searching legitimate PG/PG-13-equivalent movies see them silently vanish with no UX signal — invisible failure mode is worse than a permissive filter.
 
 
-`PROJECT_SCOPE.md:383` -- Phase 5.6 mentions "Error handling: invalid invite code, TMDB API failure, network errors" but does not define an error response format, error codes, or error categorization. Without a standard, each error will be handled ad-hoc with inconsistent user-facing messages.
+**Fix:** Define an explicit policy for "no cert data" titles. Options: (a) allow US-released-but-empty-cert when paired with a non-recent release date; (b) treat known no-rating tokens (`NR`, `""`, `Not Rated`, `Unrated`) as explicitly allowed; (c) fall back to TMDB `adult` flag + popularity threshold when cert data is absent. Document the choice in the file header.
 
 
-**Fix:** Define a standard error response shape before development: `{ error: { code: string, message: string, details?: object } }`. Create an error code enum (e.g., `INVALID_INVITE_CODE`, `TMDB_UNAVAILABLE`, `RATE_LIMITED`, `UNAUTHORIZED`). Map each code to a user-friendly message. Centralize error handling in a shared utility.
+**Implementation Risk:** Loosening could let through R-rated indies with missing US cert. Recommend a one-week log of "rejected for no-cert" counts before deciding the policy.
 
 
 ---
 ---
 
 
-### 13. Database Migration Strategy Not Specified
-
-`PROJECT_SCOPE.md:265-308` -- The data model is defined but there is no mention of how database schema changes will be managed. Without a migration strategy, schema changes during development will be ad-hoc and difficult to reproduce across environments.
+### 2. Pre-existing `movies` rows now break `<ListMoreInfoModal>`
 
 
-**Fix:** Use Supabase's migration system or a tool like `supabase db diff` / `supabase migration new`. Store all migrations in version control. Never modify the database schema directly in production -- always through versioned migrations. Document this in the project README.
-
----
+`src/app/api/tmdb/movie/[id]/route.ts:25-28`, `src/components/dice/list-more-info-modal.tsx` — Any movie added to `public.movies` before cert filtering shipped (or one that flips after a future allowlist tightening) will now fail `isMovieAllowedByCert` when the modal opens. The route correctly 404s to avoid existence leakage, but the user just sees the modal fetch fail on a movie that's already on their list.
 
 
-### 14. Supabase Row-Level Security (RLS) Not Mentioned
+**Fix:** Add a `?context=in-list` (or similar) opt-in to `/api/tmdb/movie/[id]` that skips the cert gate when the caller is fetching details for a movie already in the user's group (server can verify via session + `movies` lookup before bypassing). Alternative: backfill — sweep `movies`, soft-flag any that no longer pass, surface UI message instead of silent 404.
 
 
-`PROJECT_SCOPE.md:265-308` -- The data model defines tables and relationships but makes no mention of Row-Level Security policies. Supabase strongly recommends RLS for all tables. Without RLS, any authenticated user (or anyone with the anon key) could read/write any row in any table via the Supabase client, completely bypassing group membership checks.
-
-**Fix:** This is a borderline-critical security issue. Define RLS policies for every table: (a) `users`: users can only read/update their own row. (b) `groups`: only members can read; only admin can update/delete. (c) `group_members`: only members of the group can read; only admin can delete others. (d) `movies`: only group members can CRUD. (e) `landing_reel_posters`: public read, no public write. (f) `admin_sessions`: no public access. Implement these in Phase 1 alongside schema creation.
-
-**Implementation Risk:** RLS policies that are too restrictive will break functionality. Test each policy thoroughly. Supabase's service role key (used server-side) bypasses RLS, which is correct for admin operations.
+**Implementation Risk:** Context-bypass leaks existence to authenticated members of the owning list only — acceptable. Backfill is destructive and needs admin sign-off.
 
 
 ---
 ---
 
 
-## PERFORMANCE
+## HIGH
 
 
-### 15. Landing Page Slot-Machine Animation -- Heavy Asset Loading
+### 3. Admin layout double `min-h-screen` breaks login/dashboard chrome
 
 
-`PROJECT_SCOPE.md:74-79` -- The landing page slot-machine animation spins through ~20 movie poster images across 3 reels. This means loading 20 poster images before the animation can play smoothly. On mobile connections, this could be 2-4 MB of images that must load before the primary CTA is interactive, causing slow page loads -- the user's primary concern.
+`src/app/admin/layout.tsx:5-9`, `src/app/admin/login/page.tsx:40`, `src/app/admin/page.tsx:13` — The layout now wraps children in `flex min-h-screen flex-col bg-neutral-950` + `<TMDBFooter />` below a `flex-1` slot. Both child pages still self-render their own `min-h-screen` container. Net effect: the child fills 100vh, the footer renders below it, the page now requires scroll by default, and the centered login UX is offset. `bg-neutral-950` is duplicated (harmless).
 
 
-**Fix:** (a) Use TMDB's smallest poster size (`w92` or `w154`) for the reel animation -- full-size posters are unnecessary at reel-spin speed. (b) Preload the reel poster set using `<link rel="preload">` or a service worker cache. (c) Store the poster URLs in the `landing_reel_posters` table (already planned) and serve them from a Next.js API route with aggressive `Cache-Control` headers (e.g., `max-age=86400`). (d) Use CSS `will-change: transform` on reel elements for GPU-accelerated animation. (e) Consider using a single sprite sheet or CSS-based animation with `background-position` for the reel spin to avoid per-image loading. (f) Show a skeleton/placeholder while posters load, and allow the Roll button to be interactive even before all images are loaded.
+**Fix:** Remove `min-h-screen` from both `admin/login/page.tsx` and `admin/page.tsx`. Drop the now-redundant inner `bg-neutral-950`. Replace login centering with `flex-1 flex items-center justify-center` on an inner container so it still centers within the layout's flex-1 main area.
 
 
-**Implementation Risk:** Smaller poster sizes may look blurry on high-DPI screens. Use `w185` as a compromise. Sprite sheets add build complexity but dramatically improve animation performance.
+**Implementation Risk:** None if done correctly — Playwright/manual check the centered-login layout after.
 
 
 ---
 ---
 
 
-### 16. No Image Optimization Strategy
+### 4. List-page back/settings links use raw `<a href>` and drop client state
 
 
-`PROJECT_SCOPE.md:293` -- The scope stores `poster_path` (TMDB relative path) and constructs full URLs at render time. However, there is no mention of: (a) using Next.js `<Image>` component for automatic optimization, (b) responsive image sizing (different sizes for grid view vs. expanded panel vs. reel animation), (c) lazy loading for below-the-fold images, (d) blur placeholder generation, (e) WebP/AVIF format negotiation.
+`src/app/(app)/list/[id]/page.tsx:46-67, 70-91` — Both navigation chrome elements use `<a href="/home">` / `<a href="/list/{id}/settings">` instead of `<Link>` from `next/link`. Each click triggers a full document load: TanStack Query cache evicts, IndexedDB-persisted offline data must rehydrate, `AuthBootstrap` re-runs, optimistic mutation state is lost. The rest of the app uses `<Link>`.
 
 
-**Fix:** Mandate the use of Next.js `<Image>` component (or `next/image` with a custom TMDB loader) for all poster images. Configure TMDB image sizes per context: `w185` for grid thumbnails, `w342` for expanded panel, `w92`/`w154` for reel animation. Enable lazy loading for all images below the fold. Add TMDB's image domain to `next.config.js` `images.remotePatterns`. Use `placeholder="blur"` with a low-resolution blur data URL generated at add-time or cached.
+**Fix:** Convert both anchors to `<Link href={...}>`. Preserve className, aria-label, svg child.
 
 
-**Implementation Risk:** Next.js `<Image>` with external domains requires proper configuration. TMDB images are already served from a CDN, so double-optimization is unnecessary -- focus on correct sizing and lazy loading.
+**Implementation Risk:** None; `<Link>` accepts arbitrary children including svg.
 
 
 ---
 ---
 
 
-### 17. Unbounded Real-Time Subscriptions
+## MEDIUM
 
 
-`PROJECT_SCOPE.md:48,365` -- Supabase real-time subscriptions are specified for live add/remove/watched-status updates. If a user belongs to multiple groups, the app would need to maintain one subscription per group (or a single subscription filtered to all their groups). The scope does not address: (a) subscription lifecycle management (subscribe on mount, unsubscribe on unmount), (b) reconnection logic on network interruption, (c) subscription count limits (Supabase free tier has limits on concurrent connections).
+### 5. `aria-live="polite"` on the carousel teaser `<button>`
 
 
-**Fix:** (a) Only subscribe to the currently-viewed list, not all lists simultaneously. Unsubscribe when navigating away. (b) On the home page, use polling or a single subscription for movie counts rather than full list subscriptions. (c) Implement exponential backoff reconnection. (d) Set a maximum subscription count and degrade gracefully if exceeded. (e) Use Supabase's channel-based subscriptions with proper cleanup in React `useEffect` return functions.
+`src/components/dice/list-roll-carousel.tsx:234` — The teaser button has `aria-live="polite"`, but its accessible name (`More info about {title}`) is static once the winner is set. Worse, the button sits **inside** another `aria-live="polite"` region at line 204 — overlapping live regions can cause double-announce or AT confusion. `RollAnnouncer` is the canonical announcement source elsewhere.
 
 
-**Implementation Risk:** Limiting subscriptions to the active view means the home page movie counts may be slightly stale. This is an acceptable trade-off for resource efficiency. Document the staleness window (e.g., "counts refresh every 30 seconds on the home page").
+**Fix:** Remove `aria-live="polite"` from the `<button>` on line 234. Also drop the wrapping `aria-live` on line 204 — rely on `RollAnnouncer` for the announcement.
 
 
 ---
 ---
 
 
-### 18. Infinite Scroll Without Virtualization
+### 6. Carousel "i" glyph is decorative noise post-conversion
 
 
-`PROJECT_SCOPE.md:41,145-147` -- The grid loads 12 movies initially and appends more on scroll. For groups with large watchlists (50-200+ movies), all previously loaded movie cards remain in the DOM. This causes memory bloat and rendering slowdowns, especially on low-end mobile devices with the full-bleed poster images.
+`src/components/dice/list-roll-carousel.tsx:255-260` — The whole card is now the click target. The italic-serif "i" badge is `aria-hidden` (correct) but visually implies a separate hit target. Users may hesitate ("do I click the i?").
 
 
-**Fix:** Implement windowed/virtualized scrolling using a library like `@tanstack/react-virtual` or `react-window`. Only render DOM nodes for movies currently in or near the viewport. This keeps memory and DOM node count constant regardless of list size.
-
-**Implementation Risk:** Virtualization adds complexity to the inline panel expansion (since the expanded panel occupies space between rows). The virtualization library must account for variable row heights. Test thoroughly with the inline expansion UX.
+**Fix:** Remove the `<span aria-hidden>i</span>` or replace with a clearly decorative chevron / "tap for info" microcopy. Recompute spacing of the genre chip row below.
 
 
 ---
 ---
 
 
-### 19. Cross-List Roll May Require Expensive Query
+### 7. `/api/tmdb/search` post-filter fan-out is heavy
 
 
-`PROJECT_SCOPE.md:119,369` -- Rolling across all user lists combined requires fetching all unwatched movies from all groups the user belongs to. Without optimization, this is an N+1 query pattern (one query per group) or a complex join. For users in many groups with large lists, this could be slow.
+`src/app/api/tmdb/search/route.ts:32-39`, `src/lib/tmdb/client.ts:57-88` — Each search makes up to 20× `/movie/{id}?append_to_response=release_dates` calls (concurrency 6). A debounced user typing 8 chars produces 5-7 search requests → up to 140 detail calls cold-cache. `s-maxage=300` mitigates repeat queries but per-instance edges and cold deploys will push TMDB's 50 req/sec budget.
 
 
-**Fix:** Implement a single optimized SQL query: `SELECT m.* FROM movies m JOIN group_members gm ON m.group_id = gm.group_id WHERE gm.user_id = $1 AND m.watched = false`. Create a composite index on `movies(group_id, watched)` and `group_members(user_id)`. Consider caching the roll pool briefly (TanStack Query with a 30-second `staleTime`).
+**Fix:** Cache cert verdicts in Postgres keyed by `tmdb_id` with a 30-day TTL, lazily populated. Alternatively, only post-filter at add-time (search shows raw results, add endpoint rejects disallowed) — different UX trade-off.
 
 
-**Implementation Risk:** The query is straightforward but must be tested with realistic data volumes. Index creation is a one-time cost.
+**Implementation Risk:** Cached verdicts go stale if TMDB updates a rating — 30-day TTL is acceptable. Lazy at add-time means user can see ineligible titles in results.
 
 
 ---
 ---
 
 
-### 20. Trailer URL Refresh Job -- Potential TMDB Rate Limit Violation
+### 8. Privacy-page CCPA anchor offset hack is non-functional
 
 
-`PROJECT_SCOPE.md:52,388-392` -- The bi-weekly background job refreshes trailer URLs for movies where `trailer_url IS NULL`. If many movies have null trailers, this job could fire dozens or hundreds of TMDB API requests in rapid succession, exceeding the ~40 requests/10 seconds rate limit.
+`src/app/(public)/privacy/page.tsx:138-140` — `<span id="ccpa" className="block -translate-y-4" aria-hidden>` placed inside `<section id="gdpr">`. The transform doesn't actually create a scroll-offset (the anchor target still resolves to the original layout box in all major browsers), and both anchors land at the same line. `/privacy#ccpa` and `/privacy#gdpr` are visually identical, undermining the legal-footer claim of differentiated landings.
 
 
-**Fix:** (a) Implement request throttling in the refresh job: maximum 3 requests per second with exponential backoff on 429 responses. (b) Process movies in batches of 10 with a 5-second delay between batches. (c) Log the total count of movies needing refresh at job start. (d) Set a maximum of 100 movies processed per job run to prevent runaway execution. (e) Track and alert on persistent null trailers (some movies genuinely have no trailer).
+**Fix:** Split section 6 into two sub-sections — "Your Rights (CCPA / US California)" with `id="ccpa"` and "Your Rights (GDPR / EU/EEA)" with `id="gdpr"`, each enumerating regime-specific rights. Or place `id="ccpa"` and `id="gdpr"` on separate paragraphs within one section.
 
 
-**Implementation Risk:** Throttling means the job takes longer to complete. For large backlogs, the job may not process all null trailers in a single run, which is acceptable -- it will catch up over multiple runs.
+**Implementation Risk:** Wording care: CCPA-specific rights (sale/share opt-out, non-discrimination) and GDPR-specific (lodge a complaint, restriction-of-processing) shouldn't be conflated.
 
 
 ---
 ---
 
 
-### 21. No Code Splitting or Bundle Analysis Strategy
-
-`PROJECT_SCOPE.md` (entire document) -- The app has several distinct page types (landing, home, list view, admin panel) with distinct dependencies (e.g., the admin panel needs `otplib`, the landing page needs the reel animation, the list view needs real-time subscriptions). Without explicit code splitting, the entire app's JavaScript will be loaded on every page, leading to slow initial page loads -- the user's primary concern.
+### 9. Privacy policy CCPA coverage is incomplete
 
 
-**Fix:** (a) Next.js App Router provides route-based code splitting by default -- ensure each major view is a separate route segment. (b) Use `next/dynamic` with `{ ssr: false }` for heavy client-only components (reel animation, roll animation, inline panel). (c) Lazy-load `otplib` only on the admin route. (d) Add `@next/bundle-analyzer` to the project and review bundle sizes during Phase 5 polish. (e) Set a performance budget: initial JS bundle under 200KB gzipped, First Contentful Paint under 1.5s on 4G.
+`src/app/(public)/privacy/page.tsx:140-175` — Section 6 names CCPA but lists GDPR rights only. Missing CCPA-specific items: (a) categories of personal info collected (Identifiers — UUID; Internet/Network activity — server logs); (b) affirmative "We do not sell or share personal information for cross-context behavioral advertising"; (c) right to non-discrimination; (d) right to limit use of sensitive personal info; (e) right to correct; (f) verification method for rights requests (since there's no email collected, document the in-app account-deletion as the verified channel).
 
 
-**Implementation Risk:** Lazy loading animations may cause a flash of empty content. Use suspense boundaries with skeleton loaders to handle the loading state.
+**Fix:** Add a CCPA-specific block with the above. Worth a legal review if the deployment serves California residents at scale.
 
 
 ---
 ---
 
 
-## INFRASTRUCTURE
+### 10. Legal footer cookie notice understates browser storage in use
 
 
-### 22. Docker Deployment Not Fully Specified
+`src/components/shared/legal-footer.tsx:19-21` — Footer says "essential cookies for authentication." Privacy policy §8 also discloses localStorage usage by Supabase libs and admin iron-session cookies. "Essential cookies" is technically true (localStorage isn't a cookie) but most plain-English / ePrivacy interpretations treat any client-side identifier storage as "cookies or similar tracking technologies."
 
 
-`PROJECT_SCOPE.md:262,322` -- The scope mentions Vercel deployment but the user's primary deployment target is Docker. The scope does not include: Dockerfile creation, Docker Compose configuration, health checks, container resource limits, log aggregation, or reverse proxy setup.
-
-**Fix:** Add Docker infrastructure tasks to Phase 1: (a) Create a multi-stage Dockerfile using `node:20-alpine` base, `output: 'standalone'` in next.config.js, non-root user, and `tini` for PID 1 signal handling. (b) Create `docker-compose.yml` with the Next.js service, environment variable mapping, health check (`/api/health`), restart policy, and resource limits (`mem_limit: 512m`). (c) Create a `.dockerignore` excluding `node_modules`, `.git`, `.env`, `research/`. (d) Document the Docker deployment procedure in the README. (e) Add a `/api/health` endpoint that checks Supabase connectivity.
-
-**Implementation Risk:** Standalone Next.js output has different behavior from the standard build (e.g., static assets must be copied separately). Test the Docker build thoroughly against all routes.
+**Fix:** Reword to "This site uses essential cookies and local storage for authentication. See our Privacy Policy."
 
 
 ---
 ---
 
 
-### 23. No Logging or Monitoring Strategy
+## LOW
 
 
-`PROJECT_SCOPE.md:427` -- Phase 10.3 briefly mentions "basic error monitoring (Vercel logs + Sentry free tier)" but this is the final phase. For a Docker-deployed app, logging and monitoring must be planned from Phase 1: (a) structured logging format, (b) log levels, (c) log aggregation for containerized deployments, (d) error tracking, (e) uptime monitoring.
+### 11. Double `aria-live` regions around the roll result
 
 
-**Fix:** (a) Use a structured logger (e.g., `pino`) from Phase 1 -- output JSON logs so they can be parsed by any log aggregation tool. (b) Define log levels: ERROR for failures, WARN for degraded states (TMDB rate limited, Supabase reconnecting), INFO for significant events (user created, group created, admin action), DEBUG for development. (c) For Docker: output logs to stdout/stderr (Docker captures these by default). (d) Add Sentry error tracking in Phase 1, not Phase 10. (e) Create a `/api/health` endpoint for uptime monitoring. (f) Log all Master Admin actions for audit trail.
+`src/components/dice/list-roll-carousel.tsx:202-211`, `src/components/movies/movie-list-client.tsx:161` — `RollAnnouncer` is the canonical `aria-live` source. The carousel's outer container also wraps the teaser in `aria-live="polite"`. Two announcement surfaces for the same logical event can double-read.
 
 
-**Implementation Risk:** Structured logging adds a dependency but is essential for debugging production issues. `pino` is lightweight and performant.
+**Fix:** Drop `aria-live` from the carousel teaser container. Verify via NVDA/VoiceOver that `RollAnnouncer` fires at settle.
 
 
 ---
 ---
 
 
-### 24. Environment Variable Validation Not Specified
-
-`PROJECT_SCOPE.md:324-330` -- Five environment variables are listed as required, but there is no mention of validating them at startup. If any are missing or malformed, the app will fail at runtime with cryptic errors rather than at startup with a clear message.
-
-**Fix:** Use a validation library (e.g., `zod` or `t3-env`) to validate all environment variables at application startup. Fail fast with a descriptive error message if any required variable is missing or invalid. Include format validation (e.g., TMDB_API_KEY must be a 32-character hex string, SUPABASE_URL must be a valid URL, MASTER_ADMIN_TOTP_SECRET must be valid base32).
+### 12. Legal-footer hash links compound finding #8
 
 
-**Implementation Risk:** None. This is a reliability improvement with no downside.
+`src/components/shared/legal-footer.tsx:9-17` — `<Link href="/privacy#ccpa">` and `#gdpr` are honored by `<Link>`, but with finding #8 active, navigation appears to do nothing. Resolving #8 fixes this.
 
 
 ---
 ---
 
 
-### 25. No Database Backup Strategy
+### 13. `SearchBar` placeholder "ADD A MOVIE" with `text-center` is unconventional
 
 
-`PROJECT_SCOPE.md:323` -- Supabase free tier is mentioned with an "upgrade path available if scale demands," but there is no mention of database backups. Supabase free tier includes daily backups with 7-day retention, but (a) this is not documented in the scope, (b) for Docker/self-hosted Supabase scenarios, backups must be manually configured, (c) there is no documented restore procedure.
+`src/components/movies/search-bar.tsx:49-52` — Center-aligned uppercase placeholder + pulse-glow reads more like a button than a text input. Keyboard users are OK (aria-label "Search movies").
 
 
-**Fix:** (a) Document Supabase's backup capabilities and retention for the hosted scenario. (b) For Docker deployment, add a backup cron job (e.g., `pg_dump` daily to a mounted volume or object storage). (c) Document the restore procedure. (d) Test the restore procedure at least once before launch.
-
-**Implementation Risk:** Backup storage costs are minimal but non-zero. For self-hosted Supabase, backup automation requires additional Docker service configuration.
+**Fix:** Keep pulse-glow; revert placeholder to left-aligned + a leading "+" or magnifier glyph to disambiguate input-vs-button. (NOTE: user explicitly requested centered bold "ADD A MOVIE" placeholder — escalate before changing.)
 
 
 ---
 ---
 
 
-## COMPLIANCE
-
-### 26. TMDB Terms of Use -- Image Hosting Violation Risk
+### 14. No CPRA/PIPEDA/LGPD coverage
 
 
-`PROJECT_SCOPE.md:293,302-308` -- The data model correctly stores `poster_path` as a relative path and constructs the full URL at render time from TMDB's CDN. However, the `landing_reel_posters` table also stores `poster_path`. If the periodic refresh job or any caching layer downloads and re-serves these images from the app's own domain (e.g., via Next.js image optimization proxy), this may violate TMDB's requirement that images be served from their CDN.
+Privacy policy and footer mention only CCPA + GDPR. For a low-data anonymous-auth service the omission is defensible, but worth flagging:
 
 
-**Fix:** (a) Ensure all TMDB images are served directly from `image.tmdb.org` in production. (b) If using Next.js `<Image>` with a custom loader, configure it to rewrite URLs to TMDB's CDN rather than proxying through the Next.js server. (c) Alternatively, add `image.tmdb.org` to `images.remotePatterns` in `next.config.js` and let Next.js optimize on-the-fly (this is a gray area -- TMDB's ToS should be checked for whether CDN-proxied optimization is permitted). (d) Document the decision and rationale.
-
-**Implementation Risk:** Direct CDN serving means no control over image availability if TMDB's CDN has an outage. This is an acceptable risk given the ToS requirement.
+- **CPRA** — California's CCPA update; functionally covered by CCPA copy if you reword §6 to mention "CCPA as amended by CPRA"
+- **PIPEDA** (Canada), **LGPD** (Brazil) — broadly aligned with GDPR principles, no separate text required, but a single line ("Residents of other jurisdictions with comparable data-protection regimes — e.g., PIPEDA, LGPD — may have equivalent rights; contact the administrator.") would close the loop.
 
 
 ---
 ---
 
 
-### 27. GDPR -- Local Storage User ID Without Consent Mechanism
-
-`PROJECT_SCOPE.md:107-108` -- The app stores a user ID in local storage (or cookie) to identify returning users. Under the EU ePrivacy Directive (which complements GDPR), storing non-essential identifiers on a user's device requires informed consent. The scope does not include any consent mechanism (cookie banner, storage consent prompt).
+## POSITIVES
 
 
-**Fix:** (a) Determine if the stored user ID qualifies as "strictly necessary" for the service (it likely does, since the app cannot function without identifying the user). If so, consent is not required but a privacy policy must explain the storage. (b) If the app stores any analytics identifiers, tracking pixels, or third-party cookies (e.g., Sentry, Vercel Analytics), those require explicit consent. (c) Add a privacy policy link in the footer. (d) If deploying for EU users, implement a minimal consent banner for any non-essential storage.
-
-**Implementation Risk:** Consent banners add friction to the onboarding flow, which conflicts with the "low tolerance for signup friction" user trait. Minimize non-essential storage to avoid needing a banner.
-
----
-
-### 28. GDPR -- No Account Deletion Flow for Regular Users
-
-`PROJECT_SCOPE.md:53,399` -- The Master Admin can delete any user, but there is no self-service account deletion flow for regular users. GDPR Article 17 (Right to Erasure) requires that users can request deletion of their personal data. The scope only mentions "Leave this list" for regular users, which removes group membership but does not delete the user account or their data across other groups.
-
-**Fix:** Add a "Delete My Account" option accessible from user settings. This must: (a) Remove the user from all groups (triggering ownership transfer or list deletion as applicable). (b) Delete the user record from the `users` table. (c) Anonymize or delete the user's `added_by` attributions in the `movies` table (set to null or a "[deleted user]" sentinel). (d) Clear local storage. (e) Confirm deletion is complete. (f) This should cascade properly -- define the cascade behavior in the database schema.
-
-**Implementation Risk:** Cascade deletion is complex when a user is the admin of multiple groups. Each group must be handled independently (transfer or delete). Test edge cases: user is admin of 3 groups, member of 2 others, has added movies to all of them.
-
----
-
-### 29. GDPR -- Data Retention Period Not Defined
-
-`PROJECT_SCOPE.md` (entire document) -- No data retention period is specified for any data type. GDPR requires that personal data not be kept longer than necessary. Questions that need answers: How long is a user account retained if unused? How long are deleted lists' data retained? Are server logs with IP addresses rotated?
-
-**Fix:** Define retention policies: (a) Inactive user accounts: auto-delete after 12 months of no activity (or prompt re-confirmation). (b) Deleted lists: hard delete immediately (no soft delete unless needed for recovery). (c) Server logs: rotate every 30 days. (d) Admin action logs: retain for 90 days. (e) Document these in the privacy policy.
-
-**Implementation Risk:** Auto-deletion of inactive accounts could surprise returning users. Implement a warning mechanism (though without email, this is challenging with anonymous auth). Consider extending the retention period and documenting it clearly.
-
----
-
-### 30. WCAG 2.1 AA -- Animation Accessibility (prefers-reduced-motion)
-
-`PROJECT_SCOPE.md:242-244` -- The scope defines two distinct animations (slot-machine reel, scatter/eliminate) as core features. WCAG 2.3.3 (AAA) and general best practice at AA level require respecting the `prefers-reduced-motion` media query. Users with vestibular disorders can be physically affected by spinning/scattering animations. The scope mentions no motion preference handling.
-
-**Fix:** (a) Implement `prefers-reduced-motion` detection. When active: replace the slot-machine reel with an instant reveal, replace the scatter/eliminate animation with a simple fade-in or card flip. (b) The result should still feel satisfying -- use opacity transitions and scale transforms rather than spatial movement. (c) Add a manual toggle in user settings for users who want reduced motion regardless of system setting. (d) Test both animation paths.
-
-**Implementation Risk:** Designing two animation variants doubles animation development work. Start with the full animation, then create the reduced version. The reduced version can be much simpler.
-
----
-
-### 31. WCAG 2.1 AA -- Inline Panel Expansion Accessibility
-
-`PROJECT_SCOPE.md:149-172` -- The inline panel expansion (tapping a poster to expand details below the row) has several accessibility concerns: (a) No mention of keyboard navigation (how does a keyboard user open/close the panel?). (b) No mention of focus management (focus should move to the panel on open, return to the trigger on close). (c) No mention of screen reader announcements (the panel expansion should be announced). (d) The "tap outside to collapse" interaction has no keyboard equivalent.
-
-**Fix:** (a) Make movie cards focusable and openable with Enter/Space. (b) On panel open, move focus to the first interactive element in the panel. (c) On panel close, return focus to the triggering card. (d) Add Escape key to close the panel. (e) Use `aria-expanded` on the trigger card and `role="region"` with `aria-label` on the panel. (f) Announce panel open/close with `aria-live="polite"`.
-
-**Implementation Risk:** Focus management with a virtualized grid (if implementing finding #18) adds complexity. Ensure the focus trap works correctly within the expanded panel.
-
----
-
-### 32. WCAG 2.1 AA -- Delete Confirmation Accessibility
-
-`PROJECT_SCOPE.md:163-166` -- The two-tap delete flow (shake animation + text change) relies entirely on visual feedback. Screen reader users will not perceive the shake animation or the visual text change unless it is announced. Additionally, the shake animation may be disorienting for users with vestibular disorders.
-
-**Fix:** (a) On first tap, change the button's `aria-label` to "Click to confirm delete" and announce it via `aria-live="assertive"`. (b) Respect `prefers-reduced-motion` -- replace shake with a color change or border highlight. (c) Ensure the confirmation state is visually distinct even without animation (e.g., red background, different text). (d) Consider using a standard confirmation dialog (`window.confirm()` or a custom accessible modal) as an alternative to the shake pattern for accessibility.
-
-**Implementation Risk:** A confirmation dialog is more universally accessible but less visually distinctive than the shake pattern. Consider offering both: shake for sighted users, dialog for screen reader users (detect via accessibility API or offer a setting).
-
----
-
-### 33. PWA -- Offline Strategy Needs Definition
-
-`PROJECT_SCOPE.md:248,403` -- The scope mentions "offline tolerance: show cached list, disable write actions" and Phase 8.2 mentions "offline graceful degradation." However, the caching strategy is not defined: (a) Which assets are precached by the service worker? (b) How are movie lists cached for offline viewing? (c) How is the transition between online and offline states communicated to the user? (d) What happens to in-flight writes when the connection drops?
-
-**Fix:** Define the offline strategy: (a) Precache: app shell, critical CSS/JS, fonts. (b) Runtime cache: movie list data (via TanStack Query's persistence plugin or service worker). (c) On connection loss: show a non-intrusive banner ("You're offline -- viewing cached data"), disable all write buttons (add, delete, mark watched, roll), gray out disabled controls. (d) On reconnection: auto-reconnect Supabase subscription, refresh stale data, remove the banner. (e) Do NOT queue offline writes (too complex for MVP; risk of conflicts).
-
-**Implementation Risk:** TanStack Query's `persistQueryClient` plugin can handle offline caching but adds complexity. For MVP, a simpler approach is acceptable: cache the most recent list view and show it read-only when offline.
-
----
-
-## Positives
-
-- Recovery codes hashed before storage -- good security practice for account recovery
-- Invite code regeneration feature allows revoking compromised codes
-- TOTP 2FA for Master Admin rather than password-only auth
-- TMDB poster_path stored as relative path, not full URL -- allows flexible CDN URL construction
-- Debounced search (300ms) prevents excessive API calls
-- Real-time subscriptions for collaborative UX rather than polling
-- Display names only, no email/password -- good data minimization for MVP
-- Trailer URL stored at add-time reduces real-time API dependency
-- Admin self-removal triggers ownership transfer rather than orphaning the list
-- Two-tap delete confirmation prevents accidental deletions
-- Phase-based implementation plan with clear MVP cutoff
+- `<button>` carousel conversion is clean — no nested interactive descendants. The inner element became `<span aria-hidden>`, and `<ListMoreInfoModal>` is portaled to `document.body` (`list-more-info-modal.tsx:267-269`), so no nested-`<button>` HTML invalidity.
+- `/api/tmdb/search` cache stores the **filtered** payload (`NextResponse.json(filtered, ...)`). No cache poisoning of disallowed titles into shared edge cache.
+- `/api/tmdb/movie/[id]` correctly returns identical 404 for "doesn't exist" and "cert-blocked" — no existence leak. Intent documented in code.
+- `prefers-reduced-motion` honored consistently: `globals.css` opts out both `animate-emerge` and `animate-pulse-glow`; `ListRollCarousel` short-circuits to settled; `useRoll` reads the same media query.
+- No new `Sentry.setUser()` calls introduced. No new PII logging — `console.error` in TMDB routes logs error objects only.
+- `MovieListClient`'s `RollBar` gating on `allMovies.length > 0` is correct.
+- `RollSection` correctly gated on `hasGroups && hasMovies` — no orphan roll UI on a fresh account.
+- `JoinListButton` `size` prop is additive, no regression for default callers.
+- `SettingsPanel` shake-to-arm consistent with `ListMoreInfoModal` and `DeleteButton`. No `window.confirm` regressions.
+- TanStack query keys unchanged across the home-page refactor (`["tmdb-search", q]`, `useUserGroups`, `useAllUserMovies`) — cache behavior preserved.
+- Recovery codes, anonymous UUIDs, no-email-collection claims in `/privacy` match implemented auth flow.
+- Footer mounting is single per route group (no double-mount on `(public)` pages).
 
 
 ---
 ---
 
 
 ## Summary
 ## Summary
 
 
-| Severity           | Total |
-| ------------------ | ----- |
-| Critical           | 6     |
-| Code Quality       | 4     |
-| Documentation Gaps | 4     |
-| Performance        | 7     |
-| Infrastructure     | 4     |
-| Compliance         | 8     |
-
-**Total findings: 33**
-
-This is a pre-implementation review. All findings are recommendations for the architecture and implementation plan. No source code was reviewed because none exists yet.
-
-**Top 5 actions to take before writing any code:**
-
-1. Add TMDB API server-side proxy requirement to Phase 1 (Finding #1)
-2. Establish linting, TypeScript strict mode, and testing infrastructure (Findings #7, #8, #9)
-3. Define Supabase RLS policies alongside the schema (Finding #14)
-4. Add TMDB attribution to every page (Finding #2)
-5. Create Docker deployment infrastructure in Phase 1 (Finding #22)
-
----
-
-## Files Reviewed
-
-_Every file reviewed, listed for coverage verification._
-
-| #   | File               | Type                                  |
-| --- | ------------------ | ------------------------------------- |
-| 1   | `PROJECT_SCOPE.md` | Project scope / architecture document |
-
-**Note:** This project contains only the scope document. No source code, configuration files, or infrastructure files exist yet. This review covers the architectural design and implementation plan specified in PROJECT_SCOPE.md.
-
----
-
-## Second Review -- Updated Scope Analysis
-
-Reviewed: 2026-04-05 | Reviewer: Claude (automated)
-Scope: Second-pass review of updated PROJECT_SCOPE.md | Standard: TMDB ToS, GDPR, WCAG 2.1 AA, PWA, Docker CIS, TLS
-
-This section contains only NEW findings. The following first-review items are now addressed in the updated scope and are confirmed resolved: #1 (TMDB server proxy), #2 (TMDB attribution), #3 (privacy policy), #4 (recovery code security), #7 (linting/formatting), #8 (testing), #9 (CI via husky), #11 (API docs in markdown), #13 (migration strategy), #14 (RLS), #22 (Docker deployment), #23 (Sentry from Phase 1), #24 (env validation), #27 (privacy policy link), #28 (self-service deletion in Extra Features), #29 (12-month retention), #30 (prefers-reduced-motion), #31 (inline panel keyboard/aria).
-
----
-
-### CRITICAL
-
-### 34. TMDB Metadata Staleness -- No Refresh Mechanism for Stored Movie Data
-
-`PROJECT_SCOPE.md:326-338` -- The `movies` table stores TMDB metadata (title, year, poster_path, genres) at add-time and never refreshes it. The trailer URL refresh job only targets `trailer_url IS NULL` entries. TMDB's Terms of Service require that cached data be kept reasonably current. If TMDB updates a movie's poster, corrects a title, or reclassifies genres, the app serves stale data indefinitely. Over the 12-month retention window, this drift could be significant. Additionally, poster_path values can become invalid if TMDB re-processes images, resulting in broken poster images.
-
-**Fix:** Add a metadata freshness job (monthly cadence via pg_cron) that re-fetches title, poster_path, genres, and year from TMDB for movies added more than 30 days ago. Throttle at 3 requests/second with 429 backoff. Process in batches of 50 per run. Add a `metadata_refreshed_at` column to the `movies` table. This is the same pattern as the trailer refresh job but for core metadata.
-
-**Implementation Risk:** Additional TMDB API usage. At 3 req/s and 50 movies/run, a batch takes ~17 seconds. For large datasets, spread across multiple runs. Risk of overwriting user-visible data if TMDB makes incorrect changes -- consider logging changes for review.
-
----
-
-### 35. TMDB Adult Content Not Filtered
-
-`PROJECT_SCOPE.md:299-300` -- The TMDB API proxy routes search queries to TMDB but the scope does not specify filtering adult content from results. TMDB's API includes an `include_adult` parameter (default false for search, but true for discover/popular). The landing page "popular/top-rated" fetch for reel posters could surface adult content if the parameter is not explicitly set. Similarly, the search endpoint should explicitly exclude adult results to prevent inappropriate content appearing in a social app used by families and friend groups.
-
-**Fix:** Explicitly set `include_adult=false` on all TMDB API calls in the server proxy. Add this as a documented requirement in the TMDB proxy implementation (Phase 1.3 / Phase 3.1). Also filter results server-side by checking the `adult` field on each result object, since some adult-flagged movies can still appear in non-adult searches depending on TMDB's classification.
-
-**Implementation Risk:** Minimal. This is a query parameter addition. Double-filtering (parameter + server-side check) provides defense in depth.
-
----
-
-### 36. Supabase Studio Publicly Accessible in Docker Stack
-
-`PROJECT_SCOPE.md:391` -- The self-hosted Supabase Docker stack includes Supabase Studio (the admin dashboard UI), which by default binds to a port accessible from outside the container. Studio provides full database access using the service role key. If Studio is exposed through Caddy or directly on a public port, anyone who discovers it has unrestricted read/write access to every table, bypassing all RLS policies.
-
-**Fix:** (a) Do NOT expose the Supabase Studio port in docker-compose (remove or comment out the port mapping). (b) If Studio access is needed, bind it to `127.0.0.1` only and access via SSH tunnel. (c) Alternatively, add Caddy basic auth or IP restriction in front of the Studio route. (d) Document this as a security-critical operational requirement.
-
-**Implementation Risk:** Developers lose convenient Studio access during development. SSH tunnel is the standard solution for self-hosted deployments. During local development, Studio can be accessed directly.
-
----
-
-### COMPLIANCE
-
-### 37. GDPR -- Supabase `auth.users` Table Not Addressed in Deletion Flow
-
-`PROJECT_SCOPE.md:569` -- The self-service account deletion (Extra Features) and Master Admin deletion mention cascading deletes on the app's `public.users` table. However, Supabase's GoTrue auth system maintains its own `auth.users` table containing: user UUID, created_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data, and the `is_anonymous` flag. Deleting a row from `public.users` does NOT delete the corresponding `auth.users` record. Under GDPR Article 17, erasure must be complete. An orphaned `auth.users` record still constitutes retained personal data.
-
-**Fix:** Account deletion must call `supabase.auth.admin.deleteUser(userId)` using the service role key to remove the `auth.users` record. This should be performed server-side (API route or Server Action) after the application-level cascade completes. Document this as a required step in both the self-service deletion flow and the Master Admin deletion flow.
-
-**Implementation Risk:** The `admin.deleteUser` call requires the service role key and must be server-side only. If the GoTrue delete fails after the public table cascade succeeds, the user is partially deleted -- implement as a transaction or handle the error with a retry/alert mechanism.
-
----
-
-### 38. GDPR -- Supabase Component Logs Contain Personal Data with No Rotation
-
-`PROJECT_SCOPE.md:389-394` -- The self-hosted Docker stack includes Kong (API gateway), GoTrue (auth), PostgREST, and Realtime -- each producing logs containing IP addresses, user agent strings, JWTs, and request paths (which may include user IDs). Docker's default logging driver (`json-file`) has no size limit or rotation. These logs (a) contain personal data under GDPR, (b) grow unbounded consuming disk space, and (c) have no defined retention period. The scope's 12-month data retention policy and privacy policy mention server logs with IPs but do not address Supabase's own container logs.
-
-**Fix:** (a) Configure Docker log rotation for ALL containers in docker-compose: `logging: { driver: "json-file", options: { max-size: "10m", max-file: "5" } }`. (b) Document that Supabase container logs contain personal data. (c) Include Supabase logs in the privacy policy's log retention disclosure (recommend 30-day effective retention via rotation). (d) If log aggregation is added later, ensure the aggregation target also has a retention policy.
-
-**Implementation Risk:** Log rotation means older logs are lost. For debugging production issues, 50MB per container (5 x 10MB) is sufficient for recent history. If forensic logging is needed, consider shipping logs to a dedicated store with its own retention policy.
-
----
-
-### 39. GDPR -- Sentry Error Reports May Contain User Context
-
-`PROJECT_SCOPE.md:422` -- Sentry is added in Phase 1.8 for error monitoring. By default, Sentry's JavaScript SDK captures: error stack traces, breadcrumbs (user interactions, console logs, network requests), request URLs (which may contain user/group IDs), and browser metadata. If Sentry's `setUser()` is called or if user context appears in error messages, Sentry receives personal data. This constitutes an international data transfer (to Sentry's US servers) requiring disclosure in the privacy policy and potentially a Standard Contractual Clauses (SCC) basis under GDPR.
-
-**Fix:** (a) Configure Sentry's `beforeSend` callback to strip any user-identifying information from error events. (b) Do NOT call `Sentry.setUser()` with the anonymous UUID. (c) Sanitize URLs in breadcrumbs to remove UUID path segments. (d) Disclose Sentry as a third-party data processor in the privacy policy. (e) If using Sentry's cloud service, reference Sentry's DPA and SCC compliance in the privacy policy. (f) Alternatively, self-host Sentry to keep all error data on-premises.
-
-**Implementation Risk:** Aggressive sanitization may make debugging harder (errors without user context are harder to reproduce). A middle ground: use a hashed/truncated user identifier that cannot be reversed to the original UUID.
-
----
-
-### 40. GDPR -- Privacy Policy Missing Required Sections
-
-`PROJECT_SCOPE.md:373` -- The scope describes the privacy policy as "factual description of what data is stored, how it is used, and user rights." This is a good start but GDPR (Articles 13-14) and CCPA require specific sections that are not mentioned: (a) identity and contact details of the data controller, (b) lawful basis for each processing activity (Art. 6(1) -- likely legitimate interest or contract performance), (c) international data transfers (TMDB API calls to TMDB servers, Sentry to Sentry servers), (d) automated decision-making disclosure (the emotion-to-genre mapping is algorithmic but likely does not qualify as "solely automated" under Art. 22), (e) right to lodge a complaint with a supervisory authority, (f) CCPA categories of personal information and "do not sell" disclosure, (g) children's data disclaimer (COPPA: under-13; GDPR: under-16 in some member states).
-
-**Fix:** Create a privacy policy template with all required sections before implementation. At minimum: controller identity, data inventory with lawful basis per item, retention periods per data type, third-party recipients (TMDB, Sentry), international transfers, full list of user rights with exercise instructions, children's disclaimer, cookie/localStorage disclosure, and change notification procedure. Phase 5.1 should reference this template.
-
-**Implementation Risk:** A comprehensive privacy policy for an anonymous-auth app is unusual and may feel heavyweight. Keep language plain and concise. Consider using a privacy policy generator as a starting point, then customizing.
-
----
-
-### 41. WCAG 2.1 AA -- Slot Machine Reel Violates 2.2.2 Pause, Stop, Hide
-
-`PROJECT_SCOPE.md:74-79` -- WCAG 2.2.2 requires that any moving, blinking, or scrolling content that (a) starts automatically, (b) lasts more than 5 seconds, and (c) is presented alongside other content must have a mechanism to pause, stop, or hide it. The slot-machine reel animation is user-triggered (button press), so it does NOT auto-start -- however, if the reel spin duration exceeds 5 seconds or if the reels loop/auto-play on page load as a decorative element, this criterion applies. The scope specifies the in-app roll at "2-3 seconds" but does not specify the landing page reel duration. Additionally, `prefers-reduced-motion` replaces the animation entirely but does not provide a pause/stop control for users who want motion but need control.
-
-**Fix:** (a) Ensure the landing page reel animation is strictly user-triggered (no auto-play or looping on page load). (b) Cap the reel animation duration at under 5 seconds. (c) If any decorative animation loops (e.g., a subtle poster scroll preview before the user taps Roll), add a pause button. (d) Document the animation durations as testable success criteria.
-
-**Implementation Risk:** Minimal if animations are user-triggered and time-bounded. The main risk is a future design decision to add auto-playing reel teasers on the landing page -- if added, a pause control would be required.
-
----
-
-### 42. WCAG 2.1 AA -- Poster Images Missing Alt Text Specification
-
-`PROJECT_SCOPE.md:146-152, 160-161` -- The scope specifies poster `<img>` tags with `loading="lazy"` but does not specify `alt` text for any poster image. WCAG 1.1.1 (Non-text Content) requires meaningful alt text for all informative images. Posters in the grid, the expanded panel, the reel animation, and the roll result all need alt text. During the reel spin animation, rapidly cycling posters should be `aria-hidden="true"` (they are decorative in that context), but the final result poster must have alt text.
-
-**Fix:** (a) All poster `<img>` tags: `alt="[Movie Title] ([Year]) poster"`. (b) Reel animation posters during spin: `aria-hidden="true"` on the container or individual images. (c) Roll result poster: meaningful alt text. (d) Added-by avatar overlay and binoculars emoji overlay: `aria-hidden="true"` with meaning conveyed via `aria-label` on the parent card element or screen-reader-only text (e.g., `<span class="sr-only">Watched</span>`). (e) Add this to the component requirements in Phase 3.4 and 3.6.
-
-**Implementation Risk:** None. This is a straightforward implementation requirement.
-
----
-
-### 43. WCAG 2.1 AA -- Status Messages Not Announced (WCAG 4.1.3)
-
-`PROJECT_SCOPE.md:194, 459` -- Several user actions produce status messages that sighted users perceive visually but screen reader users would miss: (a) roll result ("You got: [Movie Title]"), (b) search results count, (c) genre filter applied ("Showing [N] movies in [Genre]"), (d) "No matches -- showing full list" fallback, (e) movie added/removed confirmation, (f) watched state toggle confirmation. WCAG 4.1.3 (Status Messages, AA) requires that status messages be programmatically determinable via `role="status"` or `aria-live` without receiving focus.
-
-**Fix:** Wrap dynamic status messages in an `aria-live="polite"` region (or use `role="status"`) for: roll results, search result counts, filter state changes, empty state messages, and action confirmations. Use `aria-live="assertive"` only for error messages and the delete confirmation state change. Do NOT move focus to these messages -- let the live region announce them.
-
-**Implementation Risk:** Multiple `aria-live` regions competing can cause announcement queuing issues. Use a single shared announcer component (common pattern: a visually hidden div that receives message text via state) rather than multiple live regions scattered across components.
-
----
-
-### INFRASTRUCTURE
-
-### 44. Docker Compose Missing Security Hardening (CIS Benchmark)
-
-`PROJECT_SCOPE.md:389-394` -- The scope specifies a docker-compose configuration with non-root user, tini, and health check, which is good. However, several CIS Docker Benchmark controls are not addressed: (a) no capability dropping (`cap_drop: [ALL]`), (b) no `no-new-privileges` security option, (c) no memory limits specified, (d) no CPU limits, (e) no read-only root filesystem. These are standard hardening measures for production Docker deployments.
-
-**Fix:** Add to the docker-compose service definition for the Next.js container:
-
-```yaml
-security_opt:
-  - no-new-privileges:true
-cap_drop:
-  - ALL
-mem_limit: 512m
-memswap_limit: 512m
-cpus: 1.0
-read_only: true
-tmpfs:
-  - /tmp
-  - /app/.next/cache
-```
-
-Apply similar hardening to Supabase containers where applicable (some Supabase containers may need specific capabilities).
-
-**Implementation Risk:** `read_only: true` requires identifying all writable paths and mounting them as `tmpfs` or named volumes. The Next.js standalone output writes to `.next/cache` at runtime. Test thoroughly to ensure no write operations fail. Supabase containers (especially Postgres) need writable data directories.
-
----
-
-### 45. Caddy HSTS Header Must Be Configured at Proxy Level
-
-`PROJECT_SCOPE.md:379-384, 392` -- The scope mentions HSTS in the security headers section and suggests configuring it in `next.config.js` or Caddy. For HSTS to be effective, it MUST be set at the TLS termination point, which is Caddy. If HSTS is only set in Next.js, it is applied after TLS has already been established, which is correct timing but means Caddy could theoretically serve a non-HSTS response if the request bypasses Next.js. More importantly, Caddy does NOT add HSTS by default. The scope should explicitly mandate HSTS configuration in the Caddyfile rather than leaving it ambiguous ("or").
-
-**Fix:** Configure HSTS in the Caddyfile explicitly:
-
-```
-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
-```
-
-Other security headers (CSP, X-Frame-Options, etc.) can be set in either Caddy or Next.js, but HSTS should be at the Caddy level. Remove the ambiguity -- specify which headers go where.
-
-**Implementation Risk:** HSTS with `preload` and a long max-age is difficult to undo once deployed. Start with a shorter max-age (e.g., `max-age=86400`) during testing, then increase to production values. Do NOT submit to the HSTS preload list until confident the domain will always serve HTTPS.
-
----
-
-### 46. Self-Hosted Supabase PostgreSQL -- No Encryption at Rest
-
-`PROJECT_SCOPE.md:391` -- The self-hosted Supabase stack runs PostgreSQL in a Docker container with data stored on a Docker volume. Docker volumes are NOT encrypted by default. The database contains user data (UUIDs, display names, group membership, movie preferences) that constitutes personal data under GDPR. While GDPR does not strictly mandate encryption at rest, Article 32 requires "appropriate technical measures" to protect personal data, and encryption at rest is listed as an example measure. An unencrypted volume on a compromised host exposes all user data.
-
-**Fix:** (a) Use full-disk encryption on the Docker host (LUKS on Linux, FileVault on macOS, BitLocker on Windows). This is the simplest approach and protects all Docker volumes. (b) Document this as a deployment requirement. (c) If the host is a cloud VM, enable the cloud provider's disk encryption feature. (d) For defense in depth, consider column-level encryption via pgcrypto for the most sensitive fields (recovery_code is already hashed, but display_name and group membership are plaintext).
-
-**Implementation Risk:** Full-disk encryption has negligible performance impact on modern hardware with AES-NI. Column-level encryption adds application complexity and prevents SQL queries on encrypted columns. Recommend host-level encryption as sufficient for this app's threat model.
-
----
-
-### 47. No Database Backup Strategy in Updated Scope
-
-`PROJECT_SCOPE.md:389-404` -- Finding #25 from the first review flagged the missing backup strategy. The updated scope now specifies self-hosted Supabase but still does not include a backup mechanism. For self-hosted PostgreSQL, there are NO automatic backups. A Docker volume failure, accidental `DROP TABLE`, or host disk failure results in complete data loss. This is especially critical because anonymous auth means users cannot be contacted to rebuild their accounts.
-
-**Fix:** Add a backup task to Phase 1.7 (Docker infrastructure): (a) Create a `pg_dump` cron job (daily, retain 7 days) writing to a separate volume or off-host storage. (b) Add a `backup` service in docker-compose that runs the dump on a schedule. (c) Document the restore procedure. (d) Test restore at least once before launch. (e) Consider WAL archiving for point-in-time recovery if data volume warrants it. Example:
-
-```yaml
-backup:
-  image: postgres:16
-  command: >
-    sh -c 'while true; do
-      pg_dump -h db -U postgres > /backups/moviedice_$$(date +%Y%m%d).sql
-      find /backups -mtime +7 -delete
-      sleep 86400
-    done'
-  volumes:
-    - ./backups:/backups
-```
-
-**Implementation Risk:** Backup storage consumes disk space. For a small app, daily dumps are small (< 1 MB). The backup container needs access to the Postgres container's network and credentials. Ensure backup files are not publicly accessible.
-
----
-
-### PERFORMANCE
-
-### 48. TMDB Server Proxy Missing 429 Rate Limit Handling
-
-`PROJECT_SCOPE.md:299-300` -- The TMDB server proxy (`/api/tmdb/*`) routes all client requests through the Next.js server to TMDB. The scope specifies debounce (300ms) and TanStack Query caching, which reduces request volume. However, there is no specification for handling TMDB's HTTP 429 (Too Many Requests) responses. If multiple users search simultaneously or the trailer/reel refresh jobs run during peak usage, the proxy could hit the ~40 req/10s limit. Without 429 handling, the proxy would pass through TMDB error responses to the client, causing confusing failures.
-
-**Fix:** (a) Implement server-side rate limiting on the TMDB proxy using a token bucket or sliding window (e.g., 30 req/10s to stay under TMDB's 40 req/10s with headroom for background jobs). (b) On receiving a 429 from TMDB, read the `Retry-After` header and return a friendly error to the client with a retry suggestion. (c) Queue or delay background job requests to avoid competing with interactive user requests. (d) Add `Cache-Control` response headers on the proxy to enable HTTP-level caching of TMDB responses (e.g., `public, max-age=300` for search results).
-
-**Implementation Risk:** Server-side rate limiting requires in-memory state (or Redis). For a single-instance Docker deployment, in-memory is fine. The rate limiter should prioritize interactive requests over background jobs.
-
----
-
-### DOCUMENTATION GAPS
-
-### 49. Invite Code Entropy Not Quantified
-
-`PROJECT_SCOPE.md:35, 315` -- The first review (Finding #5) flagged the low entropy of invite codes like WOLF-42. The updated scope changes the format to WORD-WORD (e.g., WOLF-MOON), but does not specify the word list size. If the word list has 1,000 words, WORD-WORD yields 1,000,000 combinations -- brute-forceable in under a day even with rate limiting at 5 attempts/window. If the word list has 4,000 words, it yields 16,000,000 combinations, which is more robust but still feasible for a determined attacker. The rate limiting on the join endpoint ("5-10 failed attempts per IP per window") does not specify the window duration.
-
-**Fix:** (a) Specify the word list size (recommend 4,000+ words for ~22 bits of entropy, or add a third word for ~36 bits). (b) Specify the rate limit window duration (recommend 15 minutes). (c) Document the expected brute-force time given the word list size and rate limit parameters. (d) Consider adding a numeric suffix (WOLF-MOON-73) for additional entropy without significant UX cost.
-
-**Implementation Risk:** Larger word lists or three-word codes reduce memorability. WOLF-MOON-73 is still human-readable and provides ~29 bits with a 4,000-word list + 2-digit suffix. Rate limiting is the primary defense; code entropy is defense in depth.
-
----
-
-### 50. PWA Manifest and Icon Requirements Not Specified
-
-`PROJECT_SCOPE.md:275, 481` -- The scope specifies `@serwist/next` for PWA support and mentions "home screen installation" but does not specify: (a) the `display` mode for the manifest (should be `standalone`), (b) required icon sizes (192x192 and 512x512 minimum, plus maskable variants for Android adaptive icons), (c) iOS-specific meta tags (`apple-mobile-web-app-capable`, `apple-touch-icon`, `apple-touch-startup-image` for splash screens), (d) manifest fields (`name`, `short_name`, `start_url`, `theme_color`, `background_color`). Without these specifications, Phase 8.1 implementation may miss requirements for cross-platform installability.
-
-**Fix:** Add a PWA configuration specification to the scope or Phase 8.1: (a) `display: "standalone"`, (b) Icons: 192x192, 512x512 (PNG), plus maskable variants with safe zone, (c) `name: "MovieDice"`, `short_name: "MovieDice"`, (d) `start_url: "/"`, (e) `theme_color` and `background_color` matching the app's color scheme, (f) iOS meta tags for Safari PWA support, (g) Service worker must NOT cache `/api/*` routes or WebSocket connections.
-
-**Implementation Risk:** Maskable icon design requires a safe zone (inner 80% circle). The app icon must look good both as a full-bleed and as a masked circle. iOS splash screen images require multiple resolutions -- `@serwist/next` may or may not auto-generate these.
-
----
-
-### 51. Error Response Format Not Standardized
-
-`PROJECT_SCOPE.md:459` -- Finding #12 from the first review flagged the missing error response standard. The updated scope adds error handling in Phase 5.6 ("invalid invite code, TMDB API failure, network errors") but still does not define a standard error response shape, error codes, or error categorization. Without a standard, each API route will return errors in ad-hoc formats, making client-side error handling inconsistent and brittle.
-
-**Fix:** Define before Phase 2 implementation begins: (a) Standard error response: `{ error: { code: string, message: string, details?: unknown } }`. (b) Error code enum: `INVALID_INVITE_CODE`, `TMDB_UNAVAILABLE`, `TMDB_RATE_LIMITED`, `RATE_LIMITED`, `UNAUTHORIZED`, `NOT_FOUND`, `VALIDATION_ERROR`, `INTERNAL_ERROR`. (c) Map each code to a user-friendly message string. (d) Create a shared `createErrorResponse(code, details?)` utility used by all API routes. (e) Document error codes in the API markdown documentation.
-
-**Implementation Risk:** None. Defining this early prevents inconsistency. Retrofitting error formats after multiple API routes exist is much harder.
-
----
-
-## Second Review Summary
-
-| Severity           | New Findings           |
-| ------------------ | ---------------------- |
-| Critical           | 3 (#34, #35, #36)      |
-| Compliance         | 4 (#37, #38, #39, #40) |
-| Infrastructure     | 4 (#44, #45, #46, #47) |
-| Performance        | 1 (#48)                |
-| Documentation Gaps | 3 (#49, #50, #51)      |
-
-**New findings total: 15** (numbered 34-51, continuing from first review)
-
-### Positives -- Improvements Since First Review
-
-- Server-side TMDB proxy now explicitly specified with `NEXT_PUBLIC_` prohibition -- eliminates API key exposure
-- TMDB attribution footer on all pages with logo + link + disclaimer -- ToS compliant
-- Privacy policy page added as Phase 5.1 deliverable
-- 12-month data retention policy with auto-delete -- addresses GDPR storage limitation
-- Recovery code hardened: 128-bit entropy, Argon2id, rate limiting, single-use -- solid specification
-- Invite code format improved to WORD-WORD with rate limiting on join
-- RLS policies defined per-table with specific access rules
-- prefers-reduced-motion respected for both animation types
-- Inline panel keyboard navigation with aria-expanded and role="region"
-- ESLint + Prettier + TypeScript strict + husky enforced from Phase 1
-- Vitest + Playwright testing established
-- Sentry monitoring moved to Phase 1 (was Phase 10)
-- Supabase migrations workflow via CLI
-- Security headers (CSP, HSTS, X-Frame-Options) specified
-- Self-service account deletion added to Extra Features backlog
-- Env validation via zod at startup
-- Docker infrastructure fully specified: multi-stage build, non-root user, tini, health check
-- iron-session with HttpOnly/Secure/SameSite=Strict and 8-hour expiry for admin
-- Caddy reverse proxy for HTTPS termination
-- TMDB native image sizes avoiding in-container sharp processing -- good performance decision
-- Real-time subscriptions scoped to active list only with useEffect cleanup
-
-### Top 5 Actions for Updated Scope
-
-1. Secure Supabase Studio -- do not expose publicly in docker-compose (Finding #36)
-2. Configure Docker log rotation and container security hardening (Findings #38, #44)
-3. Add `auth.users` cleanup to account deletion flows (Finding #37)
-4. Specify TMDB adult content filtering and 429 handling in proxy (Findings #35, #48)
-5. Add database backup mechanism to Phase 1.7 (Finding #47)
+| Severity | Total |
+| -------- | ----- |
+| Critical | 2     |
+| High     | 2     |
+| Medium   | 6     |
+| Low      | 4     |

+ 78 - 0
src/__tests__/guards/kong-auth-denylist.test.ts

@@ -0,0 +1,78 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { parse } from "yaml";
+
+/**
+ * Guard: GoTrue auth surfaces we don't use (magiclink, recover, otp, resend,
+ * sso, sso/saml) must be denied at Kong via `request-termination`, and the
+ * denied routes must appear BEFORE the catch-all `/auth/v1/` route in
+ * `supabase/kong/kong.yml`. Kong matches services in declaration order; if
+ * the catch-all comes first, the deny routes are dead code.
+ */
+
+const KONG_YML = path.resolve(__dirname, "..", "..", "..", "supabase", "kong", "kong.yml");
+const REQUIRED_DENIED_PATHS = [
+  "/auth/v1/magiclink",
+  "/auth/v1/recover",
+  "/auth/v1/otp",
+  "/auth/v1/resend",
+  "/auth/v1/sso",
+  "/auth/v1/sso/saml",
+];
+const CATCHALL_PATH = "/auth/v1/";
+
+interface KongPlugin {
+  name: string;
+  config?: Record<string, unknown>;
+}
+interface KongRoute {
+  name: string;
+  paths?: string[];
+}
+interface KongService {
+  name: string;
+  routes?: KongRoute[];
+  plugins?: KongPlugin[];
+}
+interface KongConfig {
+  services?: KongService[];
+}
+
+describe("CI guard: Kong denies disabled GoTrue auth surfaces", () => {
+  const raw = readFileSync(KONG_YML, "utf8");
+  const config = parse(raw) as KongConfig;
+  const services = config.services ?? [];
+
+  // Build a list of (path, service-index, has-request-termination) tuples,
+  // ordered by service declaration order.
+  const pathEntries: { path: string; serviceIndex: number; terminated: boolean }[] = [];
+  services.forEach((svc, i) => {
+    const terminated = (svc.plugins ?? []).some((p) => p.name === "request-termination");
+    for (const route of svc.routes ?? []) {
+      for (const p of route.paths ?? []) {
+        pathEntries.push({ path: p, serviceIndex: i, terminated });
+      }
+    }
+  });
+
+  it("declares a request-termination route for each disabled surface", () => {
+    for (const denied of REQUIRED_DENIED_PATHS) {
+      const match = pathEntries.find((e) => e.path === denied);
+      expect(match, `missing Kong route for ${denied}`).toBeTruthy();
+      expect(match!.terminated, `route for ${denied} is not request-terminated`).toBe(true);
+    }
+  });
+
+  it("orders every denied route before the catch-all /auth/v1/ route", () => {
+    const catchall = pathEntries.find((e) => e.path === CATCHALL_PATH);
+    expect(catchall, "catch-all /auth/v1/ route not found").toBeTruthy();
+    for (const denied of REQUIRED_DENIED_PATHS) {
+      const match = pathEntries.find((e) => e.path === denied);
+      expect(
+        match!.serviceIndex,
+        `${denied} must be declared before /auth/v1/ (Kong matches in order)`,
+      ).toBeLessThan(catchall!.serviceIndex);
+    }
+  });
+});

+ 41 - 3
src/app/(app)/home/page.tsx

@@ -1,9 +1,19 @@
 "use client";
 "use client";
 
 
+import Link from "next/link";
 import { ListGrid } from "@/components/home/list-grid";
 import { ListGrid } from "@/components/home/list-grid";
 import { RollSection } from "@/components/home/roll-section";
 import { RollSection } from "@/components/home/roll-section";
+import { JoinListButton } from "@/components/home/join-list-button";
+import { useUserGroups } from "@/hooks/use-user-groups";
+import { useAllUserMovies } from "@/hooks/use-all-user-movies";
 
 
 export default function HomePage() {
 export default function HomePage() {
+  const { data: groups } = useUserGroups();
+  const { data: movies } = useAllUserMovies();
+
+  const hasGroups = (groups?.length ?? 0) > 0;
+  const hasMovies = (movies?.length ?? 0) > 0;
+
   return (
   return (
     <div className="mx-auto max-w-3xl px-4 py-8">
     <div className="mx-auto max-w-3xl px-4 py-8">
       <section className="mb-8">
       <section className="mb-8">
@@ -11,12 +21,40 @@ export default function HomePage() {
         <p className="mt-1 text-sm text-foreground/60 text-center sm:text-left">
         <p className="mt-1 text-sm text-foreground/60 text-center sm:text-left">
           Pick a list or roll across all of them.
           Pick a list or roll across all of them.
         </p>
         </p>
-        <div className="mt-4">
-          <RollSection />
-        </div>
+        {hasGroups && (
+          <div className="mt-4 flex flex-col gap-4">
+            {/* Create/Join row always renders when the user has at least one
+                list — independent of whether any of those lists hold movies.
+                Roll buttons (inside <RollSection />) require a non-empty
+                cross-list pool. */}
+            <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
+              <Link
+                href="/create-group"
+                className="inline-flex min-h-[44px] w-full sm:flex-1 items-center justify-center rounded-lg bg-blue-700 text-white px-4 py-2 text-sm font-semibold hover:bg-blue-600 transition-colors text-center"
+              >
+                + Create List
+              </Link>
+              <JoinListButton />
+            </div>
+            {hasMovies && <RollSection />}
+          </div>
+        )}
       </section>
       </section>
 
 
       <ListGrid />
       <ListGrid />
+
+      {!hasGroups && (
+        <div className="flex flex-col items-stretch gap-4 my-12 sm:my-16 sm:flex-row sm:items-center">
+          <Link
+            href="/create-group"
+            className="inline-flex w-full sm:flex-1 items-center justify-center rounded-lg bg-blue-700 text-white px-8 py-4 text-lg sm:text-xl font-semibold hover:bg-blue-600 transition-colors text-center"
+            style={{ minHeight: 60 }}
+          >
+            + Create List
+          </Link>
+          <JoinListButton size="large" />
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 2 - 0
src/app/(app)/layout.tsx

@@ -2,6 +2,7 @@ import Link from "next/link";
 import { SignOutButton } from "@/components/auth/sign-out-button";
 import { SignOutButton } from "@/components/auth/sign-out-button";
 import { SessionKeeper } from "@/components/auth/session-keeper";
 import { SessionKeeper } from "@/components/auth/session-keeper";
 import { AuthBootstrap } from "@/components/auth/auth-bootstrap";
 import { AuthBootstrap } from "@/components/auth/auth-bootstrap";
+import { TMDBFooter } from "@/components/shared/tmdb-footer";
 
 
 export default function AppLayout({ children }: { children: React.ReactNode }) {
 export default function AppLayout({ children }: { children: React.ReactNode }) {
   return (
   return (
@@ -17,6 +18,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
         </nav>
         </nav>
       </header>
       </header>
       <main className="flex-1">{children}</main>
       <main className="flex-1">{children}</main>
+      <TMDBFooter />
     </div>
     </div>
   );
   );
 }
 }

+ 53 - 54
src/app/(app)/list/[id]/page.tsx

@@ -41,69 +41,68 @@ export default async function ListPage({ params }: ListPageProps) {
     <div className="flex min-h-screen flex-col">
     <div className="flex min-h-screen flex-col">
       {/* Header */}
       {/* Header */}
       <header className="sticky top-0 z-10 border-b border-white/10 bg-background/80 backdrop-blur-sm">
       <header className="sticky top-0 z-10 border-b border-white/10 bg-background/80 backdrop-blur-sm">
-        <div className="mx-auto grid max-w-5xl grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-4">
-          <a
-            href="/home"
-            className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
-            aria-label="Back to home"
-          >
-            <svg
-              xmlns="http://www.w3.org/2000/svg"
-              width="20"
-              height="20"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              strokeWidth="2"
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              aria-hidden="true"
+        <div className="mx-auto max-w-5xl px-4 py-4">
+          <div className="grid grid-cols-[auto_1fr_auto] items-center gap-3">
+            <a
+              href="/home"
+              className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
+              aria-label="Back to home"
             >
             >
-              <path d="M19 12H5" />
-              <path d="m12 19-7-7 7-7" />
-            </svg>
-          </a>
-          <div className="flex min-w-0 flex-col items-center gap-1 text-center">
-            <h1 className="max-w-full truncate text-2xl font-bold sm:text-3xl">{group.name}</h1>
-            <p className="text-[11px] uppercase tracking-[0.18em] text-foreground/45">
-              Join code{" "}
-              <span className="ml-1 rounded bg-foreground/10 px-2 py-0.5 font-mono text-xs tracking-normal text-foreground/80">
-                {group.invite_code}
-              </span>
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                width="20"
+                height="20"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                aria-hidden="true"
+              >
+                <path d="M19 12H5" />
+                <path d="m12 19-7-7 7-7" />
+              </svg>
+            </a>
+            <h1 className="min-w-0 max-w-full truncate text-center text-2xl font-bold sm:text-3xl">
+              {group.name}
+            </h1>
+            <a
+              href={`/list/${group.id}/settings`}
+              className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
+              aria-label="List settings"
+            >
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                width="20"
+                height="20"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                aria-hidden="true"
+              >
+                <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
+                <circle cx="12" cy="12" r="3" />
+              </svg>
+            </a>
+          </div>
+          <div className="mt-3 flex flex-col items-center gap-1.5 text-center">
+            <p className="text-xs uppercase tracking-[0.18em] text-foreground/45">
+              Invite Friends to Add Movies!
             </p>
             </p>
+            <span className="font-mono text-3xl tracking-widest text-foreground sm:text-4xl">
+              {group.invite_code}
+            </span>
           </div>
           </div>
-          <a
-            href={`/list/${group.id}/settings`}
-            className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
-            aria-label="List settings"
-          >
-            <svg
-              xmlns="http://www.w3.org/2000/svg"
-              width="20"
-              height="20"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              strokeWidth="2"
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              aria-hidden="true"
-            >
-              <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
-              <circle cx="12" cy="12" r="3" />
-            </svg>
-          </a>
         </div>
         </div>
       </header>
       </header>
 
 
       <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
       <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
         <MovieListClient groupId={group.id} groupName={group.name} members={members} />
         <MovieListClient groupId={group.id} groupName={group.name} members={members} />
       </main>
       </main>
-
-      {/* TMDB footer */}
-      <footer className="border-t border-white/10 py-4 text-center text-xs text-foreground/40">
-        <p>This product uses the TMDB API but is not endorsed or certified by TMDB.</p>
-      </footer>
     </div>
     </div>
   );
   );
 }
 }

+ 23 - 4
src/app/(app)/list/[id]/settings/page.tsx

@@ -8,11 +8,30 @@ export default async function ListSettingsPage({ params }: SettingsPageProps) {
   const { id } = await params;
   const { id } = await params;
   return (
   return (
     <main className="mx-auto w-full max-w-3xl px-4 py-6">
     <main className="mx-auto w-full max-w-3xl px-4 py-6">
-      <div className="mb-6 flex items-center justify-between">
-        <h1 className="text-2xl font-bold">List Settings</h1>
-        <a href={`/list/${id}`} className="text-sm text-foreground/60 hover:text-foreground">
-          ← Back to list
+      <div className="mb-6 grid grid-cols-[auto_1fr_auto] items-center gap-3">
+        <a
+          href={`/list/${id}`}
+          className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
+          aria-label="Back to list"
+        >
+          <svg
+            xmlns="http://www.w3.org/2000/svg"
+            width="20"
+            height="20"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            strokeWidth="2"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            aria-hidden="true"
+          >
+            <path d="M19 12H5" />
+            <path d="m12 19-7-7 7-7" />
+          </svg>
         </a>
         </a>
+        <h1 className="text-center text-2xl font-bold sm:text-3xl">List Settings</h1>
+        <span className="w-9" aria-hidden="true" />
       </div>
       </div>
       <SettingsPanel groupId={id} />
       <SettingsPanel groupId={id} />
     </main>
     </main>

+ 6 - 2
src/app/(auth)/layout.tsx

@@ -1,9 +1,13 @@
 import type { ReactNode } from "react";
 import type { ReactNode } from "react";
+import { TMDBFooter } from "@/components/shared/tmdb-footer";
 
 
 export default function AuthLayout({ children }: { children: ReactNode }) {
 export default function AuthLayout({ children }: { children: ReactNode }) {
   return (
   return (
-    <div className="flex min-h-full flex-1 items-center justify-center px-4 py-12">
-      <div className="w-full max-w-sm">{children}</div>
+    <div className="flex min-h-screen flex-col">
+      <main className="flex flex-1 items-center justify-center px-4 py-12">
+        <div className="w-full max-w-sm">{children}</div>
+      </main>
+      <TMDBFooter />
     </div>
     </div>
   );
   );
 }
 }

+ 4 - 2
src/app/(public)/privacy/page.tsx

@@ -135,10 +135,12 @@ export default function PrivacyPage() {
           </p>
           </p>
         </section>
         </section>
 
 
-        <section>
+        <section id="gdpr">
+          <span id="ccpa" className="block -translate-y-4" aria-hidden="true" />
           <h2>6. Your Rights</h2>
           <h2>6. Your Rights</h2>
           <p className="mt-2">
           <p className="mt-2">
-            Depending on your jurisdiction, you may have the following rights regarding your
+            Depending on your jurisdiction (including CCPA rights for US/California residents and
+            GDPR rights for EU/EEA residents), you may have the following rights regarding your
             personal data:
             personal data:
           </p>
           </p>
           <ul className="mt-2">
           <ul className="mt-2">

+ 8 - 1
src/app/admin/layout.tsx

@@ -1,3 +1,10 @@
+import { TMDBFooter } from "@/components/shared/tmdb-footer";
+
 export default function AdminLayout({ children }: { children: React.ReactNode }) {
 export default function AdminLayout({ children }: { children: React.ReactNode }) {
-  return <>{children}</>;
+  return (
+    <div className="flex min-h-screen flex-col bg-neutral-950">
+      <div className="flex-1">{children}</div>
+      <TMDBFooter />
+    </div>
+  );
 }
 }

+ 8 - 4
src/app/api/auth/bootstrap/route.ts

@@ -61,12 +61,16 @@ export async function POST(request: NextRequest) {
     .select("id", { count: "exact", head: true })
     .select("id", { count: "exact", head: true })
     .gt("iat_original", since);
     .gt("iat_original", since);
   if (countErr) {
   if (countErr) {
-    console.error("[bootstrap] daily-cap count failed", countErr);
-    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+    // Soft-fail: log and treat as 0 rather than 500-ing the bootstrap. The
+    // daily cap is a defense-in-depth circuit breaker (per-IP rate limit is
+    // the primary control); a transient count-query failure should not block
+    // legitimate anonymous sign-ins.
+    console.error("[bootstrap] daily-cap count failed, treating as 0", countErr);
   }
   }
-  if ((dailyCount ?? 0) >= cap) {
+  const effectiveCount = countErr ? 0 : (dailyCount ?? 0);
+  if (effectiveCount >= cap) {
     console.warn(
     console.warn(
-      `[bootstrap] daily circuit breaker tripped: ${dailyCount}/${cap} sessions in last 24h`,
+      `[bootstrap] daily circuit breaker tripped: ${effectiveCount}/${cap} sessions in last 24h`,
     );
     );
     return NextResponse.json({ error: "Service temporarily unavailable" }, { status: 503 });
     return NextResponse.json({ error: "Service temporarily unavailable" }, { status: 503 });
   }
   }

+ 20 - 23
src/app/api/auth/signout/route.ts

@@ -1,33 +1,30 @@
-import { createServerClient, type CookieOptions } from "@supabase/ssr";
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
+import { getCurrentUser } from "@/lib/auth/current-user";
+import { revokeSession } from "@/lib/auth/sessions";
 import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
 import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
 
 
+/**
+ * Sign-out for our minted-JWT trust boundary. We do NOT call
+ * `supabase.auth.signOut()` — GoTrue cannot validate our tokens (their
+ * `session_id` is not in `auth.sessions`) and the call would silently no-op
+ * while contributing nothing. Instead: best-effort revoke `user_sessions`
+ * row from the access token claims, then clear our cookie by name.
+ */
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
   const response = NextResponse.json({ ok: true });
   const response = NextResponse.json({ ok: true });
 
 
-  const supabaseUrl = process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
-  const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
-  if (!supabaseUrl || !supabaseAnonKey) {
-    return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
+  // Best-effort: if a valid session is present, mark it revoked. Missing or
+  // already-invalid sessions are fine — the only required side-effect is
+  // clearing the cookie, which always happens below.
+  const current = await getCurrentUser(request);
+  if (current) {
+    try {
+      await revokeSession(current.sessionId);
+    } catch (err) {
+      console.error("[signout] revokeSession failed", err);
+    }
   }
   }
 
 
-  const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
-    cookieOptions: { name: getSupabaseCookieName() },
-    cookies: {
-      getAll() {
-        return request.cookies.getAll();
-      },
-      setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
-        cookiesToSet.forEach(({ name, value, options }) =>
-          response.cookies.set(name, value, options),
-        );
-      },
-    },
-  });
-
-  const { error } = await supabase.auth.signOut();
-  if (error) {
-    return NextResponse.json({ error: "Failed to sign out" }, { status: 500 });
-  }
+  response.cookies.delete(getSupabaseCookieName());
   return response;
   return response;
 }
 }

+ 3 - 3
src/app/api/tmdb/discover/route.ts

@@ -1,12 +1,11 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { z } from "zod";
 import { z } from "zod";
 import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
 import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
+import { DISCOVER_CERT_PARAMS } from "@/lib/tmdb/certification";
 import type { TMDBSearchResponse } from "@/types/tmdb";
 import type { TMDBSearchResponse } from "@/types/tmdb";
 
 
 const discoverParamsSchema = z.object({
 const discoverParamsSchema = z.object({
-  with_genres: z
-    .string()
-    .regex(/^\d+(,\d+)*$/, "Genre IDs must be comma-separated numbers"),
+  with_genres: z.string().regex(/^\d+(,\d+)*$/, "Genre IDs must be comma-separated numbers"),
   page: z.coerce.number().int().min(1).max(500).optional().default(1),
   page: z.coerce.number().int().min(1).max(500).optional().default(1),
   sort_by: z
   sort_by: z
     .enum(["popularity.desc", "popularity.asc", "vote_average.desc", "vote_average.asc"])
     .enum(["popularity.desc", "popularity.asc", "vote_average.desc", "vote_average.asc"])
@@ -29,6 +28,7 @@ export async function GET(request: NextRequest) {
 
 
   try {
   try {
     const data = await tmdbFetch<TMDBSearchResponse>("/discover/movie", {
     const data = await tmdbFetch<TMDBSearchResponse>("/discover/movie", {
+      ...DISCOVER_CERT_PARAMS,
       with_genres,
       with_genres,
       page: String(page),
       page: String(page),
       sort_by,
       sort_by,

+ 7 - 6
src/app/api/tmdb/movie/[id]/route.ts

@@ -1,11 +1,9 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { tmdbFetch, movieIdSchema } from "@/lib/tmdb/client";
 import { tmdbFetch, movieIdSchema } from "@/lib/tmdb/client";
+import { isMovieAllowedByCert } from "@/lib/tmdb/certification";
 import type { TMDBMovieDetails } from "@/types/tmdb";
 import type { TMDBMovieDetails } from "@/types/tmdb";
 
 
-export async function GET(
-  _request: NextRequest,
-  { params }: { params: Promise<{ id: string }> },
-) {
+export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
   const { id: rawId } = await params;
   const { id: rawId } = await params;
   const parsed = movieIdSchema.safeParse({ id: rawId });
   const parsed = movieIdSchema.safeParse({ id: rawId });
 
 
@@ -17,9 +15,12 @@ export async function GET(
   }
   }
 
 
   try {
   try {
-    const data = await tmdbFetch<TMDBMovieDetails>(`/movie/${parsed.data.id}`);
+    const data = await tmdbFetch<TMDBMovieDetails>(`/movie/${parsed.data.id}`, {
+      append_to_response: "release_dates",
+    });
 
 
-    if (data.adult) {
+    if (data.adult || !isMovieAllowedByCert(data.release_dates)) {
+      // Don't leak that the movie exists — same 404 as a bogus ID.
       return NextResponse.json({ error: "Movie not found" }, { status: 404 });
       return NextResponse.json({ error: "Movie not found" }, { status: 404 });
     }
     }
 
 

+ 18 - 8
src/app/api/tmdb/movie/[id]/videos/route.ts

@@ -1,12 +1,12 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { tmdbFetch, movieIdSchema } from "@/lib/tmdb/client";
 import { tmdbFetch, movieIdSchema } from "@/lib/tmdb/client";
+import { isMovieAllowedByCert } from "@/lib/tmdb/certification";
 import { extractTrailerUrl } from "@/lib/tmdb/trailer";
 import { extractTrailerUrl } from "@/lib/tmdb/trailer";
-import type { TMDBVideosResponse } from "@/types/tmdb";
+import type { TMDBMovieDetails, TMDBVideosResponse } from "@/types/tmdb";
 
 
-export async function GET(
-  _request: NextRequest,
-  { params }: { params: Promise<{ id: string }> },
-) {
+type MovieWithVideos = TMDBMovieDetails & { videos?: TMDBVideosResponse };
+
+export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
   const { id: rawId } = await params;
   const { id: rawId } = await params;
   const parsed = movieIdSchema.safeParse({ id: rawId });
   const parsed = movieIdSchema.safeParse({ id: rawId });
 
 
@@ -18,11 +18,21 @@ export async function GET(
   }
   }
 
 
   try {
   try {
-    const data = await tmdbFetch<TMDBVideosResponse>(`/movie/${parsed.data.id}/videos`);
-    const trailerUrl = extractTrailerUrl(data.results);
+    // Batch cert-check + videos in a single TMDB call via append_to_response.
+    const data = await tmdbFetch<MovieWithVideos>(`/movie/${parsed.data.id}`, {
+      append_to_response: "videos,release_dates",
+    });
+
+    if (data.adult || !isMovieAllowedByCert(data.release_dates)) {
+      // Don't leak that the movie exists — same 404 as a bogus ID.
+      return NextResponse.json({ error: "Movie not found" }, { status: 404 });
+    }
+
+    const videos = data.videos ?? { id: data.id, results: [] };
+    const trailerUrl = extractTrailerUrl(videos.results);
 
 
     return NextResponse.json(
     return NextResponse.json(
-      { id: data.id, results: data.results, trailerUrl },
+      { id: videos.id ?? data.id, results: videos.results, trailerUrl },
       {
       {
         headers: {
         headers: {
           "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=300",
           "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=300",

+ 6 - 1
src/app/api/tmdb/popular/route.ts

@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { z } from "zod";
 import { z } from "zod";
 import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
 import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
+import { DISCOVER_CERT_PARAMS } from "@/lib/tmdb/certification";
 import type { TMDBSearchResponse } from "@/types/tmdb";
 import type { TMDBSearchResponse } from "@/types/tmdb";
 
 
 const popularParamsSchema = z.object({
 const popularParamsSchema = z.object({
@@ -21,7 +22,11 @@ export async function GET(request: NextRequest) {
   const { page } = parsed.data;
   const { page } = parsed.data;
 
 
   try {
   try {
-    const data = await tmdbFetch<TMDBSearchResponse>("/movie/popular", {
+    // Use /discover with sort_by=popularity.desc + cert filter so TMDB
+    // pre-filters server-side; we still post-filter adult flags defensively.
+    const data = await tmdbFetch<TMDBSearchResponse>("/discover/movie", {
+      ...DISCOVER_CERT_PARAMS,
+      sort_by: "popularity.desc",
       page: String(page),
       page: String(page),
     });
     });
 
 

+ 6 - 1
src/app/api/tmdb/reel-posters/route.ts

@@ -47,13 +47,18 @@ export async function GET() {
     }
     }
 
 
     // Fallback: TMDB live. Only hit on cold dev DBs or before the first cron run.
     // Fallback: TMDB live. Only hit on cold dev DBs or before the first cron run.
+    // Use /discover with PG-13-or-better cert filter (belt-and-suspenders alongside
+    // the cron-populated table, which is filtered upstream).
     const params = new URLSearchParams({
     const params = new URLSearchParams({
       language: "en-US",
       language: "en-US",
       page: "1",
       page: "1",
       include_adult: "false",
       include_adult: "false",
+      sort_by: "popularity.desc",
+      certification_country: "US",
+      "certification.lte": "PG-13",
     });
     });
 
 
-    const { movies, error, status } = await fetchTMDBMovies("/movie/popular", params);
+    const { movies, error, status } = await fetchTMDBMovies("/discover/movie", params);
 
 
     if (error) {
     if (error) {
       return NextResponse.json({ error }, { status: status ?? 502 });
       return NextResponse.json({ error }, { status: status ?? 502 });

+ 13 - 2
src/app/api/tmdb/search/route.ts

@@ -1,6 +1,6 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { z } from "zod";
 import { z } from "zod";
-import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
+import { tmdbFetch, filterAdultMovies, fetchAndFilterByCert } from "@/lib/tmdb/client";
 import type { TMDBSearchResponse } from "@/types/tmdb";
 import type { TMDBSearchResponse } from "@/types/tmdb";
 
 
 const searchParamsSchema = z.object({
 const searchParamsSchema = z.object({
@@ -27,7 +27,18 @@ export async function GET(request: NextRequest) {
       page: String(page),
       page: String(page),
     });
     });
 
 
-    return NextResponse.json(filterAdultMovies(data), {
+    // /search/movie has no certification.lte param, so post-filter:
+    // batched detail lookups (append_to_response=release_dates) drop any
+    // title that fails the STRICT cert walker. Disallowed/missing → dropped.
+    const adultFiltered = filterAdultMovies(data);
+    const ids = adultFiltered.results.map((m) => m.id);
+    const allowed = await fetchAndFilterByCert(ids);
+    const filtered: TMDBSearchResponse = {
+      ...adultFiltered,
+      results: adultFiltered.results.filter((m) => allowed.has(m.id)),
+    };
+
+    return NextResponse.json(filtered, {
       headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" },
       headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" },
     });
     });
   } catch (error) {
   } catch (error) {

+ 49 - 6
src/app/globals.css

@@ -26,9 +26,18 @@ body {
 }
 }
 
 
 @keyframes shake {
 @keyframes shake {
-  0%, 100% { transform: translateX(0); }
-  20%, 60% { transform: translateX(-4px); }
-  40%, 80% { transform: translateX(4px); }
+  0%,
+  100% {
+    transform: translateX(0);
+  }
+  20%,
+  60% {
+    transform: translateX(-4px);
+  }
+  40%,
+  80% {
+    transform: translateX(4px);
+  }
 }
 }
 
 
 @utility animate-shake {
 @utility animate-shake {
@@ -36,17 +45,51 @@ body {
 }
 }
 
 
 @keyframes dice-emerge {
 @keyframes dice-emerge {
-  0% { transform: scale(0); opacity: 0; }
-  60% { transform: scale(1.08); opacity: 1; }
-  100% { transform: scale(1); opacity: 1; }
+  0% {
+    transform: scale(0);
+    opacity: 0;
+  }
+  60% {
+    transform: scale(1.08);
+    opacity: 1;
+  }
+  100% {
+    transform: scale(1);
+    opacity: 1;
+  }
 }
 }
 
 
 @utility animate-emerge {
 @utility animate-emerge {
   animation: dice-emerge 500ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
   animation: dice-emerge 500ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
 }
 }
 
 
+/* Gold pulse glow — must read on both light (#ffffff) and dark (#0a0a0a)
+   page backgrounds. Pure white shadows vanish against a white page, so we
+   tint with the roll-teaser gold (rgba(250,204,21,…)) and layer a wider
+   soft halo for depth. */
+@keyframes pulse-glow {
+  0%,
+  100% {
+    box-shadow:
+      0 0 12px 2px rgba(250, 204, 21, 0.55),
+      0 0 28px 6px rgba(250, 204, 21, 0.25);
+  }
+  50% {
+    box-shadow:
+      0 0 20px 4px rgba(250, 204, 21, 0.85),
+      0 0 48px 12px rgba(250, 204, 21, 0.45);
+  }
+}
+
+@utility animate-pulse-glow {
+  animation: pulse-glow 2s ease-in-out infinite;
+}
+
 @media (prefers-reduced-motion: reduce) {
 @media (prefers-reduced-motion: reduce) {
   .animate-emerge {
   .animate-emerge {
     animation: none;
     animation: none;
   }
   }
+  .animate-pulse-glow {
+    animation: none;
+  }
 }
 }

+ 16 - 19
src/app/logout/route.ts

@@ -1,29 +1,26 @@
-import { createServerClient, type CookieOptions } from "@supabase/ssr";
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
+import { getCurrentUser } from "@/lib/auth/current-user";
+import { revokeSession } from "@/lib/auth/sessions";
 import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
 import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
 
 
+/**
+ * Linkable sign-out (GET or POST). Mirrors `/api/auth/signout` but redirects
+ * to `/`. Does NOT call `supabase.auth.signOut()` — see the POST route for
+ * rationale. Missing/invalid session is a no-op; cookie is always cleared.
+ */
 async function handle(request: NextRequest) {
 async function handle(request: NextRequest) {
   const response = NextResponse.redirect(new URL("/", request.url));
   const response = NextResponse.redirect(new URL("/", request.url));
 
 
-  const supabaseUrl = process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
-  const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
-  if (!supabaseUrl || !supabaseAnonKey) return response;
+  const current = await getCurrentUser(request);
+  if (current) {
+    try {
+      await revokeSession(current.sessionId);
+    } catch (err) {
+      console.error("[logout] revokeSession failed", err);
+    }
+  }
 
 
-  const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
-    cookieOptions: { name: getSupabaseCookieName() },
-    cookies: {
-      getAll() {
-        return request.cookies.getAll();
-      },
-      setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
-        cookiesToSet.forEach(({ name, value, options }) =>
-          response.cookies.set(name, value, options),
-        );
-      },
-    },
-  });
-
-  await supabase.auth.signOut();
+  response.cookies.delete(getSupabaseCookieName());
   return response;
   return response;
 }
 }
 
 

+ 12 - 10
src/components/dice/list-roll-carousel.tsx

@@ -226,8 +226,11 @@ function ListTeaserCard({
 
 
   return (
   return (
     <>
     <>
-      <div
-        className="flex w-44 flex-col items-center rounded-xl bg-background/95 p-3 ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)] backdrop-blur"
+      <button
+        type="button"
+        onClick={() => setModalOpen(true)}
+        aria-label={`More info about ${movie.title}`}
+        className="flex w-44 flex-col items-center rounded-xl bg-background/95 p-3 ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)] backdrop-blur text-left cursor-pointer transition-transform hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-yellow-300"
         aria-live="polite"
         aria-live="polite"
       >
       >
         {posterUrl ? (
         {posterUrl ? (
@@ -243,21 +246,20 @@ function ListTeaserCard({
             No poster
             No poster
           </div>
           </div>
         )}
         )}
-        <h3 className="mt-2 text-center text-sm font-semibold leading-tight line-clamp-2">
+        <h3 className="mt-2 w-full text-center text-sm font-semibold leading-tight line-clamp-2">
           {movie.title}
           {movie.title}
           {movie.year > 0 && (
           {movie.year > 0 && (
             <span className="ml-1 text-xs font-normal text-foreground/50">({movie.year})</span>
             <span className="ml-1 text-xs font-normal text-foreground/50">({movie.year})</span>
           )}
           )}
         </h3>
         </h3>
-        <button
-          onClick={() => setModalOpen(true)}
-          aria-label={`More info about ${movie.title}`}
-          className="mt-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-foreground/30 text-xs font-serif italic text-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
+        <span
+          aria-hidden="true"
+          className="mt-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-foreground/30 text-xs font-serif italic text-foreground/70"
         >
         >
           i
           i
-        </button>
+        </span>
         {movie.genres.length > 0 && (
         {movie.genres.length > 0 && (
-          <div className="mt-1.5 flex flex-wrap justify-center gap-1">
+          <div className="mt-1.5 flex w-full flex-wrap justify-center gap-1">
             {movie.genres.slice(0, 2).map((genre) => (
             {movie.genres.slice(0, 2).map((genre) => (
               <span
               <span
                 key={genre}
                 key={genre}
@@ -268,7 +270,7 @@ function ListTeaserCard({
             ))}
             ))}
           </div>
           </div>
         )}
         )}
-      </div>
+      </button>
       {modalOpen && (
       {modalOpen && (
         <ListMoreInfoModal
         <ListMoreInfoModal
           movie={movie}
           movie={movie}

+ 56 - 47
src/components/groups/settings-panel.tsx

@@ -184,54 +184,25 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
     setTransferTarget({ userId, displayName });
     setTransferTarget({ userId, displayName });
   }, []);
   }, []);
 
 
-  if (isLoading) return <p className="text-sm text-gray-500">Loading...</p>;
-  if (error) return <p className="text-sm text-red-500">Failed to load group</p>;
+  if (isLoading) return <p className="text-base text-gray-500">Loading...</p>;
+  if (error) return <p className="text-base text-red-500">Failed to load group</p>;
   if (!data) return null;
   if (!data) return null;
 
 
   const isAdmin = data.role === "admin";
   const isAdmin = data.role === "admin";
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
-      {/* Invite Code */}
-      <div>
-        <h3 className="text-sm font-medium mb-2">Invite Code</h3>
-        <div className="flex items-center gap-2">
-          <code className="rounded bg-gray-100 px-3 py-2 font-mono text-lg dark:bg-gray-800">
-            {data.group.invite_code}
-          </code>
-          <button
-            onClick={handleCopyCode}
-            className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
-            aria-label="Copy invite code"
-          >
-            {copied ? "Copied!" : "Copy"}
-          </button>
-          {isAdmin && (
-            <button
-              onClick={() => regenerateCode.mutate()}
-              disabled={regenerateCode.isPending}
-              className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
-            >
-              {regenerateCode.isPending ? "..." : "Regenerate"}
-            </button>
-          )}
-        </div>
-        {regenerateCode.error && (
-          <p className="mt-1 text-sm text-red-500">{regenerateCode.error.message}</p>
-        )}
-      </div>
-
       {/* Rename (admin only) */}
       {/* Rename (admin only) */}
       {isAdmin && (
       {isAdmin && (
         <div>
         <div>
-          <h3 className="text-sm font-medium mb-2">Rename Group</h3>
+          <h3 className="text-base font-medium mb-2">Rename List</h3>
           <form
           <form
             onSubmit={(e) => {
             onSubmit={(e) => {
               e.preventDefault();
               e.preventDefault();
               const trimmed = newName.trim();
               const trimmed = newName.trim();
               if (trimmed) rename.mutate(trimmed);
               if (trimmed) rename.mutate(trimmed);
             }}
             }}
-            className="flex gap-2"
+            className="flex flex-col gap-2 sm:flex-row"
           >
           >
             <input
             <input
               type="text"
               type="text"
@@ -239,17 +210,53 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
               onChange={(e) => setNewName(e.target.value)}
               onChange={(e) => setNewName(e.target.value)}
               maxLength={GROUP_NAME_MAX_LENGTH}
               maxLength={GROUP_NAME_MAX_LENGTH}
               placeholder={data.group.name}
               placeholder={data.group.name}
-              className="flex-1 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700"
+              className="flex-1 rounded-md border border-gray-300 bg-transparent px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-700"
             />
             />
             <button
             <button
               type="submit"
               type="submit"
               disabled={rename.isPending || !newName.trim()}
               disabled={rename.isPending || !newName.trim()}
-              className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
+              className="rounded-md bg-blue-600 px-4 py-2 text-base font-medium text-white hover:bg-blue-700 disabled:opacity-50"
             >
             >
-              {rename.isPending ? "..." : "Rename"}
+              {rename.isPending ? "Saving..." : "Save name"}
             </button>
             </button>
           </form>
           </form>
-          {rename.error && <p className="mt-1 text-sm text-red-500">{rename.error.message}</p>}
+          {rename.error && <p className="mt-1 text-base text-red-500">{rename.error.message}</p>}
+          <div className="mt-3 flex flex-wrap items-center gap-3 text-sm">
+            <button
+              type="button"
+              onClick={handleCopyCode}
+              className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline"
+              aria-label="Copy invite code"
+            >
+              {copied ? "Code copied" : "Copy invite code"}
+            </button>
+            {isAdmin && (
+              <button
+                type="button"
+                onClick={() => regenerateCode.mutate()}
+                disabled={regenerateCode.isPending}
+                className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:opacity-50"
+                aria-label="Regenerate invite code"
+              >
+                {regenerateCode.isPending ? "Regenerating..." : "Regenerate invite code"}
+              </button>
+            )}
+          </div>
+          {regenerateCode.error && (
+            <p className="mt-1 text-base text-red-500">{regenerateCode.error.message}</p>
+          )}
+        </div>
+      )}
+      {!isAdmin && (
+        <div className="flex flex-wrap items-center gap-3 text-sm">
+          <button
+            type="button"
+            onClick={handleCopyCode}
+            className="text-foreground/70 underline-offset-4 hover:text-foreground hover:underline"
+            aria-label="Copy invite code"
+          >
+            {copied ? "Code copied" : "Copy invite code"}
+          </button>
         </div>
         </div>
       )}
       )}
 
 
@@ -276,7 +283,7 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
                   }
                   }
                 }}
                 }}
                 disabled={deleteGroup.isPending}
                 disabled={deleteGroup.isPending}
-                className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${
+                className={`w-full rounded-md px-4 py-2 text-base font-medium text-white disabled:opacity-50 ${
                   deleteState === "armed"
                   deleteState === "armed"
                     ? "animate-shake bg-red-700 hover:bg-red-800"
                     ? "animate-shake bg-red-700 hover:bg-red-800"
                     : "bg-red-600 hover:bg-red-700"
                     : "bg-red-600 hover:bg-red-700"
@@ -295,13 +302,13 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
 
 
             {deleteState === "choosing" && (
             {deleteState === "choosing" && (
               <div className="space-y-2 rounded-md border border-red-300 p-3 dark:border-red-800">
               <div className="space-y-2 rounded-md border border-red-300 p-3 dark:border-red-800">
-                <p className="text-sm font-medium text-red-700 dark:text-red-300">
+                <p className="text-base font-medium text-red-700 dark:text-red-300">
                   Choose a new admin. You will leave the list once ownership transfers.
                   Choose a new admin. You will leave the list once ownership transfers.
                 </p>
                 </p>
                 {membersQuery.isLoading ? (
                 {membersQuery.isLoading ? (
-                  <p className="text-sm text-gray-500">Loading members...</p>
+                  <p className="text-base text-gray-500">Loading members...</p>
                 ) : successors.length === 0 ? (
                 ) : successors.length === 0 ? (
-                  <p className="text-sm text-gray-500">No other members available.</p>
+                  <p className="text-base text-gray-500">No other members available.</p>
                 ) : (
                 ) : (
                   <ul className="space-y-1">
                   <ul className="space-y-1">
                     {successors.map((m) => (
                     {successors.map((m) => (
@@ -309,7 +316,7 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
                         <button
                         <button
                           onClick={() => transferAndLeave.mutate(m.user_id)}
                           onClick={() => transferAndLeave.mutate(m.user_id)}
                           disabled={transferAndLeave.isPending}
                           disabled={transferAndLeave.isPending}
-                          className="flex w-full items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
+                          className="flex w-full items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-base hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
                         >
                         >
                           <span
                           <span
                             className="inline-block h-5 w-5 rounded-full"
                             className="inline-block h-5 w-5 rounded-full"
@@ -325,17 +332,17 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
                 <button
                 <button
                   onClick={() => setDeleteState("idle")}
                   onClick={() => setDeleteState("idle")}
                   disabled={transferAndLeave.isPending}
                   disabled={transferAndLeave.isPending}
-                  className="text-xs text-gray-600 hover:underline dark:text-gray-400"
+                  className="text-sm text-gray-600 hover:underline dark:text-gray-400"
                 >
                 >
                   Cancel
                   Cancel
                 </button>
                 </button>
                 {transferAndLeave.error && (
                 {transferAndLeave.error && (
-                  <p className="text-sm text-red-500">{transferAndLeave.error.message}</p>
+                  <p className="text-base text-red-500">{transferAndLeave.error.message}</p>
                 )}
                 )}
               </div>
               </div>
             )}
             )}
             {deleteGroup.error && (
             {deleteGroup.error && (
-              <p className="text-sm text-red-500">{deleteGroup.error.message}</p>
+              <p className="text-base text-red-500">{deleteGroup.error.message}</p>
             )}
             )}
           </div>
           </div>
         ) : (
         ) : (
@@ -350,7 +357,7 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
                 leaveGroup.mutate();
                 leaveGroup.mutate();
               }}
               }}
               disabled={leaveGroup.isPending}
               disabled={leaveGroup.isPending}
-              className={`w-full rounded-md border px-4 py-2 text-sm font-medium disabled:opacity-50 ${
+              className={`w-full rounded-md border px-4 py-2 text-base font-medium disabled:opacity-50 ${
                 deleteState === "armed"
                 deleteState === "armed"
                   ? "animate-shake border-red-500 bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
                   ? "animate-shake border-red-500 bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
                   : "border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
                   : "border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
@@ -363,7 +370,9 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
                   ? "Click again to confirm"
                   ? "Click again to confirm"
                   : "Leave List"}
                   : "Leave List"}
             </button>
             </button>
-            {leaveGroup.error && <p className="text-sm text-red-500">{leaveGroup.error.message}</p>}
+            {leaveGroup.error && (
+              <p className="text-base text-red-500">{leaveGroup.error.message}</p>
+            )}
           </div>
           </div>
         )}
         )}
       </div>
       </div>

+ 1 - 17
src/components/home/empty-state.tsx

@@ -1,8 +1,6 @@
-import Link from "next/link";
-
 export function EmptyState() {
 export function EmptyState() {
   return (
   return (
-    <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
+    <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
       <p className="text-5xl mb-4" aria-hidden="true">
       <p className="text-5xl mb-4" aria-hidden="true">
         🎲
         🎲
       </p>
       </p>
@@ -10,20 +8,6 @@ export function EmptyState() {
       <p className="mt-2 text-foreground/60 max-w-sm">
       <p className="mt-2 text-foreground/60 max-w-sm">
         Create a new list to start adding movies, or join an existing one with an invite code.
         Create a new list to start adding movies, or join an existing one with an invite code.
       </p>
       </p>
-      <div className="mt-6 flex flex-col sm:flex-row gap-3">
-        <Link
-          href="/create-group"
-          className="inline-flex items-center justify-center rounded-lg bg-foreground text-background px-6 py-3 text-sm font-medium hover:opacity-90 transition-opacity"
-        >
-          Create List
-        </Link>
-        <Link
-          href="/join"
-          className="inline-flex items-center justify-center rounded-lg border border-foreground/20 px-6 py-3 text-sm font-medium text-foreground hover:bg-foreground/5 transition-colors"
-        >
-          Join with Code
-        </Link>
-      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 16 - 4
src/components/home/join-list-button.tsx

@@ -11,7 +11,11 @@ import { useState } from "react";
  * Errors render inline; rate-limit (429) and "invalid code" (404) both
  * Errors render inline; rate-limit (429) and "invalid code" (404) both
  * surface user-friendly messages.
  * surface user-friendly messages.
  */
  */
-export function JoinListButton() {
+interface JoinListButtonProps {
+  size?: "default" | "large";
+}
+
+export function JoinListButton({ size = "default" }: JoinListButtonProps = {}) {
   const router = useRouter();
   const router = useRouter();
   const [open, setOpen] = useState(false);
   const [open, setOpen] = useState(false);
   const [code, setCode] = useState("");
   const [code, setCode] = useState("");
@@ -54,12 +58,20 @@ export function JoinListButton() {
   }
   }
 
 
   if (!open) {
   if (!open) {
+    // Both sizes are designed to participate as an equal-width flex child
+    // (sibling of "+ Create List"): `w-full` with no `mx-auto`/`max-w-*` so
+    // the parent's `sm:flex-1` controls width. Min-heights match the Create
+    // List button in each context (44px default, 60px large).
+    const largeClasses =
+      "w-full sm:flex-1 rounded-lg bg-foreground/10 px-8 py-4 text-lg sm:text-xl font-semibold text-foreground hover:bg-foreground/20 transition-colors text-center";
+    const defaultClasses =
+      "inline-flex min-h-[44px] w-full sm:flex-1 items-center justify-center rounded-lg bg-foreground/10 px-4 py-2 text-sm font-semibold text-foreground hover:bg-foreground/20 transition-colors text-center";
     return (
     return (
       <button
       <button
         type="button"
         type="button"
         onClick={() => setOpen(true)}
         onClick={() => setOpen(true)}
-        className="mx-auto block w-full max-w-md rounded-lg bg-foreground/10 px-5 py-2.5 text-sm font-medium text-foreground hover:bg-foreground/20 transition-colors text-center"
-        style={{ minHeight: 44 }}
+        className={size === "large" ? largeClasses : defaultClasses}
+        style={{ minHeight: size === "large" ? 60 : 44 }}
       >
       >
         ↪ Join a List
         ↪ Join a List
       </button>
       </button>
@@ -69,7 +81,7 @@ export function JoinListButton() {
   return (
   return (
     <form
     <form
       onSubmit={handleSubmit}
       onSubmit={handleSubmit}
-      className="mx-auto flex w-full max-w-md flex-col gap-2 rounded-lg border border-foreground/10 bg-foreground/5 p-3"
+      className="flex w-full sm:flex-1 flex-col gap-2 rounded-lg border border-foreground/10 bg-foreground/5 p-3"
       aria-label="Join a list by invite code"
       aria-label="Join a list by invite code"
     >
     >
       <label htmlFor="join-code-input" className="text-xs font-medium text-foreground/70">
       <label htmlFor="join-code-input" className="text-xs font-medium text-foreground/70">

+ 17 - 22
src/components/home/roll-section.tsx

@@ -1,6 +1,5 @@
 "use client";
 "use client";
 
 
-import Link from "next/link";
 import { useCallback, useState } from "react";
 import { useCallback, useState } from "react";
 import { useQueryClient } from "@tanstack/react-query";
 import { useQueryClient } from "@tanstack/react-query";
 import { useAllUserMovies } from "@/hooks/use-all-user-movies";
 import { useAllUserMovies } from "@/hooks/use-all-user-movies";
@@ -9,7 +8,6 @@ import { useRoll } from "@/hooks/use-roll";
 import { RollAnnouncer } from "@/components/dice/roll-announcer";
 import { RollAnnouncer } from "@/components/dice/roll-announcer";
 import { GenreRollModal, type GenreRollPayload } from "@/components/dice/genre-roll-modal";
 import { GenreRollModal, type GenreRollPayload } from "@/components/dice/genre-roll-modal";
 import { ListRollCarousel } from "@/components/dice/list-roll-carousel";
 import { ListRollCarousel } from "@/components/dice/list-roll-carousel";
-import { JoinListButton } from "@/components/home/join-list-button";
 import { filterByGenresAndEmotionsStructured } from "@/lib/dice/genre-filter";
 import { filterByGenresAndEmotionsStructured } from "@/lib/dice/genre-filter";
 import type { Movie } from "@/types/movie";
 import type { Movie } from "@/types/movie";
 
 
@@ -89,21 +87,26 @@ export function RollSection() {
 
 
   return (
   return (
     <div className="flex flex-col gap-4">
     <div className="flex flex-col gap-4">
-      <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-center">
+      <div
+        className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"
+        aria-label="Randomizer actions"
+        data-testid="roll-bar"
+      >
         <button
         <button
           type="button"
           type="button"
           onClick={handleRandomRoll}
           onClick={handleRandomRoll}
           disabled={buttonsDisabled}
           disabled={buttonsDisabled}
           aria-disabled={buttonsDisabled}
           aria-disabled={buttonsDisabled}
           title={tooltip}
           title={tooltip}
-          className={`w-full rounded-lg px-5 py-2.5 text-sm font-medium transition-opacity sm:w-auto ${
+          data-testid="roll-bar-random"
+          className={`inline-flex min-h-[44px] w-full sm:flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-colors ${
             buttonsDisabled
             buttonsDisabled
-              ? "bg-foreground/10 text-foreground/40 cursor-not-allowed"
-              : "bg-foreground text-background hover:opacity-90"
+              ? "bg-gray-700 text-gray-400 cursor-not-allowed"
+              : "bg-red-600 text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400"
           }`}
           }`}
-          style={{ minHeight: 44, minWidth: 44 }}
         >
         >
-          🎲 Roll the Dice!
+          <span aria-hidden="true">🎲</span>
+          <span>Roll the Dice!</span>
         </button>
         </button>
         <button
         <button
           type="button"
           type="button"
@@ -111,26 +114,18 @@ export function RollSection() {
           disabled={buttonsDisabled}
           disabled={buttonsDisabled}
           aria-disabled={buttonsDisabled}
           aria-disabled={buttonsDisabled}
           title={tooltip}
           title={tooltip}
-          className={`w-full rounded-lg px-5 py-2.5 text-sm font-medium transition-opacity sm:w-auto ${
+          data-testid="roll-bar-genre"
+          className={`inline-flex min-h-[44px] w-full sm:flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-colors ${
             buttonsDisabled
             buttonsDisabled
-              ? "bg-foreground/10 text-foreground/40 cursor-not-allowed"
-              : "bg-foreground/10 text-foreground hover:bg-foreground/20"
+              ? "bg-gray-700 text-gray-400 cursor-not-allowed"
+              : "bg-purple-600 text-white hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-400"
           }`}
           }`}
-          style={{ minHeight: 44, minWidth: 44 }}
         >
         >
-          🎭 Genre Roll!
+          <span aria-hidden="true">🎭</span>
+          <span>Genre Roll!</span>
         </button>
         </button>
-        <Link
-          href="/create-group"
-          className="w-full rounded-lg bg-blue-700 text-white px-5 py-2.5 text-sm font-medium hover:bg-blue-600 transition-colors text-center sm:w-auto"
-          style={{ minHeight: 44 }}
-        >
-          + Create List
-        </Link>
       </div>
       </div>
 
 
-      <JoinListButton />
-
       <RollAnnouncer state={rollState} winner={winner} />
       <RollAnnouncer state={rollState} winner={winner} />
 
 
       {noMatchesBanner && rollState !== "idle" && (
       {noMatchesBanner && rollState !== "idle" && (

+ 13 - 9
src/components/landing/teaser-card.tsx

@@ -20,7 +20,12 @@ export function TeaserCard({ movie }: TeaserCardProps) {
 
 
   return (
   return (
     <>
     <>
-      <div className="flex w-40 flex-col items-center rounded-xl bg-background/95 p-3 ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)] backdrop-blur">
+      <button
+        type="button"
+        onClick={() => setModalOpen(true)}
+        aria-label={`More info about ${movie.title}`}
+        className="flex w-40 flex-col items-center rounded-xl bg-background/95 p-3 ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)] backdrop-blur text-left cursor-pointer transition-transform hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-yellow-300"
+      >
         {posterUrl ? (
         {posterUrl ? (
           /* eslint-disable-next-line @next/next/no-img-element */
           /* eslint-disable-next-line @next/next/no-img-element */
           <img
           <img
@@ -34,19 +39,18 @@ export function TeaserCard({ movie }: TeaserCardProps) {
             No poster
             No poster
           </div>
           </div>
         )}
         )}
-        <h3 className="mt-2 text-center text-sm font-semibold leading-tight line-clamp-2">
+        <h3 className="mt-2 w-full text-center text-sm font-semibold leading-tight line-clamp-2">
           {movie.title}
           {movie.title}
           {year && <span className="ml-1 text-xs font-normal text-foreground/50">({year})</span>}
           {year && <span className="ml-1 text-xs font-normal text-foreground/50">({year})</span>}
         </h3>
         </h3>
-        <button
-          onClick={() => setModalOpen(true)}
-          aria-label={`More info about ${movie.title}`}
-          className="mt-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-foreground/30 text-xs font-serif italic text-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
+        <span
+          aria-hidden="true"
+          className="mt-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-foreground/30 text-xs font-serif italic text-foreground/70"
         >
         >
           i
           i
-        </button>
+        </span>
         {genres.length > 0 && (
         {genres.length > 0 && (
-          <div className="mt-1.5 flex flex-wrap justify-center gap-1">
+          <div className="mt-1.5 flex w-full flex-wrap justify-center gap-1">
             {genres.slice(0, 2).map((genre) => (
             {genres.slice(0, 2).map((genre) => (
               <span
               <span
                 key={genre}
                 key={genre}
@@ -57,7 +61,7 @@ export function TeaserCard({ movie }: TeaserCardProps) {
             ))}
             ))}
           </div>
           </div>
         )}
         )}
-      </div>
+      </button>
       {modalOpen && <MoreInfoModal movie={movie} onClose={() => setModalOpen(false)} />}
       {modalOpen && <MoreInfoModal movie={movie} onClose={() => setModalOpen(false)} />}
     </>
     </>
   );
   );

+ 8 - 6
src/components/movies/movie-list-client.tsx

@@ -125,12 +125,14 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
 
 
   return (
   return (
     <div className="space-y-6">
     <div className="space-y-6">
-      <RollBar
-        isLoading={isLoading}
-        poolEmpty={poolEmpty}
-        onRoll={handleRandomRoll}
-        onGenreRoll={handleGenreRollOpen}
-      />
+      {allMovies.length > 0 && (
+        <RollBar
+          isLoading={isLoading}
+          poolEmpty={poolEmpty}
+          onRoll={handleRandomRoll}
+          onGenreRoll={handleGenreRollOpen}
+        />
+      )}
 
 
       <div>
       <div>
         <SearchBar onSearch={handleSearch} isLoading={isSearchLoading} />
         <SearchBar onSearch={handleSearch} isLoading={isSearchLoading} />

+ 11 - 4
src/components/movies/search-bar.tsx

@@ -41,14 +41,14 @@ export function SearchBar({ onSearch, isLoading }: SearchBarProps) {
   }
   }
 
 
   return (
   return (
-    <div className="relative">
+    <div className="relative mx-auto max-w-xl">
       <input
       <input
         type="search"
         type="search"
         value={value}
         value={value}
         onChange={handleChange}
         onChange={handleChange}
-        placeholder="Search for a movie..."
+        placeholder="ADD A MOVIE"
         aria-label="Search movies"
         aria-label="Search movies"
-        className="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 pr-20 text-sm text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
+        className="animate-pulse-glow w-full rounded-lg border border-gray-300 bg-white py-3 pl-20 pr-20 text-center text-base font-bold text-gray-900 placeholder:text-center placeholder:font-bold placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:py-4 sm:text-lg dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
       />
       />
       <div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3">
       <div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3">
         {isLoading && (
         {isLoading && (
@@ -58,7 +58,14 @@ export function SearchBar({ onSearch, isLoading }: SearchBarProps) {
             fill="none"
             fill="none"
             aria-hidden="true"
             aria-hidden="true"
           >
           >
-            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
             <path
             <path
               className="opacity-75"
               className="opacity-75"
               fill="currentColor"
               fill="currentColor"

+ 29 - 0
src/components/shared/legal-footer.tsx

@@ -0,0 +1,29 @@
+import Link from "next/link";
+
+export function LegalFooter() {
+  const year = new Date().getFullYear();
+  return (
+    <footer className="border-t border-foreground/10 px-4 py-6 text-xs text-foreground/50">
+      <div className="mx-auto flex max-w-3xl flex-col items-center gap-3 text-center">
+        <nav
+          aria-label="Legal"
+          className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2"
+        >
+          <Link href="/privacy" className="underline hover:text-foreground/70">
+            Privacy Policy
+          </Link>
+          <Link href="/privacy#ccpa" className="underline hover:text-foreground/70">
+            US residents: CCPA rights
+          </Link>
+          <Link href="/privacy#gdpr" className="underline hover:text-foreground/70">
+            EU/EEA residents: GDPR rights
+          </Link>
+        </nav>
+        <p className="max-w-md text-foreground/40">
+          This site uses essential cookies for authentication. See our Privacy Policy.
+        </p>
+        <p className="text-foreground/40">&copy; {year} MovieDice</p>
+      </div>
+    </footer>
+  );
+}

+ 25 - 28
src/components/shared/tmdb-footer.tsx

@@ -1,33 +1,30 @@
-import Link from "next/link";
+import { LegalFooter } from "@/components/shared/legal-footer";
 
 
 export function TMDBFooter() {
 export function TMDBFooter() {
   return (
   return (
-    <footer className="mt-auto border-t border-foreground/10 px-4 py-8">
-      <div className="mx-auto flex max-w-3xl flex-col items-center gap-4 text-center">
-        <a
-          href="https://www.themoviedb.org"
-          target="_blank"
-          rel="noopener noreferrer"
-          className="inline-block"
-        >
-          {/* eslint-disable-next-line @next/next/no-img-element */}
-          <img
-            src="https://www.themoviedb.org/assets/2/v4/logos/v2/blue_short-8e7b30f73a4020692ccca9c88bafe5dcb6f8a62a4c6bc55cd9ba82bb2cd95f6c.svg"
-            alt="TMDB logo"
-            loading="lazy"
-            className="h-5"
-          />
-        </a>
-        <p className="max-w-md text-xs text-foreground/50">
-          This product uses the TMDB API but is not endorsed or certified by TMDB.
-        </p>
-        <Link
-          href="/privacy"
-          className="text-xs text-foreground/40 underline hover:text-foreground/60"
-        >
-          Privacy Policy
-        </Link>
-      </div>
-    </footer>
+    <>
+      <footer className="mt-auto border-t border-foreground/10 px-4 py-8">
+        <div className="mx-auto flex max-w-3xl flex-col items-center gap-4 text-center">
+          <a
+            href="https://www.themoviedb.org"
+            target="_blank"
+            rel="noopener noreferrer"
+            className="inline-block"
+          >
+            {/* eslint-disable-next-line @next/next/no-img-element */}
+            <img
+              src="https://www.themoviedb.org/assets/2/v4/logos/v2/blue_short-8e7b30f73a4020692ccca9c88bafe5dcb6f8a62a4c6bc55cd9ba82bb2cd95f6c.svg"
+              alt="TMDB logo"
+              loading="lazy"
+              className="h-5"
+            />
+          </a>
+          <p className="max-w-md text-xs text-foreground/50">
+            This product uses the TMDB API but is not endorsed or certified by TMDB.
+          </p>
+        </div>
+      </footer>
+      <LegalFooter />
+    </>
   );
   );
 }
 }

+ 77 - 0
src/lib/tmdb/certification.ts

@@ -0,0 +1,77 @@
+import type { TMDBReleaseDatesResponse } from "@/types/tmdb";
+
+/**
+ * Per-country allowlist of certification codes considered PG-13-or-better.
+ * Keys are ISO 3166-1 alpha-2 country codes; values are TMDB certification strings.
+ *
+ * Sources: TMDB /certification/movie/list + national ratings boards.
+ * If a country isn't listed here, we don't trust its certifications either way —
+ * the STRICT walker in isMovieAllowedByCert requires at least one positive match.
+ */
+export const ALLOWED_CERTIFICATIONS: Record<string, string[]> = {
+  US: ["G", "PG", "PG-13"],
+  GB: ["U", "PG", "12", "12A"],
+  DE: ["0", "6", "12"],
+  FR: ["U", "10", "12"],
+  AU: ["G", "PG", "M"],
+  CA: ["G", "PG", "14A"],
+  NL: ["AL", "6", "9", "12"],
+  ES: ["A", "7", "12"],
+  IT: ["T", "6+", "12+"],
+  JP: ["G", "PG12"],
+  KR: ["ALL", "12"],
+  BR: ["L", "10", "12"],
+  MX: ["AA", "A", "B"],
+  IE: ["G", "PG", "12A"],
+  SE: ["Btl", "7", "11"],
+};
+
+/**
+ * Default discover/popular query params: ask TMDB to pre-filter to
+ * US-certified PG-13-or-better titles. We still post-filter individual
+ * detail responses for surfaces that can't use discover (e.g. /search/movie).
+ */
+export const DISCOVER_CERT_PARAMS: Record<string, string> = {
+  certification_country: "US",
+  "certification.lte": "PG-13",
+  include_adult: "false",
+};
+
+/**
+ * STRICT walker: a title is allowed iff some recognized country reports a
+ * non-empty certification AND every recognized-country certification we
+ * observe is in that country's allowlist. Unknown countries are ignored.
+ *
+ * Rationale: TMDB's release_dates payload often includes multiple countries.
+ * If, say, the US reports R but Germany reports 6, that title is R-rated in
+ * the only market we trust strongly — reject. We only accept when at least
+ * one recognized country says "PG-13-or-better" AND no recognized country
+ * contradicts that with a stricter rating.
+ */
+export function isMovieAllowedByCert(
+  releaseDates: TMDBReleaseDatesResponse | undefined | null,
+): boolean {
+  if (!releaseDates?.results?.length) return false;
+
+  let sawRecognizedCert = false;
+  let sawPositiveMatch = false;
+
+  for (const country of releaseDates.results) {
+    const allow = ALLOWED_CERTIFICATIONS[country.iso_3166_1];
+    if (!allow) continue;
+
+    for (const rd of country.release_dates ?? []) {
+      const cert = (rd.certification ?? "").trim();
+      if (!cert) continue;
+      sawRecognizedCert = true;
+      if (allow.includes(cert)) {
+        sawPositiveMatch = true;
+      } else {
+        // Recognized country reports a cert above our allowlist → reject.
+        return false;
+      }
+    }
+  }
+
+  return sawRecognizedCert && sawPositiveMatch;
+}

+ 40 - 5
src/lib/tmdb/client.ts

@@ -1,15 +1,13 @@
 import { z } from "zod";
 import { z } from "zod";
 import { TMDB_API_BASE_URL } from "@/lib/constants";
 import { TMDB_API_BASE_URL } from "@/lib/constants";
-import type { TMDBSearchResponse } from "@/types/tmdb";
+import { isMovieAllowedByCert } from "@/lib/tmdb/certification";
+import type { TMDBMovieDetails, TMDBSearchResponse } from "@/types/tmdb";
 
 
 export const movieIdSchema = z.object({
 export const movieIdSchema = z.object({
   id: z.coerce.number().int().positive(),
   id: z.coerce.number().int().positive(),
 });
 });
 
 
-export async function tmdbFetch<T>(
-  path: string,
-  params?: Record<string, string>,
-): Promise<T> {
+export async function tmdbFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
   const apiKey = process.env.TMDB_API_KEY;
   const apiKey = process.env.TMDB_API_KEY;
   if (!apiKey) {
   if (!apiKey) {
     throw new Error("TMDB_API_KEY is not configured");
     throw new Error("TMDB_API_KEY is not configured");
@@ -44,3 +42,40 @@ export function filterAdultMovies(data: TMDBSearchResponse): TMDBSearchResponse
     results: data.results.filter((movie) => !movie.adult),
     results: data.results.filter((movie) => !movie.adult),
   };
   };
 }
 }
+
+/**
+ * Batched detail fetch with append_to_response=release_dates, returning the
+ * SET of IDs whose certifications pass isMovieAllowedByCert. Concurrency
+ * capped at 6 so we don't blow TMDB's 50 req/sec budget under load.
+ *
+ * Failures for individual IDs are treated as "not allowed" (omit from set)
+ * rather than throwing — callers want belt-and-suspenders, not a 502 cascade.
+ */
+export async function fetchAndFilterByCert(movieIds: number[]): Promise<Set<number>> {
+  const allowed = new Set<number>();
+  if (movieIds.length === 0) return allowed;
+
+  const CONCURRENCY = 6;
+  let cursor = 0;
+
+  async function worker(): Promise<void> {
+    while (true) {
+      const i = cursor++;
+      if (i >= movieIds.length) return;
+      const id = movieIds[i];
+      try {
+        const details = await tmdbFetch<TMDBMovieDetails>(`/movie/${id}`, {
+          append_to_response: "release_dates",
+        });
+        if (!details.adult && isMovieAllowedByCert(details.release_dates)) {
+          allowed.add(id);
+        }
+      } catch {
+        // Treat fetch failures as not-allowed; drop silently.
+      }
+    }
+  }
+
+  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, movieIds.length) }, () => worker()));
+  return allowed;
+}

+ 18 - 0
src/types/tmdb.ts

@@ -17,6 +17,24 @@ export interface TMDBMovieDetails extends Omit<TMDBMovie, "genre_ids"> {
   runtime: number | null;
   runtime: number | null;
   tagline: string | null;
   tagline: string | null;
   imdb_id: string | null;
   imdb_id: string | null;
+  release_dates?: TMDBReleaseDatesResponse;
+}
+
+export interface TMDBReleaseDate {
+  certification: string;
+  iso_639_1: string;
+  note: string;
+  release_date: string;
+  type: number;
+}
+
+export interface TMDBReleaseDatesCountry {
+  iso_3166_1: string;
+  release_dates: TMDBReleaseDate[];
+}
+
+export interface TMDBReleaseDatesResponse {
+  results: TMDBReleaseDatesCountry[];
 }
 }
 
 
 export interface TMDBSearchResponse {
 export interface TMDBSearchResponse {

+ 83 - 0
supabase/kong/kong.yml

@@ -31,6 +31,89 @@ services:
     plugins:
     plugins:
       - name: cors
       - name: cors
 
 
+  # Denylist for disabled GoTrue auth methods. These routes must come BEFORE
+  # the catch-all /auth/v1/ route so Kong matches them first and short-circuits
+  # with request-termination. MovieDice only uses anonymous sign-in + our own
+  # minted JWTs for recovery; magiclink, password recovery, OTP, resend, and
+  # SSO surfaces are unreachable by design.
+  - name: auth-v1-denied-magiclink
+    url: http://supabase-auth:9999
+    routes:
+      - name: auth-v1-denied-magiclink
+        strip_path: true
+        paths:
+          - /auth/v1/magiclink
+    plugins:
+      - name: request-termination
+        config:
+          status_code: 404
+          message: "Not Found"
+
+  - name: auth-v1-denied-recover
+    url: http://supabase-auth:9999
+    routes:
+      - name: auth-v1-denied-recover
+        strip_path: true
+        paths:
+          - /auth/v1/recover
+    plugins:
+      - name: request-termination
+        config:
+          status_code: 404
+          message: "Not Found"
+
+  - name: auth-v1-denied-otp
+    url: http://supabase-auth:9999
+    routes:
+      - name: auth-v1-denied-otp
+        strip_path: true
+        paths:
+          - /auth/v1/otp
+    plugins:
+      - name: request-termination
+        config:
+          status_code: 404
+          message: "Not Found"
+
+  - name: auth-v1-denied-resend
+    url: http://supabase-auth:9999
+    routes:
+      - name: auth-v1-denied-resend
+        strip_path: true
+        paths:
+          - /auth/v1/resend
+    plugins:
+      - name: request-termination
+        config:
+          status_code: 404
+          message: "Not Found"
+
+  - name: auth-v1-denied-sso
+    url: http://supabase-auth:9999
+    routes:
+      - name: auth-v1-denied-sso
+        strip_path: true
+        paths:
+          - /auth/v1/sso
+    plugins:
+      - name: request-termination
+        config:
+          status_code: 404
+          message: "Not Found"
+
+  - name: auth-v1-denied-sso-saml
+    url: http://supabase-auth:9999
+    routes:
+      - name: auth-v1-denied-sso-saml
+        strip_path: true
+        paths:
+          - /auth/v1/sso/saml
+    plugins:
+      - name: request-termination
+        config:
+          status_code: 404
+          message: "Not Found"
+
   - name: auth-v1
   - name: auth-v1
     _comment: "GoTrue: /auth/v1/* -> http://supabase-auth:9999/*"
     _comment: "GoTrue: /auth/v1/* -> http://supabase-auth:9999/*"
     url: http://supabase-auth:9999
     url: http://supabase-auth:9999

+ 17 - 0
supabase/migrations/00007_landing_reel_posters_cert_backfill.sql

@@ -0,0 +1,17 @@
+-- Clear cached reel posters that pre-date the 2026-05-21 PG-13 cert filter.
+--
+-- An older cron populated landing_reel_posters without applying the
+-- isMovieAllowedByCert filter, so the table may contain titles that would
+-- be rejected by the current policy. We can't cert-check from inside SQL
+-- (the data lives at TMDB), so the safe path is to truncate and let the
+-- bi-weekly cron repopulate with cert-filtered titles on its next run.
+--
+-- The route layer (src/app/api/tmdb/reel-posters/route.ts) falls back to
+-- a live TMDB /discover call (also cert-filtered) when the table is empty,
+-- so this does not cause a user-visible outage.
+--
+-- PINNED_REEL_POSTERS is not stored in this table — it's an in-code
+-- constant prepended at the route layer — so the truncate does not affect
+-- the editorial pins.
+
+TRUNCATE TABLE public.landing_reel_posters;

Some files were not shown because too many files changed in this diff