COMPLIANCE.md 12 KB

Compliance Review — MovieDice (UI + Filter Batch)

Reviewed 2026-05-21. Scope: uncommitted batch on master (cert filter, layout/footer refactor, carousel button conversion, settings refresh, privacy/legal footer).


CRITICAL

1. STRICT cert walker silently hides legitimate titles in search

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.

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.

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.


2. Pre-existing movies rows now break <ListMoreInfoModal>

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.

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.

Implementation Risk: Context-bypass leaks existence to authenticated members of the owning list only — acceptable. Backfill is destructive and needs admin sign-off.


HIGH

3. Admin layout double min-h-screen breaks login/dashboard chrome

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: 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: None if done correctly — Playwright/manual check the centered-login layout after.


4. List-page back/settings links use raw <a href> and drop client state

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: Convert both anchors to <Link href={...}>. Preserve className, aria-label, svg child.

Implementation Risk: None; <Link> accepts arbitrary children including svg.


MEDIUM

5. aria-live="polite" on the carousel teaser <button>

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.

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.


6. Carousel "i" glyph is decorative noise post-conversion

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: 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.


7. /api/tmdb/search post-filter fan-out is heavy

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: 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: 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.


8. Privacy-page CCPA anchor offset hack is non-functional

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: 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: Wording care: CCPA-specific rights (sale/share opt-out, non-discrimination) and GDPR-specific (lodge a complaint, restriction-of-processing) shouldn't be conflated.


9. Privacy policy CCPA coverage is incomplete

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

Fix: Add a CCPA-specific block with the above. Worth a legal review if the deployment serves California residents at scale.


10. Legal footer cookie notice understates browser storage in use

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."

Fix: Reword to "This site uses essential cookies and local storage for authentication. See our Privacy Policy."


LOW

11. Double aria-live regions around the roll result

src/components/dice/list-roll-carousel.tsx:202-211, src/components/movies/movie-list-client.tsx:161RollAnnouncer 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.

Fix: Drop aria-live from the carousel teaser container. Verify via NVDA/VoiceOver that RollAnnouncer fires at settle.


12. Legal-footer hash links compound finding #8

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.


13. SearchBar placeholder "ADD A MOVIE" with text-center is unconventional

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: 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.)


14. No CPRA/PIPEDA/LGPD coverage

Privacy policy and footer mention only CCPA + GDPR. For a low-data anonymous-auth service the omission is defensible, but worth flagging:

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

POSITIVES

  • <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

Severity Total
Critical 2
High 2
Medium 6
Low 4