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; }