Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/privacy/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ describe("/privacy", () => {
);
});

/** covers: prompts/tmdb-image-loader.md, AC-6 */
it("says images load from TMDB, not from the host", () => {
const { container } = render(<PrivacyPage />);
const text = container.textContent ?? "";

expect(text).toContain("Your browser loads the images from");
expect(text).not.toContain("our host delivers the images");
expect(
PROCESSORS.find((processor) => processor.name === "Vercel")?.purpose,
).not.toMatch(/image/i);
});

it("opens external links in a new tab, safely (AC-7)", () => {
const { container } = render(<PrivacyPage />);

Expand Down Expand Up @@ -135,6 +147,18 @@ describe("/privacy", () => {
expect(text).toContain("EU Standard Contractual Clauses");
});

it("states each processor's transfer from the one module", () => {
const { container } = render(<PrivacyPage />);
const text = container.textContent ?? "";

for (const processor of PROCESSORS) {
if (processor.transfer) expect(text).toContain(processor.transfer);
}
expect(
PROCESSORS.find((processor) => processor.name === "TMDB")?.transfer,
).toMatch(/outside the EEA/);
});

it("promises no account email production can't send (spec 0018, AC-23)", () => {
const { container } = render(<PrivacyPage />);

Expand Down
21 changes: 13 additions & 8 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
RIGHTS_RESPONSE,
SUPABASE_REGION,
SUPERVISORY_AUTHORITY,
TMDB_HOME_URL,
} from "@/lib/legal/operator";
import { catalogMetadata } from "@/lib/seo/metadata";

Expand Down Expand Up @@ -134,9 +135,11 @@ export default function PrivacyPage() {
</LegalList>
<p>
Catalog details come from{" "}
<ExternalLink href="https://www.themoviedb.org">TMDB</ExternalLink>,
which receives no personal data from you: our server makes the catalog
requests, and our host delivers the images.
<ExternalLink href={TMDB_HOME_URL}>TMDB</ExternalLink>. Our server
makes the catalog requests, so TMDB never receives your account or
tracking data. Your browser loads the images from TMDB&rsquo;s image
service, which sees your IP address and browser details, and
TMDB&rsquo;s own privacy policy applies to them.
</p>
</LegalSection>

Expand All @@ -146,11 +149,13 @@ export default function PrivacyPage() {
{SUPABASE_REGION.name}, and the app&rsquo;s server functions in the
matching Vercel region.
</p>
<p>
Supabase and Vercel are United States companies. Where their staff
access your data for support and operations, that access is covered by
the EU Standard Contractual Clauses.
</p>
{PROCESSORS.flatMap((processor) =>
processor.transfer ? (
<p key={processor.name}>{processor.transfer}</p>
) : (
[]
),
)}
</LegalSection>

<LegalSection title="How long we keep it">
Expand Down
5 changes: 2 additions & 3 deletions app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { catalogList, listPath } from "@/lib/catalog/lists";
import { TMDB_HOME_URL } from "@/lib/legal/operator";

import { CardRoundButtonDemo } from "./card-round-button-demo";

Expand Down Expand Up @@ -568,9 +569,7 @@ export default function ShowcasePage() {
<LegalSection title="Sample section">
<p>
Body text in the secondary token, with an{" "}
<ExternalLink href="https://www.themoviedb.org">
external link
</ExternalLink>{" "}
<ExternalLink href={TMDB_HOME_URL}>external link</ExternalLink>{" "}
and the contact address, <ContactEmail />.
</p>
<LegalList>
Expand Down
8 changes: 6 additions & 2 deletions components/layout/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { cn } from "cn";
import Image from "next/image";
import Link from "next/link";
import type * as React from "react";
import { OPERATOR_GITHUB_URL, OPERATOR_HANDLE } from "@/lib/legal/operator";
import {
OPERATOR_GITHUB_URL,
OPERATOR_HANDLE,
TMDB_HOME_URL,
} from "@/lib/legal/operator";
import { TMDB_ATTRIBUTION } from "@/lib/tmdb";

import { IconKey } from "./icon-key";
Expand Down Expand Up @@ -32,7 +36,7 @@ export function SiteFooter() {
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between md:gap-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
<a
href="https://www.themoviedb.org"
href={TMDB_HOME_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 w-fit items-center rounded-sm md:min-h-9"
Expand Down
11 changes: 11 additions & 0 deletions lib/legal/boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
OPERATOR_GITHUB_URL,
OPERATOR_HANDLE,
SUPERVISORY_AUTHORITY,
TMDB_HOME_URL,
} from "./operator";

/**
Expand Down Expand Up @@ -55,6 +56,16 @@ describe("one home for each legal fact", () => {
]);
});

it("writes TMDB's site URL only in lib/legal/operator.ts", () => {
expect(filesContaining(TMDB_HOME_URL)).toEqual(["lib/legal/operator.ts"]);
});

it("names no processor's country in /privacy itself", () => {
expect(readFileSync("app/privacy/page.tsx", "utf8")).not.toContain(
"United States",
);
});

it("writes the JustWatch credit only in lib/legal/operator.ts (spec 0022, AC-12)", () => {
expect(filesContaining(JUSTWATCH_ATTRIBUTION)).toEqual([
"lib/legal/operator.ts",
Expand Down
42 changes: 36 additions & 6 deletions lib/legal/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const MINIMUM_AGE = 16;
* whenever the text changes materially. Pages format it in UTC and never read
* the clock (spec 0017, AC-7).
*/
export const LEGAL_LAST_UPDATED = "2026-10-12";
export const LEGAL_LAST_UPDATED = "2026-10-16";

/**
* Where the production Supabase project stores your data, read from
Expand All @@ -63,6 +63,15 @@ export const SUPABASE_REGION = {
*/
export const JUSTWATCH_ATTRIBUTION = "Availability data from JustWatch";

/**
* TMDB's own site, where the footer logo, the privacy policy and TMDB's
* processor entry link. One home, like `JUSTWATCH_ATTRIBUTION`, so the link
* cannot drift between the places that name TMDB. It lives here rather than in
* `lib/tmdb` because that entry point is server only and the footer test and
* the legal pages render in jsdom.
*/
export const TMDB_HOME_URL = "https://www.themoviedb.org";

/** Days within which an account deletion request is completed. */
export const DELETION_DAYS = 30;

Expand All @@ -72,27 +81,48 @@ export const RIGHTS_RESPONSE = "one month";
/** Days within which a complaint about the service is answered. */
export const COMPLAINT_RESPONSE_DAYS = 14;

/** A service provider that processes personal data on the operator's behalf. */
export type Processor = { name: string; purpose: string; url: string };
/**
* A service provider that processes personal data on the operator's behalf.
* `transfer`, when set, is what "Transfers outside the EEA" says about it, so
* the company's country lives beside its other facts and not in page text.
*/
export type Processor = {
name: string;
purpose: string;
url: string;
transfer?: string;
};

/**
* Only the processors live today. Production sends no email (spec 0018), so
* neither Google nor an email provider is listed; both join with the deferred
* email delivery and Google sign in work, in the PR that turns them on.
* YouTube joined with the title page trailers (spec 0022, AC-19): its player
* loads only after you press Trailer.
* loads only after you press Trailer. TMDB joined when images moved off
* Vercel's optimizer onto TMDB's own CDN (prompts/tmdb-image-loader.md).
*/
export const PROCESSORS: readonly Processor[] = [
{
name: "Supabase",
purpose: "Stores your account and tracking data and handles sign in.",
url: "https://supabase.com",
transfer:
"Supabase is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.",
},
{
name: "Vercel",
purpose:
"Hosts the website, delivers catalog images, and keeps request logs.",
purpose: "Hosts the website and keeps request logs.",
url: "https://vercel.com",
transfer:
"Vercel is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.",
},
{
name: "TMDB",
purpose:
"Delivers posters, photos and other catalog images straight to your browser, which shares your IP address with it.",
url: TMDB_HOME_URL,
transfer:
"TMDB is a United States company. The images your browser loads from it come from its content delivery network, which may serve them from outside the EEA.",
},
{
name: "YouTube",
Expand Down
15 changes: 13 additions & 2 deletions lib/tmdb/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
# AGENTS.md · lib/tmdb

The server only module every TMDB request goes through. Decided in
The server only module every TMDB request goes through, plus the one browser
file, `image-loader.ts`, that `next.config.ts` hands to `next/image`. Decided in
[spec 0002](../../docs/specs/0002-tmdb-integration-module/index.md); scope
feature 4. Runtime steps live in that spec's `verify.md`.

## What lives where

- `index.ts` is the only entry point a page may import. It exports the cached
reads, `imageUrl`, `isTmdbNotFound`, `TmdbError`, `TMDB_ATTRIBUTION` and the
normalized types. Everything else in this folder is internal.
normalized types. Everything else in this folder is internal, except
`image-loader.ts` (`'use client'`), which only the `loaderFile` setting
imports, and the pure `images.ts` it reads.
- `reads.ts` holds every `use cache` wrapper and is the only file that imports
`next/cache`.
- `client.ts` owns auth, the deadline, the retry budget, the status mapping and
Expand Down Expand Up @@ -69,6 +72,14 @@ feature 4. Runtime steps live in that spec's `verify.md`.
(`resolveOverview` in `normalize.ts`, spec 0006, AC-5).
- **Errors carry a `kind`**, never a bare status. Pages branch through
`isTmdbNotFound`, not through a hand rolled catch.
- **Images skip Vercel's optimizer.** `image-loader.ts` (`'use client'`, the
`loaderFile` in `next.config.ts`) maps a `next/image` width onto TMDB's own
sizes, never above the size `images.ts` pinned and never `original`, so the
browser loads straight from TMDB's CDN (prompts/tmdb-image-loader.md). A new
image field keeps a size from `TMDB_IMAGE_WIDTHS` in `images.ts`, the one
list the loader and `TmdbImageSize` share. `h632`, another host or a size
off that list passes through unresized, and Next's dev warning ("does not
implement width") names it.
- Adding a field the app needs is a three place change: `schemas.ts`,
`types.ts`, `normalize.ts`.

Expand Down
79 changes: 79 additions & 0 deletions lib/tmdb/image-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";

import { TMDB_IMAGE_BASE } from "./constants";
import tmdbImageLoaderDefault, { tmdbImageLoader } from "./image-loader";

/**
* covers: prompts/tmdb-image-loader.md, AC-1 to AC-3
*
* The loader is what keeps Vercel Image Optimization at zero transformations,
* so a wrong rung is either a broken TMDB URL (a 400 for `w640`) or a heavier
* image than the field size allows. Both are invisible until someone looks.
*/
const at = (size: string) => `${TMDB_IMAGE_BASE}/${size}/abc123.jpg`;
/** The source's own size comes back marked, so Next sees the width honoured. */
const same = (size: string) => `${at(size)}#same-size`;

describe("tmdbImageLoader", () => {
it("is the module's default export, which loaderFile needs", () => {
expect(tmdbImageLoaderDefault).toBe(tmdbImageLoader);
});

it.each([
[32, "w92"],
[92, "w92"],
[93, "w154"],
[200, "w300"],
[342, "w342"],
])("serves a w500 poster at width %i from %s (AC-1)", (width, size) => {
expect(tmdbImageLoader({ src: at("w500"), width, quality: 75 })).toBe(
at(size),
);
});

it.each([384, 3840])(
"serves a w500 poster at width %i from its own size, marked so Next does not warn",
(width) => {
expect(tmdbImageLoader({ src: at("w500"), width })).toBe(same("w500"));
},
);

it.each([[640, "w780"]])(
"serves a w1280 backdrop at width %i from %s (AC-2)",
(width, size) => {
expect(tmdbImageLoader({ src: at("w1280"), width })).toBe(at(size));
},
);

it("serves a w1280 backdrop from its own size above w780 (AC-2)", () => {
expect(tmdbImageLoader({ src: at("w1280"), width: 828 })).toBe(
same("w1280"),
);
});

it("never asks for more than the pinned profile size", () => {
expect(tmdbImageLoader({ src: at("w185"), width: 384 })).toBe(same("w185"));
});

it("caps an original source at w1280 and still sizes it down (AC-3)", () => {
expect(tmdbImageLoader({ src: at("original"), width: 3840 })).toBe(
at("w1280"),
);
expect(tmdbImageLoader({ src: at("original"), width: 128 })).toBe(
at("w154"),
);
});

it.each([
at("h632"),
at("w45"),
`${TMDB_IMAGE_BASE}/abc123.jpg`,
"https://example.com/t/p/w500/abc123.jpg",
"/tmdb-logo.svg",
])(
"returns %s unchanged, leaving Next's width warning to flag it (AC-3)",
(src) => {
expect(tmdbImageLoader({ src, width: 200 })).toBe(src);
},
);
});
55 changes: 55 additions & 0 deletions lib/tmdb/image-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import { TMDB_IMAGE_BASE } from "./constants";
import { splitImageUrl, TMDB_IMAGE_WIDTHS } from "./images";

/** What `original` is capped to, so a full size source never ships as is. */
const ORIGINAL_CEILING = 1280;

/**
* Appended when the fitting size is the source's own. Next warns in
* development that a loader "does not implement width" whenever it returns
* `src` unchanged, which every image at its pinned size would. A fragment never
* reaches TMDB, so the request and its CDN cache entry are the same file.
*/
const SAME_SIZE_FRAGMENT = "#same-size";

type LoaderParams = { src: string; width: number; quality?: number };

/**
* Maps a `next/image` width request onto TMDB's own pre-sized images, so the
* browser fetches straight from TMDB's CDN and Vercel Image Optimization runs
* no transformations (prompts/tmdb-image-loader.md). TMDB already negotiates
* WebP from the `Accept` header, so nothing is lost by skipping the optimizer.
*
* The size `lib/tmdb/images.ts` pinned is the ceiling: a `w500` poster is never
* upgraded on a dense screen, so no page gets heavier than its field size allows.
* `quality` is ignored because TMDB has no such parameter. Any URL that is not
* a TMDB size path on the ladder comes back unchanged, so the loader never
* builds a URL for a host it was not handed. Next's development warning then
* names that image, which is the signal to pin it to a ladder size.
*/
export function tmdbImageLoader({ src, width }: LoaderParams): string {
const parts = splitImageUrl(src);
if (!parts) return src;
const ceiling = sizeCeiling(parts.size);
if (ceiling === null) return src;

// `original` is left off the ladder because it can be several megabytes.
const fit = TMDB_IMAGE_WIDTHS.find((rung) => rung >= width) ?? ceiling;
const url = `${TMDB_IMAGE_BASE}/w${Math.min(fit, ceiling)}${parts.path}`;
return url === src ? `${url}${SAME_SIZE_FRAGMENT}` : url;
}

/** The pixel width a source size allows, or null when it is not on the ladder. */
function sizeCeiling(size: string): number | null {
if (size === "original") return ORIGINAL_CEILING;
const match = /^w(\d+)$/.exec(size);
if (!match) return null;
const value = Number(match[1]);
return (TMDB_IMAGE_WIDTHS as readonly number[]).includes(value)
? value
: null;
}

export default tmdbImageLoader;
Loading
Loading