From 9c17148b0b23e632c0d06c890570fada251d38da Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:59:55 +0800 Subject: [PATCH] fix(web): legal links are plain anchors, never Next-prefetched routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy/Terms links from #1051 render same-origin paths with next/link's , which prefetches the route's RSC payload. On the hosted deployment /privacy and /terms are not Next routes — Caddy serves static pages at those paths — so every landing-page load logged a 404 for the prefetch request. Legal URLs are operator-supplied and may resolve outside this Next app by design, so they must never be prefetched or client-routed. Render them with a plain always: same-origin paths open in the same tab, absolute cross-origin URLs keep target="_blank" rel="noopener noreferrer". Touches the three call sites from #1051 (SignInConsent's LegalAnchor, LegalFooterLinks, and the landing page footer's FOOTER_LINKS) plus a new `plain` field on the link type legalFooterLinks() returns, so the footer map can render those entries as bare anchors without touching any other footer link. Existing hosted tests are updated to assert a plain anchor via a next/link mock marker, each made to fail before this fix. Co-Authored-By: Claude Fable 5.1 --- .../layout.legal-consent.hosted.test.tsx | 28 ++++++++++----- .../app/components/LegalFooterLinks.test.tsx | 25 +++++++++++++- web/src/app/components/LegalFooterLinks.tsx | 34 +++++++++---------- web/src/app/components/SignInConsent.test.tsx | 20 ++++++++--- web/src/app/components/SignInConsent.tsx | 10 ++++-- web/src/app/page.legal-links.hosted.test.tsx | 23 ++++++++++++- web/src/app/page.tsx | 22 ++++++++++-- web/src/lib/site.test.ts | 16 ++++++--- web/src/lib/site.ts | 34 +++++++++++++++++-- 9 files changed, 166 insertions(+), 46 deletions(-) diff --git a/web/src/app/(app)/layout.legal-consent.hosted.test.tsx b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx index 12983f6f3..c115c5c8f 100644 --- a/web/src/app/(app)/layout.legal-consent.hosted.test.tsx +++ b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx @@ -12,6 +12,12 @@ process.env.NEXT_PUBLIC_TERMS_URL = "/terms"; import { render, screen, within } from "@testing-library/react"; import AppLayout from "./AppLayoutClient"; +// Legal links must never render through next/link's — see the +// comment on LegalAnchor in SignInConsent.tsx for why (same-origin legal +// paths can resolve outside this Next app, and prefetches the +// route's RSC payload regardless). This mock tags anything that DOES go +// through with a marker attribute so a Link-rendered legal link is +// distinguishable from a plain even though both render as tags. jest.mock("next/link", () => { return function MockLink({ href, @@ -23,7 +29,7 @@ jest.mock("next/link", () => { [k: string]: unknown; }) { return ( - + {children} ); @@ -57,12 +63,18 @@ describe("(app) layout — sign-in consent (hosted build)", () => { expect(consent).toHaveTextContent( "By signing in you agree to the Terms and Privacy policy.", ); - expect(within(consent).getByRole("link", { name: "Terms" })).toHaveAttribute( - "href", - "/terms", - ); - expect( - within(consent).getByRole("link", { name: "Privacy policy" }), - ).toHaveAttribute("href", "/privacy"); + const termsLink = within(consent).getByRole("link", { name: "Terms" }); + expect(termsLink).toHaveAttribute("href", "/terms"); + const privacyLink = within(consent).getByRole("link", { + name: "Privacy policy", + }); + expect(privacyLink).toHaveAttribute("href", "/privacy"); + + // Same-origin on paper ("/terms", "/privacy"), but on the hosted + // deployment these resolve to static Caddy routes, not Next pages. + // would prefetch that route's RSC payload and 404 — regression + // coverage for the #1051 follow-up bug. + expect(termsLink).not.toHaveAttribute("data-next-link"); + expect(privacyLink).not.toHaveAttribute("data-next-link"); }); }); diff --git a/web/src/app/components/LegalFooterLinks.test.tsx b/web/src/app/components/LegalFooterLinks.test.tsx index c424fcc4e..e64cff0af 100644 --- a/web/src/app/components/LegalFooterLinks.test.tsx +++ b/web/src/app/components/LegalFooterLinks.test.tsx @@ -11,6 +11,12 @@ export {}; const PRIVACY_ENV_KEY = "NEXT_PUBLIC_PRIVACY_URL"; const TERMS_ENV_KEY = "NEXT_PUBLIC_TERMS_URL"; +// Legal links must never render through next/link's — see the +// comment on legalFooterLinks() in lib/site.ts for why (same-origin legal +// paths can resolve outside this Next app, and prefetches the +// route's RSC payload regardless). This mock tags anything that DOES go +// through with a marker attribute so a Link-rendered legal link is +// distinguishable from a plain even though both render as tags. jest.mock("next/link", () => { return function MockLink({ href, @@ -22,7 +28,7 @@ jest.mock("next/link", () => { [key: string]: unknown; }) { return ( - + {children} ); @@ -72,4 +78,21 @@ describe("LegalFooterLinks", () => { "/terms", ); }); + + it("renders same-origin Privacy/Terms as plain anchors, never next/link's ", () => { + // A same-origin legal path (e.g. "/privacy") may resolve outside this + // Next app on the hosted deployment (Caddy serves it as a static page, + // not a Next route). Rendering it with would prefetch a route + // this app doesn't own and 404. Regression coverage for that bug. + process.env[PRIVACY_ENV_KEY] = "/privacy"; + process.env[TERMS_ENV_KEY] = "/terms"; + const { LegalFooterLinks } = loadLegalFooterLinks(); + render(); + expect(screen.getByRole("link", { name: "Privacy" })).not.toHaveAttribute( + "data-next-link", + ); + expect(screen.getByRole("link", { name: "Terms" })).not.toHaveAttribute( + "data-next-link", + ); + }); }); diff --git a/web/src/app/components/LegalFooterLinks.tsx b/web/src/app/components/LegalFooterLinks.tsx index d3d3b4efe..2b37521c0 100644 --- a/web/src/app/components/LegalFooterLinks.tsx +++ b/web/src/app/components/LegalFooterLinks.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import { legalFooterLinks } from "../../lib/site"; // Privacy/Terms links for a public-page footer that doesn't already carry @@ -6,6 +5,11 @@ import { legalFooterLinks } from "../../lib/site"; // legalFooterLinks() straight into its own FOOTER_LINKS). Renders nothing // when neither PRIVACY_URL nor TERMS_URL is configured, so a self-host or // staging footer looks exactly as it did before this existed. +// +// Every entry from legalFooterLinks() is `plain` — always a bare , never +// next/link's — because these are operator-supplied URLs that may +// resolve outside this Next app (see the comment on legalFooterLinks in +// lib/site.ts). Only `external` varies the target/rel. export function LegalFooterLinks({ className, linkStyle, @@ -17,23 +21,17 @@ export function LegalFooterLinks({ if (links.length === 0) return null; return (
- {links.map((l) => - l.external ? ( - - {l.label} - - ) : ( - - {l.label} - - ), - )} + {links.map((l) => ( + + {l.label} + + ))}
); } diff --git a/web/src/app/components/SignInConsent.test.tsx b/web/src/app/components/SignInConsent.test.tsx index bcd702f3f..71654a83a 100644 --- a/web/src/app/components/SignInConsent.test.tsx +++ b/web/src/app/components/SignInConsent.test.tsx @@ -10,6 +10,12 @@ export {}; const PRIVACY_ENV_KEY = "NEXT_PUBLIC_PRIVACY_URL"; const TERMS_ENV_KEY = "NEXT_PUBLIC_TERMS_URL"; +// Legal links must never render through next/link's — see the +// comment on LegalAnchor in SignInConsent.tsx for why (same-origin legal +// paths can resolve outside this Next app, and prefetches the +// route's RSC payload regardless). This mock tags anything that DOES go +// through with a marker attribute so a Link-rendered legal link is +// distinguishable from a plain even though both render as tags. jest.mock("next/link", () => { return function MockLink({ href, @@ -21,7 +27,7 @@ jest.mock("next/link", () => { [key: string]: unknown; }) { return ( - + {children} ); @@ -91,15 +97,19 @@ describe("SignInConsent", () => { ).toHaveAttribute("href", "/privacy"); }); - it("renders a same-origin path as an in-app link with no target", () => { + it("renders a same-origin path as a plain anchor with no target", () => { process.env[PRIVACY_ENV_KEY] = "/privacy"; process.env[TERMS_ENV_KEY] = "/terms"; const { SignInConsent } = loadSignInConsent(); render(); const consent = screen.getByTestId("sign-in-consent"); - expect( - within(consent).getByRole("link", { name: "Terms" }), - ).not.toHaveAttribute("target"); + const termsLink = within(consent).getByRole("link", { name: "Terms" }); + expect(termsLink).not.toHaveAttribute("target"); + // A same-origin legal path may resolve outside this Next app (the + // hosted deployment serves /privacy and /terms as static Caddy routes, + // not Next pages), so this must be a plain , never next/link's + // — would prefetch a route this app doesn't own and 404. + expect(termsLink).not.toHaveAttribute("data-next-link"); }); it("opens an absolute cross-origin legal URL in a new tab", () => { diff --git a/web/src/app/components/SignInConsent.tsx b/web/src/app/components/SignInConsent.tsx index 11c6a582a..ca0c69340 100644 --- a/web/src/app/components/SignInConsent.tsx +++ b/web/src/app/components/SignInConsent.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import { PRIVACY_URL, TERMS_URL } from "../../lib/site"; // Rendered directly beneath the primary "Sign in" call to action — the @@ -35,11 +34,16 @@ function LegalAnchor({ children: React.ReactNode; }) { const style: React.CSSProperties = { textDecoration: "underline" }; + // Legal URLs are operator-supplied and may resolve outside this Next app + // (the hosted deployment serves /privacy and /terms as static Caddy + // routes, not Next pages) — always a plain , never next/link's , + // which would prefetch the route's RSC payload and 404 against a path + // Next doesn't own. See lib/site.ts's legalFooterLinks for the full story. if (href.startsWith("/")) { return ( - + {children} - + ); } return ( diff --git a/web/src/app/page.legal-links.hosted.test.tsx b/web/src/app/page.legal-links.hosted.test.tsx index 73c1dcf03..78fb3a74b 100644 --- a/web/src/app/page.legal-links.hosted.test.tsx +++ b/web/src/app/page.legal-links.hosted.test.tsx @@ -13,6 +13,12 @@ process.env.NEXT_PUBLIC_TERMS_URL = "/terms"; import { render, screen, within } from "@testing-library/react"; import Home from "./page"; +// Legal links must never render through next/link's — see the +// comment on legalFooterLinks() in lib/site.ts for why (same-origin legal +// paths can resolve outside this Next app, and prefetches the +// route's RSC payload regardless). This mock tags anything that DOES go +// through with a marker attribute so a Link-rendered legal link is +// distinguishable from a plain even though both render as tags. jest.mock("next/link", () => { return function MockLink({ href, @@ -24,7 +30,7 @@ jest.mock("next/link", () => { [key: string]: unknown; }) { return ( - + {children} ); @@ -49,6 +55,21 @@ describe("Privacy/Terms links and sign-in consent (hosted build)", () => { ); }); + it("renders footer Privacy/Terms as plain anchors, never next/link's ", () => { + // Same-origin as this Next app on paper ("/privacy", "/terms"), but on + // the hosted deployment these resolve to a static Caddy route, not a + // Next page. would prefetch that route's RSC payload and 404 — + // regression coverage for the #1051 follow-up bug. + render(); + const footer = screen.getByRole("contentinfo"); + expect( + within(footer).getByRole("link", { name: "Privacy" }), + ).not.toHaveAttribute("data-next-link"); + expect( + within(footer).getByRole("link", { name: "Terms" }), + ).not.toHaveAttribute("data-next-link"); + }); + it("renders the sign-in consent line beneath the nav Sign in link with exact wording", () => { render(); const consent = screen.getByTestId("sign-in-consent"); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 04acbbf03..88c7eaabd 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -124,7 +124,15 @@ const SOCIAL_LINKS: { label: string; href: string; aria: string; path: string }[ }, ]; -const FOOTER_LINKS: { label: string; href: string; external?: boolean }[] = [ +const FOOTER_LINKS: { + label: string; + href: string; + external?: boolean; + // Set only by the spread of legalFooterLinks() below — see its comment in + // lib/site.ts. Marks an entry as always rendered with a bare , never + // next/link's , because the href may resolve outside this Next app. + plain?: boolean; +}[] = [ { label: "GitHub", href: "https://github.com/tokencanopy/e2a", external: true }, { label: "Transactional Email", href: "/transactional-email-api" }, { label: "API Docs", href: "/api-docs" }, @@ -1172,7 +1180,17 @@ export default function Home() {
{FOOTER_LINKS.map((l) => - l.external ? ( + l.plain ? ( + + {l.label} + + ) : l.external ? ( { delete process.env[TERMS_ENV_KEY]; const site = loadSite(); expect(site.legalFooterLinks()).toEqual([ - { label: "Privacy", href: "/privacy", external: false }, + { label: "Privacy", href: "/privacy", external: false, plain: true }, ]); }); @@ -112,7 +112,7 @@ describe("site config — legal links", () => { process.env[TERMS_ENV_KEY] = "/terms"; const site = loadSite(); expect(site.legalFooterLinks()).toEqual([ - { label: "Terms", href: "/terms", external: false }, + { label: "Terms", href: "/terms", external: false, plain: true }, ]); }); @@ -121,8 +121,8 @@ describe("site config — legal links", () => { process.env[TERMS_ENV_KEY] = "/terms"; const site = loadSite(); expect(site.legalFooterLinks()).toEqual([ - { label: "Privacy", href: "/privacy", external: false }, - { label: "Terms", href: "/terms", external: false }, + { label: "Privacy", href: "/privacy", external: false, plain: true }, + { label: "Terms", href: "/terms", external: false, plain: true }, ]); }); @@ -142,6 +142,7 @@ describe("site config — legal links", () => { label: "Privacy", href: "https://legal.example.com/privacy", external: true, + plain: true, }, ]); }); @@ -152,7 +153,12 @@ describe("site config — legal links", () => { delete process.env[PRIVACY_ENV_KEY]; const site = loadSite(); expect(site.legalFooterLinks()).toEqual([ - { label: "Terms", href: "https://e2a.dev/terms", external: false }, + { + label: "Terms", + href: "https://e2a.dev/terms", + external: false, + plain: true, + }, ]); }); diff --git a/web/src/lib/site.ts b/web/src/lib/site.ts index 2694f93d7..49f82baf4 100644 --- a/web/src/lib/site.ts +++ b/web/src/lib/site.ts @@ -92,7 +92,14 @@ export function isExternalURL(url: string): boolean { } } -export type LegalLink = { label: string; href: string; external: boolean }; +export type LegalLink = { + label: string; + href: string; + external: boolean; + // Always true for these entries — see the comment on legalFooterLinks() + // below for why they must never be rendered with next/link's . + plain: boolean; +}; // Privacy/Terms entries for a public-page footer, in display order. Each // entry is present only when its URL is configured — see PRIVACY_URL / @@ -100,13 +107,34 @@ export type LegalLink = { label: string; href: string; external: boolean }; // a self-host or staging build. Centralized here so the landing page, blog, // docs, and MCP footers all present the same pair rather than each growing // its own copy that can drift. +// +// `plain: true` marks every entry here as operator-supplied and possibly +// outside this Next app's own routing — the hosted deployment serves +// /privacy and /terms as static pages via a Caddy route, not as Next routes. +// Next's prefetches the target route's RSC payload on viewport/hover +// regardless of whether that route exists in this app, so using it for a +// same-origin legal path 404s the prefetch request in production even +// though the visible link works. Callers must render `plain` entries with a +// bare — same-origin opens with no target, cross-origin (`external`) +// keeps target="_blank" rel="noopener noreferrer" — and never next/link's +// , even though the href looks same-origin. export function legalFooterLinks(): LegalLink[] { const links: LegalLink[] = []; if (PRIVACY_URL) { - links.push({ label: "Privacy", href: PRIVACY_URL, external: isExternalURL(PRIVACY_URL) }); + links.push({ + label: "Privacy", + href: PRIVACY_URL, + external: isExternalURL(PRIVACY_URL), + plain: true, + }); } if (TERMS_URL) { - links.push({ label: "Terms", href: TERMS_URL, external: isExternalURL(TERMS_URL) }); + links.push({ + label: "Terms", + href: TERMS_URL, + external: isExternalURL(TERMS_URL), + plain: true, + }); } return links; }