diff --git a/app/lib/graphql.ts b/app/lib/graphql.ts index a1c2017..81db3e5 100644 --- a/app/lib/graphql.ts +++ b/app/lib/graphql.ts @@ -5,12 +5,12 @@ import { createFontdueFetch, FontdueNotFoundError } from "fontdue-js/server"; // there's no transport boilerplate in the loaders. // // There's no per-request binding: because the root route's middleware (see -// app/root.tsx) wraps every loader in runWithPreview, this fetcher automatically +// app/root.tsx) wraps every loader in runWithFontdue, this fetcher automatically // forwards the admin preview token when an admin is previewing (revealing -// unpublished fonts), and sends a plain request otherwise. The same is true of -// every fontdue-js preload helper (loadTypeTesterQuery, loadFontdueProviderQuery, -// …) — call them with just their variables and they pick up preview from the -// ambient context. +// unpublished fonts) and the visitor's node-access token for a collection they've +// unlocked, and sends a plain request otherwise. The same is true of every +// fontdue-js preload helper (loadTypeTesterQuery, loadFontdueProviderQuery, …) — +// call them with just their variables and they pick up the ambient context. // // Use it at the top of a loader: // diff --git a/app/root.tsx b/app/root.tsx index 9328cc8..9110774 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -11,7 +11,7 @@ import { import FontdueProvider, { loadFontdueProviderQuery } from "fontdue-js/FontdueProvider"; import StoreModal from "fontdue-js/StoreModal"; import CartButton from "fontdue-js/CartButton"; -import { runWithPreview } from "fontdue-js/preview/server"; +import { runWithFontdue } from "fontdue-js/server/middleware"; import type { Route } from "./+types/root"; import "./app.css"; @@ -20,14 +20,17 @@ import { fetchGraphql } from "./lib/graphql"; import RootLayoutDoc from "./queries/RootLayout.graphql?raw"; import type { RootLayoutQuery } from "./queries/operations-types"; -// Root middleware wraps every loader on every route. runWithPreview puts the -// admin preview token (from the preview cookie) into an ambient context for the -// whole request, so the fetcher and all fontdue-js preloads reveal unpublished -// fonts with no per-loader plumbing — and it forces preview responses out of -// the shared CDN cache so an admin render is never served to the public. Public -// requests pass through untouched and stay cacheable (see `headers` below). +// Root middleware wraps every loader on every route. runWithFontdue puts two +// request-scoped tokens into an ambient context for the whole request: the admin +// preview token (reveals unpublished fonts) and the visitor's per-collection +// node-access token (a collection they unlocked with a password). The fetcher +// and all fontdue-js preloads forward them with no per-loader plumbing — and it +// forces a per-visitor response out of the shared CDN cache so an admin's (or an +// unlocked visitor's) render is never served to the public. Public requests pass +// through untouched and stay cacheable (see `headers` below). (runWithFontdue is +// runWithPreview composed with runWithNodeAccess; mount either alone for one.) export const middleware: Route.MiddlewareFunction[] = [ - ({ request }, next) => runWithPreview(request, next), + ({ request }, next) => runWithFontdue(request, next), ]; // The route loader is the SSR data layer — equivalent to Astro's diff --git a/app/routes/api.preview.ts b/app/routes/api.preview.ts index 1435af2..fc1efd9 100644 --- a/app/routes/api.preview.ts +++ b/app/routes/api.preview.ts @@ -5,7 +5,7 @@ import type { Route } from "./+types/api.preview"; // by — POSTs a short-lived token here to turn preview on, and // DELETEs to turn it off. handlePreviewRequest sets the preview cookies (an // httpOnly token + a readable marker that the toolbar checks); the root route's -// middleware (app/root.tsx) wraps each request in runWithPreview, which forwards +// middleware (app/root.tsx) wraps each request in runWithFontdue, which forwards // the token to GraphQL and keeps preview pages out of the shared CDN cache so // the public never sees unpublished fonts. // diff --git a/app/routes/fonts.$slug.tsx b/app/routes/fonts.$slug.tsx index 03acd3c..8942e13 100644 --- a/app/routes/fonts.$slug.tsx +++ b/app/routes/fonts.$slug.tsx @@ -4,6 +4,8 @@ import CharacterViewer, { loadCharacterViewerQuery, } from "fontdue-js/CharacterViewer"; import BuyButton, { loadBuyButtonQuery } from "fontdue-js/BuyButton"; +import { FontduePasswordProtectedError } from "fontdue-js/server"; +import NodePasswordForm from "fontdue-js/NodePasswordForm"; import { fetchGraphql } from "../lib/graphql"; import FontDoc from "../queries/Font.graphql?raw"; import type { @@ -12,6 +14,9 @@ import type { } from "../queries/operations-types"; export function meta({ data }: Route.MetaArgs) { + if (data?.locked) { + return [{ title: "Password required — fontdue-js on RR7" }]; + } const collection = data?.collection; const title = collection?.pageMetadata?.title ?? collection?.name ?? "Font detail"; @@ -25,19 +30,28 @@ export function meta({ data }: Route.MetaArgs) { // reveal unpublished styles — preview rides the ambient context, so nothing // is threaded here (see app/lib/graphql.ts). export async function loader({ params }: Route.LoaderArgs) { - const [ - fontData, + let fontData, typeTestersPreload, characterViewerPreload, - buyButtonPreload, - ] = await Promise.all([ - fetchGraphql("Font", FontDoc, { - slug: params.slug, - }), - loadTypeTestersQuery({ collectionSlug: params.slug }), - loadCharacterViewerQuery({ collectionSlug: params.slug }), - loadBuyButtonQuery({ collectionSlug: params.slug }), - ]); + buyButtonPreload; + try { + [fontData, typeTestersPreload, characterViewerPreload, buyButtonPreload] = + await Promise.all([ + fetchGraphql("Font", FontDoc, { + slug: params.slug, + }), + loadTypeTestersQuery({ collectionSlug: params.slug }), + loadCharacterViewerQuery({ collectionSlug: params.slug }), + loadBuyButtonQuery({ collectionSlug: params.slug }), + ]); + } catch (error) { + // The collection is password-protected and the visitor hasn't unlocked it. + // Render the password form instead of a 404 — it exists, it's just gated. + if (error instanceof FontduePasswordProtectedError) { + return { locked: true as const, slug: params.slug }; + } + throw error; + } const collection = fontData.viewer.slug?.fontCollection; if (!collection) { @@ -45,6 +59,7 @@ export async function loader({ params }: Route.LoaderArgs) { } return { + locked: false as const, collection, typeTestersPreload, characterViewerPreload, @@ -53,6 +68,18 @@ export async function loader({ params }: Route.LoaderArgs) { } export default function FontDetail({ loaderData }: Route.ComponentProps) { + if (loaderData.locked) { + return ( + <> +

Password required

+

+ This collection is password-protected. Enter the password to view it. +

+ + + ); + } + const { collection, typeTestersPreload, diff --git a/react-router.config.ts b/react-router.config.ts index 7ce0621..790411a 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -5,9 +5,9 @@ export default { ssr: true, future: { // Route middleware (stable since React Router 7.9). The root route's - // middleware wraps every loader in runWithPreview so the staff preview - // token reaches fontdue-js fetches/preloads automatically. Opt-in now; the - // default in the next major. + // middleware wraps every loader in runWithFontdue so the staff preview token + // and a visitor's collection-unlock token reach fontdue-js fetches/preloads + // automatically. Opt-in now; the default in the next major. v8_middleware: true, }, } satisfies Config;