Переглянути джерело

Update PROJECT_SCOPE.md with accepted audit findings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User 2 місяців тому
батько
коміт
a4b9e18cca
1 змінених файлів з 191 додано та 98 видалено
  1. 191 98
      PROJECT_SCOPE.md

+ 191 - 98
PROJECT_SCOPE.md

@@ -29,28 +29,28 @@ MovieDice solves group decision paralysis by combining collaborative curation (e
 
 | Feature | Description | Priority |
 |---------|-------------|----------|
-| Landing page | Centered logo, splash text, slot-machine reel animation on Roll the Dice (3 reels spinning through ~20 automatically fetched posters from TMDB popular/top-rated, replaced on each periodic refresh), Genre Roll against TMDB (no login required), Login button, scrolling About section, 3-step how-it-works demo with alternating left-right-left alignment | Must Have |
-| Anonymous auth with display name | User picks a display name and optional avatar color; a UUID-based account is created and persisted on device | Must Have |
-| Recovery code | A longer alphanumeric code shown once after account creation that lets users reclaim their identity on a new device | Must Have |
-| Group creation with invite code | Creator gets a short human-readable code (e.g., WOLF-42) to share; creator becomes List Admin | Must Have |
-| Group join via invite code | Enter code to join a group and access its shared list; regular member role assigned | Must Have |
+| Landing page | Centered logo, splash text, slot-machine reel animation on Roll the Dice (3 reels spinning through ~20 automatically fetched posters from TMDB popular/top-rated, replaced on each periodic refresh), Genre Roll against TMDB (no login required), Login button, scrolling About section, 3-step how-it-works demo with alternating left-right-left alignment, TMDB attribution footer, privacy policy link | Must Have |
+| Anonymous auth via Supabase | User picks a display name and optional avatar color; account created via `supabase.auth.signInAnonymously()` which issues a JWT for RLS-compatible sessions; persisted on device | Must Have |
+| Recovery code | A 24-character alphanumeric code (128-bit entropy) shown once after account creation that lets users reclaim their identity on a new device; hashed with Argon2id before storage; single-use (invalidated after successful claim); claim endpoint rate-limited (5 failed attempts per IP per 15-minute window) | Must Have |
+| Group creation with invite code | Creator gets a short human-readable code in WORD-WORD format (e.g., WOLF-MOON) to share; creator becomes List Admin | Must Have |
+| Group join via invite code | Enter code to join a group and access its shared list; regular member role assigned; join endpoint rate-limited (5-10 failed attempts per IP per window) | Must Have |
 | List Admin permissions | Creator can rename the list, initiate list deletion or ownership transfer (on self-removal), remove members, and regenerate the invite code | Must Have |
 | Regular user permissions | Members can add/remove movies, mark movies as watched, and leave the list | Must Have |
-| Movie search (TMDB integration) | Search bar queries TMDB with debounce (~300ms); results show below a separator from in-list results | Must Have |
+| Movie search (TMDB integration) | Search bar queries TMDB via server-side API proxy (`/api/tmdb/*`) with debounce (~300ms); results show below a separator from in-list results | Must Have |
 | Add/remove movie | Tap a TMDB result to add it; poster, genres, title, year, and trailer URL auto-populate (trailer URL stored in DB and refreshed periodically); added-by attribution stored | Must Have |
-| Poster-forward grid view | 2-column evenly-scaling grid; each card shows movie poster (full bleed) with title below; added-by avatar overlaid top-right; binoculars emoji overlaid top-left when watched; infinite scroll loading 12 movies initially | Must Have |
+| Poster-forward grid view | 2-column evenly-scaling grid; each card shows movie poster (full bleed, using TMDB native sized URLs) with title below; added-by avatar overlaid top-right; binoculars emoji overlaid top-left when watched; infinite scroll loading 12 movies initially | Must Have |
 | Expanded movie card (inline panel) | Tapping a poster expands a full-page-width panel downward, inserted below that row in the grid — not a modal or popup. Panel order (top to bottom): full-size poster → title → "Added by [username]" → genre tags → Watched It + Trailer (side by side) → Delete (centered below). Delete uses two-tap shake-and-confirm. Watched It toggles watched state. Trailer opens in new tab. Panel collapses on tap outside. | Must Have |
 | Genre filter | Tapping a genre tag in the expanded panel filters the grid to that genre | Must Have |
 | Roll the Dice | Large button pinned above the list; triggers an animated randomizer that lands on one unwatched movie from the group list | Must Have |
 | Re-roll | Tapping Roll again re-rolls from the same eligible pool | Must Have |
 | Genre + Emotion Roll | Secondary button accepting comma-separated genres and/or emotion keywords; maps emotions to genre IDs, filters pool, then rolls | Must Have |
 | Watched state (per group, toggle) | Marking a movie watched moves it to a collapsed "Watched" section; marking again moves it back. Binoculars overlay and button color update in real time across all members. | Must Have |
-| Real-time list sync | Add, remove, and watched-status changes appear live on all connected group members' screens (Supabase real-time) | Must Have |
+| Real-time list sync | Add, remove, and watched-status changes appear live on all connected group members' screens (Supabase real-time); subscribe only to the currently-viewed list, unsubscribe on navigation away | Must Have |
 | Logged-in home page | Upon login or return visit (stored user ID detected), user lands on a home page that mirrors the landing page layout but shows their lists as cards and replaces Login with Create List; Roll the Dice and Genre Roll roll across all user lists combined and display the result as a standalone teaser card on the home page (no navigation into a specific list) | Must Have |
 | Multi-group support | A user can belong to more than one group; all their lists appear as cards on the home page | Should Have |
 | Invite code rotation | List Admin can regenerate the invite code to revoke access for anyone with the old code | Should Have |
-| Trailer URL periodic refresh | Background job on a bi-weekly cadence refreshes stored trailer URLs only for movies where trailer_url is currently null. Note: this behavior should be reassessed post-launch to also refresh stale URLs after a certain age. | Should Have |
-| Master Admin panel | Site-owner-only admin page with TOTP 2FA; can search and delete any list or user; credentials set via environment variables | Must Have |
+| Trailer URL periodic refresh | Background job via Supabase pg_cron on a bi-weekly cadence refreshes stored trailer URLs only for movies where trailer_url is currently null. Note: this behavior should be reassessed post-launch to also refresh stale URLs after a certain age. | Should Have |
+| Master Admin panel | Site-owner-only admin page with TOTP 2FA; can search and delete any list or user; credentials set via environment variables; session managed via iron-session (HttpOnly, Secure, SameSite=Strict cookie, 8-hour expiry) | Must Have |
 
 ### Out of Scope (Future)
 
@@ -87,50 +87,55 @@ MovieDice solves group decision paralysis by combining collaborative curation (e
       - Step 1: left-aligned
       - Step 2: right-aligned
       - Step 3: left-aligned (zigzag visual rhythm)
+8. Footer on all pages: TMDB attribution (logo + link + disclaimer), privacy policy link
 ```
 
 ### Onboarding (New User)
 ```
 1. User taps "Login / Get Started" on landing page
 2. User enters display name, optionally picks an avatar color
-3. UUID-based account created; persisted in local storage
-4. Recovery code shown once — user prompted to save it
+3. Account created via Supabase Anonymous Sign-In (supabase.auth.signInAnonymously());
+   JWT issued and managed by Supabase GoTrue; session persisted automatically
+4. Recovery code (24 alphanumeric characters, 128-bit entropy) shown once — user prompted to save it
 5. User selects: "Create a Group" or "Join with a Code"
-   A. Create → enter group name → group created, invite code shown (e.g., WOLF-42)
+   A. Create → enter group name → group created, invite code shown (e.g., WOLF-MOON)
       → creator assigned List Admin role → lands on home page (with their new list card shown)
-   B. Join → enter invite code → validated → member role assigned → lands on home page
+   B. Join → enter invite code → validated (rate-limited) → member role assigned → lands on home page
          (with the joined list card shown)
 ```
 
 ### Logged-In Home Page (Returning or Newly Onboarded User)
 ```
-1. App reads stored user ID from browser (local storage or cookie)
-2. If user ID found → navigate directly to the home page (skip landing page)
-3. If user ID not found → show landing page (pre-login)
+1. App checks for valid Supabase Auth session
+2. If valid session found → navigate directly to the home page (skip landing page)
+3. If no session → show landing page (pre-login)
 4. Home page layout mirrors the landing page layout, with these differences:
    a. The Login button is replaced by a "Create List" button
    b. The About / how-it-works section is replaced by the user's list cards
 5. Each list card shows:
    - List name (left-aligned)
-   - Number of movies with a 🎬 film emoji (right-aligned)
+   - Number of movies with a film emoji (right-aligned)
    - Below: "Created by: [username]"
 6. Tapping a list card navigates into that list view
 7. "Roll the Dice" and "Genre Roll" buttons at the top roll across ALL unwatched movies
    from ALL of the user's lists combined (cross-list roll)
 8. The roll result is displayed as a standalone teaser card directly on the home page.
    The result does NOT navigate the user into any specific list.
-9. If user ID not found (new device) → prompt for recovery code or start fresh
+9. If no session (new device) → prompt for recovery code or start fresh
 ```
 
 ### Adding a Movie
 ```
 1. User taps search bar at top of list view
-2. User types a movie title; TMDB is queried with ~300ms debounce
+2. User types a movie title; TMDB is queried via server-side proxy (/api/tmdb/search)
+   with ~300ms debounce
 3. Results appear in two sections:
    - Top: movies already in the group's list (labeled "In Your List")
    - Below separator: TMDB search results
 4. User taps a TMDB result → movie inserted into DB with poster, genres, title, year,
-   trailer URL (fetched from TMDB and stored at add-time), and added-by attribution
+   trailer URL (fetched from TMDB via server proxy and stored at add-time;
+   validated against allowlist: youtube.com, themoviedb.org, imdb.com),
+   and added-by attribution
 5. All group members see the new movie appear in real time
 ```
 
@@ -138,12 +143,13 @@ MovieDice solves group decision paralysis by combining collaborative curation (e
 ```
 1. Default view: 2-column evenly-scaling poster grid (3-4 columns on tablet/desktop)
 2. Each card shows:
-   - Movie poster (full bleed)
+   - Movie poster (full bleed, using TMDB native sized URL: w342 for mobile grid)
    - Movie title below the poster
    - Added-by user avatar overlaid top-right corner
    - Binoculars emoji overlaid top-left corner — only when movie is watched
 3. Grid loads 12 movies initially; additional movies load automatically on scroll to bottom
 4. No action buttons on collapsed grid cards — cards are tap-only
+5. All poster images use native loading="lazy" attribute
 ```
 
 ### Expanded Movie Card (Inline Panel)
@@ -152,7 +158,7 @@ MovieDice solves group decision paralysis by combining collaborative curation (e
 2. A full-page-width panel expands downward, inserted inline below that row in the grid
    (mirrors Google Image Search inline expansion — not a modal, popup, or slide-up sheet)
 3. Panel contents, top to bottom:
-   a. Full-size movie poster
+   a. Full-size movie poster (TMDB native sized URL: w500)
    b. Movie title
    c. "Added by [username]"
    d. Genre tags — tappable; each filters the grid to that genre
@@ -167,7 +173,7 @@ MovieDice solves group decision paralysis by combining collaborative curation (e
      poster (top-left), movie moves to "Watched" section
    - If watched: button color reverts, binoculars overlay removed, movie returns to main list
    - Both indicators update simultaneously on all group members' screens
-6. Trailer: opens stored trailer URL in a new tab
+6. Trailer: opens stored trailer URL in a new tab (with rel="noopener noreferrer")
 7. Tapping outside the panel or a close affordance collapses it
 ```
 
@@ -176,6 +182,7 @@ MovieDice solves group decision paralysis by combining collaborative curation (e
 1. User taps "Roll the Dice!" (pinned above the movie grid or on the home page)
 2. Animated randomizer plays — scatter/flip/spin elimination sequence, 2-3 seconds
    (this is distinct from the landing page slot-machine reel animation)
+   Respects prefers-reduced-motion: if enabled, use a simple fade-in on the winner instead
 3. Animation settles on one random unwatched movie from the eligible pool
    - On a list page: pool is that list's unwatched movies
    - On the home page: pool is all unwatched movies across all of the user's lists combined
@@ -226,12 +233,14 @@ List Deletion flow (separate from self-removal):
 1. Master Admin navigates to /admin
 2. Login prompt: username + TOTP authenticator code (no password-only fallback)
 3. Credentials (username and TOTP secret) are set via environment variables — no first-run UI
-4. On successful auth → Master Admin dashboard
+4. On successful auth → iron-session issues encrypted HttpOnly cookie (8-hour expiry)
+   → Master Admin dashboard
 5. Available tools:
    - Search any list by name or ID → view details → delete (with confirmation)
    - Search any user by display name or ID → view details → delete (with confirmation)
 6. Master Admin session is separate from regular user sessions
 7. All /admin routes redirect to login if no valid admin session
+8. TOTP secret rotation requires redeployment (documented operational constraint)
 ```
 
 ## 6. Usability Concerns
@@ -239,13 +248,15 @@ List Deletion flow (separate from self-removal):
 - **Mobile-first**: All layouts designed for 375px+ screens first; desktop is a bonus. Grid expands to 3-4 columns on tablet/desktop.
 - **PWA**: App should be installable to home screen and behave like a native app in browser.
 - **Inline expansion**: The expanded movie panel is a full-page-width inline expansion inserted below the tapped card's row — never a modal, popup, slide-up sheet, or centered dialogue. Mirrors Google Image Search inline expansion behavior.
+- **Inline panel keyboard navigation**: Panel must be operable via keyboard: Enter to open, Escape to close, focus moves into panel on open and returns to trigger on close. Use `aria-expanded` on trigger and `role="region"` on panel.
 - **Two distinct roll animations**: The landing page uses a slot-machine reel animation (3 spinning reels of poster images). The in-app roll uses a scatter/flip/spin elimination animation. These must feel visually distinct — the landing animation is attention-grabbing and cinematic; the in-app animation is fast and satisfying but not distracting from repeat use.
+- **Reduced motion**: Both animations must respect `prefers-reduced-motion`. Landing page reel: instant reveal or simple fade-in. In-app roll: fade to winner instead of scatter animation. Full animation remains the default.
 - **Roll animation (in-app)**: Target 2-3 seconds max — visually impactful but not so long it frustrates repeat use.
 - **Search responsiveness**: Debounce TMDB queries at ~300ms; show loading state during fetch.
 - **Real-time updates**: New movies and watched status changes appear without a page refresh via Supabase subscriptions.
-- **Offline tolerance**: If connection drops, the app should degrade gracefully — show cached list, disable write actions with an explanatory message.
+- **Offline tolerance**: If connection drops, the app should degrade gracefully — show cached list (via TanStack Query persistence), disable write actions with an explanatory message. Do not queue offline writes.
 - **Accessibility**: Sufficient color contrast on poster overlays; tap targets minimum 44x44px; screen reader labels on icon buttons.
-- **TMDB API**: Cache recent search results client-side to reduce API calls and stay within free tier rate limits.
+- **TMDB API**: All TMDB calls routed through server-side proxy (`/api/tmdb/*`). Cache recent search results via TanStack Query with explicit `staleTime` configuration.
 
 ## 7. Technical Considerations
 
@@ -253,28 +264,55 @@ List Deletion flow (separate from self-removal):
 
 | Layer | Choice | Notes |
 |-------|--------|-------|
-| Frontend | Next.js (React, App Router) | PWA support, Vercel deployment |
+| Frontend | Next.js (React, App Router) | PWA support; `output: 'standalone'` for Docker |
 | Styling | Tailwind CSS | Mobile-first, fast iteration |
-| Backend / Database | Supabase | Postgres + real-time subscriptions |
-| Movie Data | TMDB API (free tier) | Posters, genres, metadata, trailer URLs |
-| State Management | TanStack Query (React Query) | Server state sync, caching, loading states |
+| Backend / Database | Supabase (self-hosted) | Postgres + real-time subscriptions + GoTrue auth; full Docker stack |
+| Auth | Supabase Anonymous Sign-In | `signInAnonymously()` — no email, instant account, JWT for RLS |
+| Movie Data | TMDB API | Posters, genres, metadata, trailer URLs; all calls via server-side proxy |
+| State Management | TanStack Query (React Query) | Server state sync, caching with explicit `staleTime`, loading states |
+| Admin Sessions | iron-session | Encrypted HttpOnly cookie for Master Admin TOTP sessions |
 | 2FA (Master Admin) | TOTP via otplib (or equivalent) | Authenticator-app compatible; TOTP secret never exposed client-side |
-| Deployment | Vercel (frontend) + Supabase (hosted) | Both free tiers sufficient for MVP scale |
+| PWA | @serwist/next | App Router compatible, Workbox 7, actively maintained |
+| Image Optimization | sharp (local assets only) | TMDB posters use native sized URLs from TMDB CDN directly (not next/image) |
+| Background Jobs | Supabase pg_cron + Edge Functions | Runs on self-hosted Postgres; replaces Vercel Cron |
+| Reverse Proxy | Caddy | HTTPS termination (required for PWA, wss://, secure cookies) |
+| Linting / Formatting | ESLint + Prettier + TypeScript strict | next/core-web-vitals + next/typescript presets; husky + lint-staged pre-commit |
+| Testing | Vitest (unit) + Playwright (E2E) | Unit tests for pure logic; E2E for critical paths |
+| Env Validation | zod (or t3-env) | Validate all required env vars at startup |
+| Runtime | Node.js 22 LTS | node:22-slim Docker base image |
+
+### TMDB Image Strategy
+
+TMDB posters are served directly from TMDB's CDN using native sized URLs — not processed through `next/image` optimization (which would run `sharp` in-container and create CPU/memory pressure in Docker). Use these TMDB size variants:
+- `w342` — grid thumbnails (mobile)
+- `w185` — reel animation posters
+- `w500` — expanded inline panel
+- Add `loading="lazy"` attribute on all poster `<img>` tags
+- Reserve `next/image` for locally-served assets only (logo, icons)
+- Install `sharp` as an explicit production dependency for local asset optimization
+
+### TMDB Attribution
+
+TMDB Terms of Service require visible attribution on every page: the TMDB logo, a link to themoviedb.org, and the disclaimer "This product uses the TMDB API but is not endorsed or certified by TMDB." Implement as a site-wide footer component. Non-compliance risks API key revocation.
+
+### TMDB API Proxy
+
+All TMDB API calls must be routed through Next.js API Route Handlers (`/api/tmdb/*`). The `TMDB_API_KEY` environment variable must NEVER use the `NEXT_PUBLIC_` prefix — it must remain server-side only. This is a TMDB Terms of Service requirement. The proxy also enables server-side response caching via `Cache-Control` headers.
 
 ### Data Model
 
 **users**
-- id (UUID, primary key)
-- display_name (text)
+- id (UUID, primary key — maps to Supabase Auth UID from `signInAnonymously()`)
+- display_name (text, CHECK: 1-30 characters, no HTML angle brackets or control characters, Unicode letters allowed)
 - avatar_color (text, hex)
-- recovery_code (text, hashed)
-- is_master_admin (boolean, default false)
+- recovery_code (text, hashed with Argon2id; 24 alphanumeric characters / 128-bit entropy; single-use)
+- last_active_at (timestamp — updated on login/activity; used for 12-month retention policy)
 - created_at
 
 **groups**
 - id (UUID, primary key)
-- name (text)
-- invite_code (text, unique, short human-readable)
+- name (text, CHECK: 1-50 characters, no HTML angle brackets or control characters, Unicode letters allowed)
+- invite_code (text, unique, WORD-WORD human-readable format)
 - created_by (FK → users.id)
 - created_at
 
@@ -290,9 +328,9 @@ List Deletion flow (separate from self-removal):
 - tmdb_id (integer)
 - title (text)
 - year (integer)
-- poster_path (text, TMDB relative path — full URL constructed at render time)
+- poster_path (text, TMDB relative path — full URL constructed at render time using TMDB native sizes)
 - genres (text[], TMDB genre labels)
-- trailer_url (text, nullable — fetched from TMDB at add-time and stored; background job refreshes only null entries on a bi-weekly cadence)
+- trailer_url (text, nullable — fetched from TMDB at add-time via server proxy and stored; validated against domain allowlist: youtube.com, themoviedb.org, imdb.com; background job refreshes only null entries on a bi-weekly cadence)
 - trailer_url_refreshed_at (timestamp — tracks when the trailer URL was last fetched, used by the refresh job)
 - added_by (FK → users.id)
 - watched (boolean, default false)
@@ -304,30 +342,67 @@ List Deletion flow (separate from self-removal):
 - tmdb_id (integer)
 - poster_path (text)
 - title (text)
-- refreshed_at (timestamp — set by the periodic reel refresh job)
+- refreshed_at (timestamp — set by the periodic reel refresh job via pg_cron)
 - (Table holds ~20 rows; entire set replaced on each refresh from TMDB popular/top-rated)
 
 **admin_sessions**
-- Secure server-side token store for Master Admin TOTP-authenticated sessions; separate from regular user sessions
+- Managed via iron-session encrypted HttpOnly cookies; no database table required. Session expiry: 8 hours. Cookies set with Secure and SameSite=Strict flags.
+
+### Row Level Security (RLS)
+
+RLS must be enabled on ALL tables with explicit policies — no permissive catch-all. Supabase's anon key is public by design; RLS is the authorization mechanism. Policies use `auth.uid()` from the JWT issued by Supabase Anonymous Sign-In. Define policies alongside the schema in Phase 1.2. Key rules:
+- **users**: Users can read/update only their own row
+- **groups**: Readable by members of the group only
+- **group_members**: Readable by members of the same group; insertable via valid invite code flow; deletable by admins or self (leave)
+- **movies**: Full CRUD for members of the owning group only
+- **landing_reel_posters**: Readable by anyone (public); writable only by service role (pg_cron job)
+
+Supabase Realtime also respects RLS — subscriptions are authorized by the same policies.
+
+### Database Migrations
+
+Use `supabase migration new` via the Supabase CLI. All migrations stored in version control. Migrations must be the sole mechanism for schema changes — no ad-hoc SQL in production.
 
 ### Privacy
 - No email addresses collected in MVP
 - Display names only — no real identity data
-- Recovery codes hashed before storage
-- Invite codes are the sole access control mechanism for regular users
+- Recovery codes hashed with Argon2id before storage
+- Invite codes are the primary access control mechanism for regular users; join endpoint rate-limited
 - Master Admin credentials (username) and TOTP secret are stored as environment variables server-side only; never exposed client-side
 - TMDB data is public — no privacy concern
+- Privacy policy page required: factual description of what data is stored (anonymous UUID, display name, group membership, movie preferences, server logs with IPs), how it is used, and user rights
+- **Data retention**: Inactive accounts (no activity for 12 months) are automatically deleted. Users cannot be notified before deletion due to anonymous auth (no email). This is documented in the privacy policy.
+- TMDB attribution displayed on all pages per Terms of Service
+
+### Security Headers
+
+Configure HTTP security headers in `next.config.js` or at the Caddy reverse proxy level:
+- `Content-Security-Policy` — restrict `img-src` to `image.tmdb.org`, `connect-src` to Supabase `wss://`, etc.
+- `X-Frame-Options: DENY`
+- `X-Content-Type-Options: nosniff`
+- `Referrer-Policy: strict-origin-when-cross-origin`
+- `Strict-Transport-Security` (HSTS)
+- `Permissions-Policy`
+- Use `Content-Security-Policy-Report-Only` during development to identify violations without blocking
 
 ### Deployment
-- Vercel auto-deploys from main branch
-- Supabase on free tier; upgrade path available if scale demands
+- **Self-hosted Docker deployment** orchestrated via docker-compose
+- **Next.js app container**: multi-stage Dockerfile with `node:22-slim`, `output: 'standalone'`, non-root user, `tini` for PID 1 signal handling, `.dockerignore`
+- **Supabase self-hosted**: full Docker stack (Postgres, GoTrue, Realtime, PostgREST, Kong, Studio) using Supabase's official docker-compose configuration adapted for this project
+- **Reverse proxy**: Caddy for HTTPS termination (required for PWA service workers, `wss://` Supabase Realtime, and secure cookies)
+- **Health check**: `/api/health` endpoint checking Supabase connectivity; used by Docker `HEALTHCHECK`
+- **Background jobs**: Supabase pg_cron triggering Edge Functions (local Deno runtime in Docker) for landing reel refresh and trailer URL refresh
 - Environment variables required:
-  - `TMDB_API_KEY`
+  - `TMDB_API_KEY` (server-side only — never `NEXT_PUBLIC_`)
   - `SUPABASE_URL`
   - `SUPABASE_ANON_KEY`
+  - `SUPABASE_SERVICE_ROLE_KEY` (for server-side admin operations)
   - `MASTER_ADMIN_USERNAME` — the master admin login username
   - `MASTER_ADMIN_TOTP_SECRET` — the TOTP secret (base32); configure this in your authenticator app (e.g., Google Authenticator, Authy) before first use
+  - `IRON_SESSION_SECRET` — 32+ character secret for iron-session cookie encryption
+- All environment variables validated at startup via zod (or t3-env); missing or malformed variables produce a clear startup failure
 - No first-run setup UI exists; the master admin account is fully configured via environment variables before deployment
+- **CI**: Linting, type-checking, and build validation enforced via husky pre-push hook (no external CI platform required)
 
 ## 8. Implementation Plan
 
@@ -336,64 +411,66 @@ List Deletion flow (separate from self-removal):
 
 ---
 
-### Phase 1: Foundation (April 5-9, 2026) — MVP
-- [ ] 1.1 — Initialize Next.js project with Tailwind CSS and App Router
-- [ ] 1.2 — Set up Supabase project; create schema (users, groups, group_members, movies, landing_reel_posters tables)
-- [ ] 1.3 — Configure Supabase client in Next.js with environment variables
-- [ ] 1.4 — Implement anonymous user creation: display name input, UUID generation, local storage persistence
-- [ ] 1.5 — Implement recovery code generation and show-once display screen
-- [ ] 1.6 — Implement recovery code claim flow (enter code → restore identity on new device)
-- [ ] 1.7 — Deploy skeleton to Vercel; confirm Supabase connection works in production
-
-### Phase 2: Groups and Permissions (April 9-13, 2026) — MVP
-- [ ] 2.1 — Build "Create a Group" flow: name input, invite code generation (short human-readable format, e.g., WOLF-42), store in DB, assign creator as List Admin
-- [ ] 2.2 — Build "Join with a Code" flow: code entry, validation, group_members record with role: 'member'
-- [ ] 2.3 — Build logged-in home page: mirrors landing page layout; replaces Login with "Create List" button; shows user's list cards (list name left, movie count + 🎬 right, "Created by: [username]" below); tapping a card navigates to that list; Roll the Dice and Genre Roll on the home page roll across all lists combined and display the result as a standalone teaser card on the home page (no navigation into a specific list)
+### Phase 1: Foundation (April 6-10, 2026) — MVP
+- [ ] 1.1 — Initialize Next.js project with Tailwind CSS, App Router, and `output: 'standalone'` in `next.config.ts`; configure TypeScript strict mode, ESLint (`next/core-web-vitals` + `next/typescript`), Prettier; set up husky + lint-staged (pre-commit: lint/format, pre-push: lint + typecheck + build); install sharp as production dependency; add Vitest for unit testing
+- [ ] 1.2 — Set up self-hosted Supabase Docker stack; create schema (users, groups, group_members, movies, landing_reel_posters tables) with CHECK constraints on display_name and group name; define and enable RLS policies on all tables (see RLS section); initialize Supabase CLI migrations workflow — all schema changes via `supabase migration new`
+- [ ] 1.3 — Configure Supabase client in Next.js with environment variables; implement env var validation at startup via zod (or t3-env); create TMDB API proxy route (`/api/tmdb/*`) to keep API key server-side
+- [ ] 1.4 — Implement anonymous auth via `supabase.auth.signInAnonymously()`; display name input and optional avatar color picker; session managed by Supabase GoTrue (JWT issued automatically)
+- [ ] 1.5 — Implement recovery code generation (24 alphanumeric characters, 128-bit entropy), Argon2id hashing, and show-once display screen
+- [ ] 1.6 — Implement recovery code claim flow (enter code → verify against Argon2id hash → restore identity on new device); rate-limit claim endpoint (5 failed attempts per IP per 15-minute window); invalidate code after successful claim
+- [ ] 1.7 — Build Docker infrastructure: multi-stage Dockerfile (node:22-slim, non-root user, tini), docker-compose.yml orchestrating Next.js app + self-hosted Supabase stack + Caddy reverse proxy, .dockerignore, /api/health endpoint; deploy and confirm Supabase connection works in production
+- [ ] 1.8 — Add Sentry error monitoring (free tier) for production error tracking from day one
+
+### Phase 2: Groups and Permissions (April 10-14, 2026) — MVP
+- [ ] 2.1 — Build "Create a Group" flow: name input (validated: 1-50 chars), invite code generation (WORD-WORD format), store in DB, assign creator as List Admin; document API routes in markdown
+- [ ] 2.2 — Build "Join with a Code" flow: code entry, validation, group_members record with role: 'member'; rate-limit join endpoint (5-10 failed attempts per IP per window); document API routes in markdown
+- [ ] 2.3 — Build logged-in home page: mirrors landing page layout; replaces Login with "Create List" button; shows user's list cards (list name left, movie count + film emoji right, "Created by: [username]" below); tapping a card navigates to that list; Roll the Dice and Genre Roll on the home page roll across all lists combined and display the result as a standalone teaser card on the home page (no navigation into a specific list); use polling (not real-time subscriptions) for home page movie counts
 - [ ] 2.4 — Implement List Admin settings: rename list, regenerate invite code, display + copy invite code, view + remove members; implement "Leave this list" self-removal for admin — if other members exist, show "Transfer Ownership" popup (member list + Cancel button); on confirm, transfer ownership and remove original admin from the list; if admin is last member, delete the list with confirmation
 - [ ] 2.5 — Implement List Admin direct deletion: "Delete the list" action in settings shows a standard confirmation prompt and permanently deletes the list; does NOT trigger the Transfer Ownership popup
 - [ ] 2.6 — Implement regular user settings: leave list option only
 
-### Phase 3: Movie List Core (April 13-19, 2026) — MVP
-- [ ] 3.1 — Integrate TMDB API: search endpoint, poster URL construction, trailer URL fetch at add-time; implement client-side result caching to stay within free tier rate limits
+### Phase 3: Movie List Core (April 14-20, 2026) — MVP
+- [ ] 3.1 — Integrate TMDB API via server-side proxy (`/api/tmdb/*`): search endpoint, poster URL construction using TMDB native sizes (w342 grid, w185 reel, w500 expanded), trailer URL fetch at add-time; implement TanStack Query caching with explicit `staleTime` configuration; document API routes in markdown
 - [ ] 3.2 — Build search bar with ~300ms debounce, loading state, and two-section results ("In Your List" above separator, TMDB results below)
-- [ ] 3.3 — Implement add-movie flow: tap result → insert into movies table with TMDB metadata, stored trailer_url (fetched at add-time), trailer_url_refreshed_at, and added_by attribution
-- [ ] 3.4 — Build 2-column poster grid: full-bleed poster, title below, added-by avatar overlaid top-right, binoculars emoji overlaid top-left (watched only), no action buttons on cards, tap-only interaction
+- [ ] 3.3 — Implement add-movie flow: tap result → insert into movies table with TMDB metadata, stored trailer_url (fetched via server proxy at add-time; validated against domain allowlist: youtube.com, themoviedb.org, imdb.com), trailer_url_refreshed_at, and added_by attribution; document API routes in markdown
+- [ ] 3.4 — Build 2-column poster grid: full-bleed poster using TMDB native sized URLs (w342) with `loading="lazy"`, title below, added-by avatar overlaid top-right, binoculars emoji overlaid top-left (watched only), no action buttons on cards, tap-only interaction
 - [ ] 3.5 — Implement infinite scroll: load 12 movies initially, fetch and append next batch automatically on scroll to bottom
-- [ ] 3.6 — Build expanded movie inline panel: full-page-width expansion inserted below the tapped row (not a modal); fixed element order (poster → title → Added by → genre tags → Watched It + Trailer side-by-side → Delete centered below); delete two-tap shake-and-confirm; Watched It toggle; Trailer opens stored trailer_url in new tab; collapse on tap outside
+- [ ] 3.6 — Build expanded movie inline panel: full-page-width expansion inserted below the tapped row (not a modal); fixed element order (poster at w500 → title → Added by → genre tags → Watched It + Trailer side-by-side → Delete centered below); delete two-tap shake-and-confirm; Watched It toggle; Trailer opens stored trailer_url in new tab with rel="noopener noreferrer"; collapse on tap outside
 - [ ] 3.7 — Implement genre filter: tapping a genre tag in the inline panel filters the grid to that genre
 - [ ] 3.8 — Implement Watched state: toggle marks/unmarks movie as watched for the group; watched movies move to collapsed "Watched" section; binoculars overlay and button color update simultaneously across all members
-- [ ] 3.9 — Enable Supabase real-time subscriptions on movies table for live add, remove, and watched-status updates
+- [ ] 3.9 — Enable Supabase real-time subscriptions on movies table for live add, remove, and watched-status updates; subscribe only to the currently-viewed list (subscribe on mount, unsubscribe on unmount via useEffect cleanup); implement exponential backoff on reconnection
 
-### Phase 4: Randomizer (April 19-22, 2026) — MVP
+### Phase 4: Randomizer (April 20-23, 2026) — MVP
 - [ ] 4.1 — Build "Roll the Dice!" and "Genre Roll!" button layout pinned above the movie grid
 - [ ] 4.2 — Implement randomizer logic: select a random unwatched movie from the eligible pool (single list when in a list view; all user lists combined when on the home page); on the home page, render result as a standalone teaser card in place — do not navigate into any list
-- [ ] 4.3 — Build in-app roll animation: scatter/flip/spin elimination sequence landing on winner (target 2-3 seconds); test performance on low-end mobile devices
+- [ ] 4.3 — Build in-app roll animation: scatter/flip/spin elimination sequence landing on winner (target 2-3 seconds); test performance on low-end mobile devices; respect `prefers-reduced-motion` (use simple fade-in on winner when enabled)
 - [ ] 4.4 — Implement re-roll on second tap of Roll button
 - [ ] 4.5 — Build Genre Roll text input UI
-- [ ] 4.6 — Implement emotion-to-genre static mapping (see Section 10 reference table)
+- [ ] 4.6 — Implement emotion-to-genre mapping as a static TypeScript constant (see Section 10 reference table); translate genre labels to TMDB numeric genre IDs
 - [ ] 4.7 — Implement genre + emotion filter logic: normalize input (lowercase, tokenize on spaces/commas), map emotions to TMDB genre IDs, filter unwatched pool; if no matches, show "No matches — showing full list" and proceed unfiltered
-- [ ] 4.8 — Apply roll animation to genre-filtered results
+- [ ] 4.8 — Apply roll animation to genre-filtered results; document API routes in markdown
 
-### Phase 5: Landing Page and MVP Polish (April 22-26, 2026) — MVP CUTOFF
-- [ ] 5.1 — Build landing page structure: centered logo, splash text, Roll + Genre Roll buttons, Login button, scrolling About section, 3-step how-it-works demo (Step 1 left, Step 2 right, Step 3 left)
-- [ ] 5.2 — Build and seed landing_reel_posters table: implement periodic refresh job (suggested cadence: bi-weekly) that automatically fetches ~20 posters from TMDB popular or top-rated endpoints (no manual curation) and replaces the full set in the DB on each run
-- [ ] 5.3 — Build slot-machine reel animation for the landing page Roll the Dice button: 3 side-by-side reels that spin through the automatically fetched poster set, decelerate, and land on a single TMDB movie result (the final result is fetched live from TMDB — not constrained to the reel poster set)
-- [ ] 5.4 — Wire landing page Roll the Dice and Genre Roll to TMDB directly (no login required); display result as a static teaser card showing poster, title, and genres — no link, no tap action
+### Phase 5: Landing Page and MVP Polish (April 23-26, 2026) — MVP CUTOFF
+- [ ] 5.1 — Build landing page structure: centered logo, splash text, Roll + Genre Roll buttons, Login button, scrolling About section, 3-step how-it-works demo (Step 1 left, Step 2 right, Step 3 left); add site-wide footer with TMDB attribution (logo + link + disclaimer) and privacy policy link; build privacy policy page
+- [ ] 5.2 — Build and seed landing_reel_posters table: implement periodic refresh job via Supabase pg_cron + Edge Function (local Deno runtime) on bi-weekly cadence that automatically fetches ~20 posters from TMDB popular or top-rated endpoints (no manual curation) and replaces the full set in the DB on each run
+- [ ] 5.3 — Build slot-machine reel animation for the landing page Roll the Dice button: 3 side-by-side reels that spin through the automatically fetched poster set (using w185 size), decelerate, and land on a single TMDB movie result (the final result is fetched live from TMDB via server proxy — not constrained to the reel poster set); respect `prefers-reduced-motion` (instant reveal or simple fade-in when enabled)
+- [ ] 5.4 — Wire landing page Roll the Dice and Genre Roll to TMDB via server proxy (no login required); display result as a static teaser card showing poster, title, and genres — no link, no tap action
 - [ ] 5.5 — Loading and empty states for all major views (empty list, no search results, no genre matches, empty home page for new users)
 - [ ] 5.6 — Error handling: invalid invite code, TMDB API failure, network errors
-- [ ] 5.7 — Final MVP smoke test and Vercel production deployment
+- [ ] 5.7 — Configure HTTP security headers (CSP, X-Frame-Options, HSTS, etc.) in next.config.js or Caddy; use Report-Only mode during testing
+- [ ] 5.8 — Final MVP smoke test and Docker production deployment
 
 ---
 
 ### Phase 6: Trailer URL Refresh (April 27 - May 3, 2026) — Post-MVP
-- [ ] 6.1 — Implement background trailer URL refresh job: query movies where trailer_url IS NULL (bi-weekly cadence); fetch trailer URL from TMDB for each; update trailer_url and trailer_url_refreshed_at in DB. NOTE: this scope (null-only) should be reassessed post-launch to also refresh stale/aging URLs after a certain age threshold.
-- [ ] 6.2 — Set up job scheduling (Vercel Cron or Supabase scheduled function); confirm job runs reliably in production
+- [ ] 6.1 — Implement background trailer URL refresh job: query movies where trailer_url IS NULL (bi-weekly cadence); fetch trailer URL from TMDB via server proxy for each; validate against domain allowlist; update trailer_url and trailer_url_refreshed_at in DB. NOTE: this scope (null-only) should be reassessed post-launch to also refresh stale/aging URLs after a certain age threshold.
+- [ ] 6.2 — Set up job scheduling via Supabase pg_cron + Edge Function (local Deno runtime); confirm job runs reliably in production
 - [ ] 6.3 — Add monitoring/logging for refresh failures (e.g., TMDB returned no trailer) so missing URLs can be investigated
 
 ### Phase 7: Master Admin (May 4-17, 2026) — Post-MVP
-- [ ] 7.1 — Document Master Admin setup: MASTER_ADMIN_USERNAME and MASTER_ADMIN_TOTP_SECRET must be set as environment variables before first use; provide instructions for generating and enrolling the TOTP secret in an authenticator app
+- [ ] 7.1 — Document Master Admin setup: MASTER_ADMIN_USERNAME and MASTER_ADMIN_TOTP_SECRET must be set as environment variables before first use; provide instructions for generating and enrolling the TOTP secret in an authenticator app; document that TOTP secret rotation requires redeployment
 - [ ] 7.2 — Build /admin login route: username + TOTP code input, no password-only fallback; reads credentials from environment variables
-- [ ] 7.3 — Implement server-side TOTP verification (otplib); issue admin session token on success
+- [ ] 7.3 — Implement server-side TOTP verification (otplib); issue admin session via iron-session (encrypted HttpOnly cookie, Secure, SameSite=Strict, 8-hour expiry)
 - [ ] 7.4 — Build Master Admin dashboard UI
 - [ ] 7.5 — Implement list search and deletion (by name or ID, with confirmation)
 - [ ] 7.6 — Implement user search and deletion (by display name or ID, with confirmation)
@@ -401,29 +478,34 @@ List Deletion flow (separate from self-removal):
 - [ ] 7.8 — End-to-end TOTP test with a real authenticator app (Google Authenticator, Authy)
 
 ### Phase 8: PWA and Accessibility (May 18-31, 2026) — Post-MVP
-- [ ] 8.1 — Add PWA manifest and service worker for home screen installation
-- [ ] 8.2 — Implement offline graceful degradation (show cached list, disable write actions with message)
-- [ ] 8.3 — Accessibility pass: contrast ratios, tap target sizes (min 44x44px), aria labels on icon buttons
-- [ ] 8.4 — Performance tuning: poster lazy loading, infinite scroll, search debounce, animation on low-end devices
+- [ ] 8.1 — Add PWA manifest and service worker via `@serwist/next` for home screen installation
+- [ ] 8.2 — Implement offline graceful degradation: cached list data via TanStack Query `persistQueryClient` (IndexedDB), disable write actions with message; do not queue offline writes
+- [ ] 8.3 — Accessibility pass: contrast ratios, tap target sizes (min 44x44px), aria labels on icon buttons; inline panel keyboard navigation and focus management (Enter to open, Escape to close, focus trap on open, focus return on close, `aria-expanded` on trigger, `role="region"` on panel)
+- [ ] 8.4 — Performance tuning: poster lazy loading verification, infinite scroll performance, search debounce, animation on low-end devices; consider virtualized scrolling (`@tanstack/react-virtual`) if grid performance degrades with large lists
 
 ### Phase 9: QA and Cross-Device Testing (June 1-21, 2026) — Post-MVP
 - [ ] 9.1 — Cross-device testing: iOS Safari, Android Chrome, desktop Chrome/Firefox
 - [ ] 9.2 — Group flow end-to-end: create group, join from second device, add movies, roll, mark watched
-- [ ] 9.3 — Real-time sync test: verify live updates across two active sessions
-- [ ] 9.4 — Recovery code test: create account, simulate new device, recover identity
-- [ ] 9.5 — List Admin permissions test: confirm admin-only actions are blocked for regular members
+- [ ] 9.3 — Real-time sync test: verify live updates across two active sessions; verify subscription cleanup on navigation
+- [ ] 9.4 — Recovery code test: create account, simulate new device, recover identity; verify rate limiting on claim endpoint; verify code invalidation after use
+- [ ] 9.5 — List Admin permissions test: confirm admin-only actions are blocked for regular members via RLS
 - [ ] 9.6 — Admin self-removal test: trigger "Leave this list" as admin with other members present; confirm Transfer Ownership popup appears with member list and Cancel; confirm original admin is removed (not just demoted) after transfer; confirm list is NOT deleted; confirm that "Delete the list" action does NOT show the Transfer Ownership popup
-- [ ] 9.7 — Master Admin test: TOTP login, list/user search and deletion, session protection
-- [ ] 9.8 — TMDB rate limit check: confirm debounce and client-side caching stay within free tier limits
-- [ ] 9.9 — Landing page test: slot-machine reel animation plays using automatically fetched TMDB posters and lands on a valid movie result; result displays as a static teaser card (poster, title, genres) with no tap/link action; roll buttons work without login; 3-step demo alignment renders correctly on all screen sizes
+- [ ] 9.7 — Master Admin test: TOTP login, iron-session cookie validation, list/user search and deletion, session protection and expiry
+- [ ] 9.8 — TMDB rate limit check: confirm debounce and TanStack Query caching stay within rate limits; verify TMDB API key is not exposed in client-side code or network requests
+- [ ] 9.9 — Landing page test: slot-machine reel animation plays using automatically fetched TMDB posters and lands on a valid movie result; result displays as a static teaser card (poster, title, genres) with no tap/link action; roll buttons work without login; 3-step demo alignment renders correctly on all screen sizes; TMDB attribution footer visible; privacy policy accessible
 - [ ] 9.10 — Home page test: returning user lands on home page (not landing page); list cards display correctly; cross-list roll works and result appears as a standalone teaser card on the home page (no navigation into a list); new user with no lists sees empty state
-- [ ] 9.11 — Inline panel test: expands below correct row, collapses cleanly, delete two-tap flow works, binoculars overlay and Watched It button update in real time
-- [ ] 9.12 — Trailer URL refresh test: confirm background job processes only movies with null trailer_url and updates timestamps correctly; verify movies with existing trailer URLs are not re-processed
+- [ ] 9.11 — Inline panel test: expands below correct row, collapses cleanly, delete two-tap flow works, binoculars overlay and Watched It button update in real time; keyboard navigation works (Enter/Escape, focus management)
+- [ ] 9.12 — Trailer URL refresh test: confirm pg_cron job processes only movies with null trailer_url and updates timestamps correctly; verify movies with existing trailer URLs are not re-processed; verify URL domain validation
+- [ ] 9.13 — Security headers test: verify CSP, HSTS, X-Frame-Options are correctly applied on all routes
+- [ ] 9.14 — RLS test: verify unauthorized access attempts are blocked at the database level (attempt direct Supabase queries outside group membership)
+- [ ] 9.15 — Invite code rate limiting test: verify brute-force protection on join endpoint
+- [ ] 9.16 — Run Playwright E2E tests for critical paths: onboarding flow, add movie + real-time sync, roll the dice
+- [ ] 9.17 — Data retention test: verify inactive account cleanup job correctly identifies and deletes accounts inactive for 12+ months
 
 ### Phase 10: Launch (June 22 - July 5, 2026) — Full Feature Complete
-- [ ] 10.1 — Final Vercel production deployment and full smoke test
+- [ ] 10.1 — Final Docker production deployment and full smoke test
 - [ ] 10.2 — Confirm PWA install flows work on iOS and Android
-- [ ] 10.3 — Set up basic error monitoring (Vercel logs + Sentry free tier)
+- [ ] 10.3 — Verify Sentry error monitoring is capturing errors correctly; review and resolve any outstanding issues
 - [ ] 10.4 — Soft launch: share with initial test group, gather feedback
 - [ ] 10.5 — Address any launch-blocking bugs found during soft launch
 - [ ] 10.6 — Full feature complete sign-off by July 5, 2026
@@ -435,23 +517,33 @@ List Deletion flow (separate from self-removal):
 - Real-time list updates appear on all connected devices within 3 seconds
 - Genre Roll correctly filters by at least 8 of 10 emotion categories
 - Recovery code flow successfully restores a user's identity on a new device
-- Returning users land on their home page (not the landing page) when their user ID is in the browser
+- Recovery code claim endpoint blocks after 5 failed attempts per IP
+- Returning users land on their home page (not the landing page) when a valid Supabase session exists
 - Logged-in home page correctly shows all of the user's lists as cards with accurate movie counts
 - Cross-list roll on the home page draws from all user lists combined and displays the result as a standalone teaser card on the home page without navigating into any list
 - Admin "Leave this list" with other members present triggers the Transfer Ownership popup; original admin is removed after transfer; list is not deleted; direct "Delete the list" action does not trigger the transfer popup
-- Master Admin can log in with TOTP (credentials from environment variables) and delete any list or user
-- Trailer URL refresh job processes only movies where trailer_url is null (bi-weekly cadence)
+- Master Admin can log in with TOTP (credentials from environment variables) and delete any list or user; session expires after 8 hours
+- Trailer URL refresh job processes only movies where trailer_url is null (bi-weekly cadence via pg_cron)
 - Landing page slot-machine reel animation uses automatically fetched TMDB posters and lands on a valid movie result displayed as a static teaser card (poster, title, genres) with no tap/link action
 - Landing page roll buttons work without any login or account creation
 - App installs to mobile home screen and functions as a PWA
 - Inline panel expands below the correct grid row with no layout shift
+- Inline panel is fully operable via keyboard (Enter/Escape, focus management)
 - Delete two-tap confirmation does not trigger on a single tap
 - Watched state toggle (binoculars overlay + button color) updates in real time across all group members' screens
 - No personal data beyond display name is stored or transmitted
+- TMDB API key is not exposed in any client-side code or network request
+- TMDB attribution is visible on all pages
+- RLS policies prevent unauthorized data access at the database level
+- All animations respect `prefers-reduced-motion` preference
+- Inactive accounts are automatically deleted after 12 months
+- Security headers (CSP, HSTS, X-Frame-Options) are correctly applied
+- Invite code join endpoint is rate-limited against brute force
+- Docker deployment runs with non-root user and health check
 
 ## 10. Reference: Emotion-to-Genre Mapping
 
-Static mapping used by Genre Roll to translate emotion keywords into TMDB genre IDs. Normalize input to lowercase and tokenize on spaces and commas before matching.
+Static mapping used by Genre Roll to translate emotion keywords into TMDB genre IDs. Implemented as a static TypeScript constant. Normalize input to lowercase and tokenize on spaces and commas before matching.
 
 | Emotion Keywords | Primary Genres | Secondary Genres |
 |-----------------|----------------|-----------------|
@@ -474,3 +566,4 @@ _Features added after initial scope. Complete current Implementation Plan progre
 | Feature | Description | Added On | Rationale |
 |---------|-------------|----------|-----------|
 | Compact list/grid toggle | Toggle between poster grid and a compact list layout (title, year, metadata per row) | 2026-04-05 | Deferred from MVP — user unfamiliar with it at scoping; low priority relative to core flows |
+| Self-service account deletion | User-facing account deletion flow with cascading deletes, ownership transfer for administered groups, and anonymization of added_by references in movies | 2026-04-06 | GDPR Article 17 (Right to Erasure); MVP relies on Master Admin deletion on request as interim |