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
28 changes: 20 additions & 8 deletions web/src/app/(app)/layout.legal-consent.hosted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Link> — see the
// comment on LegalAnchor in SignInConsent.tsx for why (same-origin legal
// paths can resolve outside this Next app, and <Link> prefetches the
// route's RSC payload regardless). This mock tags anything that DOES go
// through <Link> with a marker attribute so a Link-rendered legal link is
// distinguishable from a plain <a> even though both render as <a> tags.
jest.mock("next/link", () => {
return function MockLink({
href,
Expand All @@ -23,7 +29,7 @@ jest.mock("next/link", () => {
[k: string]: unknown;
}) {
return (
<a href={href} {...rest}>
<a href={href} {...rest} data-next-link="true">
{children}
</a>
);
Expand Down Expand Up @@ -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.
// <Link> 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");
});
});
25 changes: 24 additions & 1 deletion web/src/app/components/LegalFooterLinks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Link> — see the
// comment on legalFooterLinks() in lib/site.ts for why (same-origin legal
// paths can resolve outside this Next app, and <Link> prefetches the
// route's RSC payload regardless). This mock tags anything that DOES go
// through <Link> with a marker attribute so a Link-rendered legal link is
// distinguishable from a plain <a> even though both render as <a> tags.
jest.mock("next/link", () => {
return function MockLink({
href,
Expand All @@ -22,7 +28,7 @@ jest.mock("next/link", () => {
[key: string]: unknown;
}) {
return (
<a href={href} {...props}>
<a href={href} {...props} data-next-link="true">
{children}
</a>
);
Expand Down Expand Up @@ -72,4 +78,21 @@ describe("LegalFooterLinks", () => {
"/terms",
);
});

it("renders same-origin Privacy/Terms as plain anchors, never next/link's <Link>", () => {
// 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 <Link> 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(<LegalFooterLinks />);
expect(screen.getByRole("link", { name: "Privacy" })).not.toHaveAttribute(
"data-next-link",
);
expect(screen.getByRole("link", { name: "Terms" })).not.toHaveAttribute(
"data-next-link",
);
});
});
34 changes: 16 additions & 18 deletions web/src/app/components/LegalFooterLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import Link from "next/link";
import { legalFooterLinks } from "../../lib/site";

// Privacy/Terms links for a public-page footer that doesn't already carry
// its own link array (blog, docs, mcp — the landing page instead spreads
// 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 <a>, never
// next/link's <Link> — 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,
Expand All @@ -17,23 +21,17 @@ export function LegalFooterLinks({
if (links.length === 0) return null;
return (
<div className={className}>
{links.map((l) =>
l.external ? (
<a
key={l.label}
href={l.href}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{l.label}
</a>
) : (
<Link key={l.label} href={l.href} style={linkStyle}>
{l.label}
</Link>
),
)}
{links.map((l) => (
<a
key={l.label}
href={l.href}
target={l.external ? "_blank" : undefined}
rel={l.external ? "noopener noreferrer" : undefined}
style={linkStyle}
>
{l.label}
</a>
))}
</div>
);
}
20 changes: 15 additions & 5 deletions web/src/app/components/SignInConsent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Link> — see the
// comment on LegalAnchor in SignInConsent.tsx for why (same-origin legal
// paths can resolve outside this Next app, and <Link> prefetches the
// route's RSC payload regardless). This mock tags anything that DOES go
// through <Link> with a marker attribute so a Link-rendered legal link is
// distinguishable from a plain <a> even though both render as <a> tags.
jest.mock("next/link", () => {
return function MockLink({
href,
Expand All @@ -21,7 +27,7 @@ jest.mock("next/link", () => {
[key: string]: unknown;
}) {
return (
<a href={href} {...props}>
<a href={href} {...props} data-next-link="true">
{children}
</a>
);
Expand Down Expand Up @@ -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(<SignInConsent />);
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 <a>, never next/link's
// <Link> — <Link> 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", () => {
Expand Down
10 changes: 7 additions & 3 deletions web/src/app/components/SignInConsent.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <a>, never next/link's <Link>,
// 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 (
<Link href={href} style={style}>
<a href={href} style={style}>
{children}
</Link>
</a>
);
}
return (
Expand Down
23 changes: 22 additions & 1 deletion web/src/app/page.legal-links.hosted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Link> — see the
// comment on legalFooterLinks() in lib/site.ts for why (same-origin legal
// paths can resolve outside this Next app, and <Link> prefetches the
// route's RSC payload regardless). This mock tags anything that DOES go
// through <Link> with a marker attribute so a Link-rendered legal link is
// distinguishable from a plain <a> even though both render as <a> tags.
jest.mock("next/link", () => {
return function MockLink({
href,
Expand All @@ -24,7 +30,7 @@ jest.mock("next/link", () => {
[key: string]: unknown;
}) {
return (
<a href={href} {...props}>
<a href={href} {...props} data-next-link="true">
{children}
</a>
);
Expand All @@ -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 <Link>", () => {
// 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. <Link> would prefetch that route's RSC payload and 404 —
// regression coverage for the #1051 follow-up bug.
render(<Home />);
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(<Home />);
const consent = screen.getByTestId("sign-in-consent");
Expand Down
22 changes: 20 additions & 2 deletions web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a>, never
// next/link's <Link>, 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" },
Expand Down Expand Up @@ -1172,7 +1180,17 @@ export default function Home() {
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-[12px]">
{FOOTER_LINKS.map((l) =>
l.external ? (
l.plain ? (
<a
key={l.label}
href={l.href}
target={l.external ? "_blank" : undefined}
rel={l.external ? "noopener noreferrer" : undefined}
style={{ color: "var(--fg-muted)" }}
>
{l.label}
</a>
) : l.external ? (
<a
key={l.label}
href={l.href}
Expand Down
16 changes: 11 additions & 5 deletions web/src/lib/site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe("site config — legal links", () => {
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 },
]);
});

Expand All @@ -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 },
]);
});

Expand All @@ -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 },
]);
});

Expand All @@ -142,6 +142,7 @@ describe("site config — legal links", () => {
label: "Privacy",
href: "https://legal.example.com/privacy",
external: true,
plain: true,
},
]);
});
Expand All @@ -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,
},
]);
});

Expand Down
34 changes: 31 additions & 3 deletions web/src/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,49 @@ 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 <Link>.
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 /
// TERMS_URL — so every footer that spreads this in renders nothing extra on
// 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 <Link> 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 <a> — same-origin opens with no target, cross-origin (`external`)
// keeps target="_blank" rel="noopener noreferrer" — and never next/link's
// <Link>, 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;
}
Expand Down
Loading