diff --git a/docs/deployment.md b/docs/deployment.md
index 1bef5874e..467577f03 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -150,6 +150,8 @@ The Next.js dashboard ships as a static export, so its config is inlined at buil
| `NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION` | Google Search Console token. Only emitted into `
` when set, so forks don't inherit upstream's property. |
| `NEXT_PUBLIC_PRICING_PATH` | Site-relative path of the pricing page, when the deployment serves one (the hosted deployment sets `/pricing`). Only used to add the page to the sitemap. Leave empty if there is no pricing route — a sitemap entry that 404s is a crawl-quality problem. |
| `NEXT_PUBLIC_E2A_SIGN_IN_URL` | Sign-in door for the dashboard's "Sign in" links. Default: `/api/auth/login` (legacy Google OAuth). Set to `/api/auth/oidc/login` to make the generic OIDC door the default — only when the server runs with `E2A_OIDC_ENABLED=true`, otherwise the link 404s. OIDC copy remains provider-neutral for self-hosters; the upstream `e2a.dev` and `staging.e2a.dev` site builds identify their door as TokenCanopy. When OIDC is primary, signed-out app and OAuth-consent screens also show the legacy Google door as a fallback. |
+| `NEXT_PUBLIC_PRIVACY_URL` | Site-relative path or absolute URL of the deployment's Privacy Policy, when it has one (the hosted deployment's `legal/` pages live in the private ops repo). Adds a "Privacy" link to public-page footers and, together with `NEXT_PUBLIC_TERMS_URL`, a consent line under the sign-in call to action. Leave empty if there's no privacy page. |
+| `NEXT_PUBLIC_TERMS_URL` | Same as `NEXT_PUBLIC_PRIVACY_URL`, for the Terms of Service page. The sign-in consent line ("By signing in you agree to the Terms and Privacy policy.") only renders when **both** URLs are set — one without the other would either link nowhere or assert a policy that doesn't exist. |
## MCP HTTP server
diff --git a/web/.env.example b/web/.env.example
index 3288346ab..fd3081f5e 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -50,3 +50,13 @@ NEXT_PUBLIC_PRICING_PATH=
# legacy door and provider-neutral "Sign in" for OIDC. The upstream hosted
# e2a.dev builds identify their OIDC door as TokenCanopy using their site URL.
NEXT_PUBLIC_E2A_SIGN_IN_URL=
+
+# Optional. Site-relative path or absolute URL of the deployment's Privacy
+# Policy and Terms of Service pages. Neither is part of this app — like
+# pricing, the hosted deployment's legal pages live in the private ops repo
+# and these get baked in at image build time. Leave both empty for a
+# self-host or staging build: the footer links and the sign-in consent line
+# both stay hidden rather than pointing at a 404. The consent line only
+# appears when BOTH are set.
+NEXT_PUBLIC_PRIVACY_URL=
+NEXT_PUBLIC_TERMS_URL=
diff --git a/web/src/app/(app)/AppLayoutClient.tsx b/web/src/app/(app)/AppLayoutClient.tsx
index 34b8e9c65..88eefacfc 100644
--- a/web/src/app/(app)/AppLayoutClient.tsx
+++ b/web/src/app/(app)/AppLayoutClient.tsx
@@ -7,6 +7,7 @@ import { useAuth } from "../components/AuthProvider";
import { SWRProvider } from "../components/swr/SWRProvider";
import { PendingPollingOwner } from "../components/swr/PendingPollingOwner";
import { SignInLinks } from "../components/SignInLinks";
+import { SignInConsent } from "../components/SignInConsent";
import { RestoredNotice } from "../components/RestoredNotice";
import { ReadOnlyBanner } from "../components/ReadOnlyBanner";
import { Sidebar } from "../components/loft/Sidebar";
@@ -141,6 +142,10 @@ export default function AppLayout({
secondaryClassName="text-[13px] underline underline-offset-2"
secondaryStyle={{ color: "var(--fg-muted)" }}
/>
+
diff --git a/web/src/app/(app)/layout.legal-consent.hosted.test.tsx b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx
new file mode 100644
index 000000000..12983f6f3
--- /dev/null
+++ b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx
@@ -0,0 +1,68 @@
+/**
+ * Hosted-build branch of the dashboard's signed-out sign-in consent line:
+ * NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL are inlined at build time
+ * and read at module load (lib/site.PRIVACY_URL / TERMS_URL), so this file
+ * sets both env vars BEFORE importing AppLayoutClient. The unset/self-host
+ * branch (no consent line) is asserted in layout.test.tsx, where jest runs
+ * with the env vars absent.
+ */
+process.env.NEXT_PUBLIC_PRIVACY_URL = "/privacy";
+process.env.NEXT_PUBLIC_TERMS_URL = "/terms";
+
+import { render, screen, within } from "@testing-library/react";
+import AppLayout from "./AppLayoutClient";
+
+jest.mock("next/link", () => {
+ return function MockLink({
+ href,
+ children,
+ ...rest
+ }: {
+ href: string;
+ children: React.ReactNode;
+ [k: string]: unknown;
+ }) {
+ return (
+
+ {children}
+
+ );
+ };
+});
+
+jest.mock("next/navigation", () => ({
+ usePathname: () => "/inboxes",
+ useRouter: () => ({ replace: jest.fn(), push: jest.fn(), back: jest.fn() }),
+}));
+
+jest.mock("../components/AuthProvider", () => ({
+ useAuth: () => ({ user: null, loading: false }),
+}));
+
+jest.mock("../components/loft/Sidebar", () => ({
+ Sidebar: () => null,
+}));
+jest.mock("../components/swr/PendingPollingOwner", () => ({
+ PendingPollingOwner: () => null,
+}));
+
+describe("(app) layout — sign-in consent (hosted build)", () => {
+ it("renders the consent line beneath the sign-in links when both legal URLs are configured", () => {
+ render(
+
+
page content
+ ,
+ );
+ const consent = screen.getByTestId("sign-in-consent");
+ 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");
+ });
+});
diff --git a/web/src/app/(app)/layout.test.tsx b/web/src/app/(app)/layout.test.tsx
index f510eb3aa..31b0366de 100644
--- a/web/src/app/(app)/layout.test.tsx
+++ b/web/src/app/(app)/layout.test.tsx
@@ -84,6 +84,9 @@ describe("(app) layout — auth gates", () => {
expect(screen.getByRole("link", { name: "Sign in with Google" }))
.toHaveAttribute("href", "/api/auth/login");
expect(screen.queryByText("page content")).not.toBeInTheDocument();
+ // No legal URLs configured in jest (self-host/staging default) — the
+ // hosted branch is covered in layout.legal-consent.hosted.test.tsx.
+ expect(screen.queryByTestId("sign-in-consent")).not.toBeInTheDocument();
});
it("preserves a review deep link through sign-in", async () => {
diff --git a/web/src/app/blog/layout.tsx b/web/src/app/blog/layout.tsx
index 7b733dac1..a6c1878dd 100644
--- a/web/src/app/blog/layout.tsx
+++ b/web/src/app/blog/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE_URL } from "../../lib/site";
+import { LegalFooterLinks } from "../components/LegalFooterLinks";
const TITLE = "Blog — e2a, email for AI agents";
const DESC =
@@ -90,6 +91,10 @@ export default function BlogLayout({ children }: { children: React.ReactNode })
>
e2a
+ {
+ return function MockLink({
+ href,
+ children,
+ ...props
+ }: {
+ href: string;
+ children: React.ReactNode;
+ [key: string]: unknown;
+ }) {
+ return (
+
+ {children}
+
+ );
+ };
+});
+
+function loadLegalFooterLinks(): typeof import("./LegalFooterLinks") {
+ let mod: typeof import("./LegalFooterLinks") | undefined;
+ jest.isolateModules(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ mod = require("./LegalFooterLinks");
+ });
+ if (!mod) throw new Error("failed to load ./LegalFooterLinks");
+ return mod;
+}
+
+describe("LegalFooterLinks", () => {
+ const originalPrivacy = process.env[PRIVACY_ENV_KEY];
+ const originalTerms = process.env[TERMS_ENV_KEY];
+
+ afterEach(() => {
+ if (originalPrivacy === undefined) delete process.env[PRIVACY_ENV_KEY];
+ else process.env[PRIVACY_ENV_KEY] = originalPrivacy;
+ if (originalTerms === undefined) delete process.env[TERMS_ENV_KEY];
+ else process.env[TERMS_ENV_KEY] = originalTerms;
+ });
+
+ it("renders nothing on a self-host/staging build with neither URL configured", () => {
+ delete process.env[PRIVACY_ENV_KEY];
+ delete process.env[TERMS_ENV_KEY];
+ const { LegalFooterLinks } = loadLegalFooterLinks();
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders Privacy and Terms links when both are configured", () => {
+ process.env[PRIVACY_ENV_KEY] = "/privacy";
+ process.env[TERMS_ENV_KEY] = "/terms";
+ const { LegalFooterLinks } = loadLegalFooterLinks();
+ render();
+ expect(screen.getByRole("link", { name: "Privacy" })).toHaveAttribute(
+ "href",
+ "/privacy",
+ );
+ expect(screen.getByRole("link", { name: "Terms" })).toHaveAttribute(
+ "href",
+ "/terms",
+ );
+ });
+});
diff --git a/web/src/app/components/LegalFooterLinks.tsx b/web/src/app/components/LegalFooterLinks.tsx
new file mode 100644
index 000000000..d3d3b4efe
--- /dev/null
+++ b/web/src/app/components/LegalFooterLinks.tsx
@@ -0,0 +1,39 @@
+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.
+export function LegalFooterLinks({
+ className,
+ linkStyle,
+}: {
+ className?: string;
+ linkStyle?: React.CSSProperties;
+}) {
+ const links = legalFooterLinks();
+ if (links.length === 0) return null;
+ return (
+
+ );
+}
diff --git a/web/src/app/components/SignInConsent.test.tsx b/web/src/app/components/SignInConsent.test.tsx
new file mode 100644
index 000000000..bcd702f3f
--- /dev/null
+++ b/web/src/app/components/SignInConsent.test.tsx
@@ -0,0 +1,124 @@
+// SignInConsent reads NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL through
+// lib/site, which resolves env vars at module load (Next.js inlines them at
+// build time). Each scenario sets the env vars and re-imports the module
+// tree fresh — see lib/site.test.ts for the same isolateModules pattern.
+
+import { render, screen, within } from "@testing-library/react";
+
+export {};
+
+const PRIVACY_ENV_KEY = "NEXT_PUBLIC_PRIVACY_URL";
+const TERMS_ENV_KEY = "NEXT_PUBLIC_TERMS_URL";
+
+jest.mock("next/link", () => {
+ return function MockLink({
+ href,
+ children,
+ ...props
+ }: {
+ href: string;
+ children: React.ReactNode;
+ [key: string]: unknown;
+ }) {
+ return (
+
+ {children}
+
+ );
+ };
+});
+
+function loadSignInConsent(): typeof import("./SignInConsent") {
+ let mod: typeof import("./SignInConsent") | undefined;
+ jest.isolateModules(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ mod = require("./SignInConsent");
+ });
+ if (!mod) throw new Error("failed to load ./SignInConsent");
+ return mod;
+}
+
+describe("SignInConsent", () => {
+ const originalPrivacy = process.env[PRIVACY_ENV_KEY];
+ const originalTerms = process.env[TERMS_ENV_KEY];
+
+ afterEach(() => {
+ if (originalPrivacy === undefined) delete process.env[PRIVACY_ENV_KEY];
+ else process.env[PRIVACY_ENV_KEY] = originalPrivacy;
+ if (originalTerms === undefined) delete process.env[TERMS_ENV_KEY];
+ else process.env[TERMS_ENV_KEY] = originalTerms;
+ });
+
+ it("renders nothing when neither URL is configured", () => {
+ delete process.env[PRIVACY_ENV_KEY];
+ delete process.env[TERMS_ENV_KEY];
+ const { SignInConsent } = loadSignInConsent();
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when only the privacy URL is configured", () => {
+ process.env[PRIVACY_ENV_KEY] = "/privacy";
+ delete process.env[TERMS_ENV_KEY];
+ const { SignInConsent } = loadSignInConsent();
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when only the terms URL is configured", () => {
+ delete process.env[PRIVACY_ENV_KEY];
+ process.env[TERMS_ENV_KEY] = "/terms";
+ const { SignInConsent } = loadSignInConsent();
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders the exact consent wording with links to both pages when both are configured", () => {
+ process.env[PRIVACY_ENV_KEY] = "/privacy";
+ process.env[TERMS_ENV_KEY] = "/terms";
+ const { SignInConsent } = loadSignInConsent();
+ render();
+ const consent = screen.getByTestId("sign-in-consent");
+ 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");
+ });
+
+ it("renders a same-origin path as an in-app link 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");
+ });
+
+ it("opens an absolute cross-origin legal URL in a new tab", () => {
+ process.env[PRIVACY_ENV_KEY] = "https://legal.example.com/privacy";
+ process.env[TERMS_ENV_KEY] = "/terms";
+ const { SignInConsent } = loadSignInConsent();
+ render();
+ const consent = screen.getByTestId("sign-in-consent");
+ const privacyLink = within(consent).getByRole("link", {
+ name: "Privacy policy",
+ });
+ expect(privacyLink).toHaveAttribute(
+ "href",
+ "https://legal.example.com/privacy",
+ );
+ expect(privacyLink).toHaveAttribute("target", "_blank");
+ expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer");
+
+ const termsLink = within(consent).getByRole("link", { name: "Terms" });
+ expect(termsLink).not.toHaveAttribute("target");
+ });
+});
diff --git a/web/src/app/components/SignInConsent.tsx b/web/src/app/components/SignInConsent.tsx
new file mode 100644
index 000000000..11c6a582a
--- /dev/null
+++ b/web/src/app/components/SignInConsent.tsx
@@ -0,0 +1,50 @@
+import Link from "next/link";
+import { PRIVACY_URL, TERMS_URL } from "../../lib/site";
+
+// Rendered directly beneath the primary "Sign in" call to action — the
+// landing page nav and the dashboard's signed-out screen both use this so
+// the wording and gating logic live in exactly one place.
+//
+// Both legal pages are hosted-deployment-only (see PRIVACY_URL / TERMS_URL
+// in lib/site): a self-host or staging build that hasn't configured both
+// renders nothing here at all, rather than a half sentence or a link into a
+// 404. It is deliberately all-or-nothing — one URL without the other would
+// either link "Terms" nowhere or assert a policy that doesn't exist.
+export function SignInConsent({
+ className,
+ style,
+}: {
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ if (!PRIVACY_URL || !TERMS_URL) return null;
+ return (
+
+ By signing in you agree to the{" "}
+ Terms and{" "}
+ Privacy policy.
+
+ );
+}
+
+function LegalAnchor({
+ href,
+ children,
+}: {
+ href: string;
+ children: React.ReactNode;
+}) {
+ const style: React.CSSProperties = { textDecoration: "underline" };
+ if (href.startsWith("/")) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/app/docs/page.test.tsx b/web/src/app/docs/page.test.tsx
index f383b9b59..1b16d106b 100644
--- a/web/src/app/docs/page.test.tsx
+++ b/web/src/app/docs/page.test.tsx
@@ -106,4 +106,13 @@ describe("Docs page", () => {
expect(names).not.toContain("How do I give my AI agent an email address?");
expect(names).not.toContain("Which MCP clients does e2a work with?");
});
+
+ // No legal URLs configured in jest (self-host/staging default) — the
+ // shared LegalFooterLinks helper (see lib/site.ts) must render nothing.
+ // Its "both configured" branch is covered by its own component tests.
+ it("renders no Privacy or Terms links when no legal URLs are configured", () => {
+ render();
+ expect(screen.queryByRole("link", { name: "Privacy" })).toBeNull();
+ expect(screen.queryByRole("link", { name: "Terms" })).toBeNull();
+ });
});
diff --git a/web/src/app/docs/page.tsx b/web/src/app/docs/page.tsx
index 8ec558386..d4d654034 100644
--- a/web/src/app/docs/page.tsx
+++ b/web/src/app/docs/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { JsonLd } from "../components/JsonLd";
+import { LegalFooterLinks } from "../components/LegalFooterLinks";
import { breadcrumbs, faqPage, type FaqEntry } from "../../lib/jsonld";
// Deliberately a server component with no "use client". This route used to be
@@ -249,6 +250,10 @@ export default function DocsPage() {
>
e2a
+
Apache 2.0
diff --git a/web/src/app/mcp/page.tsx b/web/src/app/mcp/page.tsx
index 24bcdaa95..63f543500 100644
--- a/web/src/app/mcp/page.tsx
+++ b/web/src/app/mcp/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { SITE_URL } from "../../lib/site";
import { JsonLd } from "../components/JsonLd";
+import { LegalFooterLinks } from "../components/LegalFooterLinks";
import { breadcrumbs, faqPage, howTo, type FaqEntry } from "../../lib/jsonld";
// Deliberately a server component with no "use client". Everything on this
@@ -358,6 +359,10 @@ export default function McpPage() {
>
e2a
+
Apache 2.0
diff --git a/web/src/app/page.legal-links.hosted.test.tsx b/web/src/app/page.legal-links.hosted.test.tsx
new file mode 100644
index 000000000..73c1dcf03
--- /dev/null
+++ b/web/src/app/page.legal-links.hosted.test.tsx
@@ -0,0 +1,66 @@
+/**
+ * Hosted-build branch of the Privacy/Terms footer links and sign-in consent
+ * line: NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL are inlined at build
+ * time and read at module load (lib/site.PRIVACY_URL / TERMS_URL), so this
+ * file sets both env vars BEFORE importing the page. The unset/self-host
+ * branch (no links, no consent line) is asserted in page.test.tsx, where
+ * jest runs with the env vars absent — see page.pricing-nav.hosted.test.tsx
+ * for the same pattern applied to NEXT_PUBLIC_PRICING_PATH.
+ */
+process.env.NEXT_PUBLIC_PRIVACY_URL = "/privacy";
+process.env.NEXT_PUBLIC_TERMS_URL = "/terms";
+
+import { render, screen, within } from "@testing-library/react";
+import Home from "./page";
+
+jest.mock("next/link", () => {
+ return function MockLink({
+ href,
+ children,
+ ...props
+ }: {
+ href: string;
+ children: React.ReactNode;
+ [key: string]: unknown;
+ }) {
+ return (
+
+ {children}
+
+ );
+ };
+});
+
+jest.mock("./components/AuthProvider", () => ({
+ useAuth: () => ({ user: null, loading: false, signOut: jest.fn() }),
+}));
+
+describe("Privacy/Terms links and sign-in consent (hosted build)", () => {
+ it("renders Privacy and Terms in the footer", () => {
+ render();
+ const footer = screen.getByRole("contentinfo");
+ expect(within(footer).getByRole("link", { name: "Privacy" })).toHaveAttribute(
+ "href",
+ "/privacy",
+ );
+ expect(within(footer).getByRole("link", { name: "Terms" })).toHaveAttribute(
+ "href",
+ "/terms",
+ );
+ });
+
+ it("renders the sign-in consent line beneath the nav Sign in link with exact wording", () => {
+ render();
+ const consent = screen.getByTestId("sign-in-consent");
+ 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");
+ });
+});
diff --git a/web/src/app/page.test.tsx b/web/src/app/page.test.tsx
index b8c779fa6..3940e6562 100644
--- a/web/src/app/page.test.tsx
+++ b/web/src/app/page.test.tsx
@@ -330,6 +330,14 @@ describe("Navigation auth state", () => {
expect(screen.queryByText("Go to Dashboard")).not.toBeInTheDocument();
});
+ // No legal URLs configured in jest (self-host/staging default) — the
+ // consent line must not render at all. The hosted branch is covered in
+ // page.legal-links.hosted.test.tsx.
+ it("renders no sign-in consent line when no legal URLs are configured", () => {
+ render();
+ expect(screen.queryByTestId("sign-in-consent")).not.toBeInTheDocument();
+ });
+
it("shows loading indicator while checking auth", () => {
mockAuthValue = { user: null, loading: true, signOut: mockSignOut };
render();
@@ -376,6 +384,17 @@ describe("Footer", () => {
expect(screen.getAllByText("Apache 2.0").length).toBeGreaterThan(0);
});
+ // Self-host and staging builds have no legal pages configured
+ // (lib/site.PRIVACY_URL / TERMS_URL are unset in jest, matching a build
+ // without NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL), so the footer
+ // must render neither link rather than pointing at a 404. The hosted
+ // branch is covered in page.legal-links.hosted.test.tsx.
+ it("renders no Privacy or Terms links when no legal URLs are configured", () => {
+ render();
+ expect(screen.queryByRole("link", { name: "Privacy" })).toBeNull();
+ expect(screen.queryByRole("link", { name: "Terms" })).toBeNull();
+ });
+
it("links the GitHub repo from the footer", () => {
render();
const githubLink = screen
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
index a713353f2..04acbbf03 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/page.tsx
@@ -7,7 +7,8 @@ import { Eyebrow } from "@e2a/ui";
import { JsonLd } from "./components/JsonLd";
import { TokenCanopyBadge } from "./components/loft/TokenCanopyBadge";
import { faqPage, type FaqEntry } from "../lib/jsonld";
-import { PRICING_PATH, SIGN_IN_URL } from "../lib/site";
+import { PRICING_PATH, SIGN_IN_URL, legalFooterLinks } from "../lib/site";
+import { SignInConsent } from "./components/SignInConsent";
// Agent onboarding surfaces: install the plugin in a coding agent, or point
// any MCP runtime at the hosted server. Application integrations use the SDK
@@ -133,6 +134,10 @@ const FOOTER_LINKS: { label: string; href: string; external?: boolean }[] = [
{ label: "CLI", href: "https://www.npmjs.com/package/@e2a/cli", external: true },
{ label: "Plugin", href: "https://github.com/tokencanopy/e2a/tree/main/plugins/e2a", external: true },
{ label: "Feedback", href: "/feedback" },
+ // Privacy/Terms are hosted-deployment-only — see PRIVACY_URL/TERMS_URL in
+ // lib/site. Each is present only when its URL is configured, so a
+ // self-host or staging footer renders exactly as it did before.
+ ...legalFooterLinks(),
];
// The landing page's answer surface. Every entry is rendered twice: as visible
@@ -282,14 +287,25 @@ export default function Home() {
already have an account; the primary CTA beside it starts a
new one. Signed IN there is no such pair — both would just
be doors into the app — so the primary becomes the only
- one and points at the dashboard. */
-
- Sign in
-
+ one and points at the dashboard.
+ `relative` + the consent line's `absolute` positioning
+ (same pattern as NavMenu's dropdown below) keeps the
+ overlay out of the flex row's layout — it renders only on
+ the hosted build where both legal URLs are set, and must
+ not change nav height or wrap the other items. */
+
)}
{
expect(site.SIGN_IN_LABEL).toBe("Sign in");
});
});
+
+describe("site config — legal links", () => {
+ const PRIVACY_ENV_KEY = "NEXT_PUBLIC_PRIVACY_URL";
+ const TERMS_ENV_KEY = "NEXT_PUBLIC_TERMS_URL";
+ const originalPrivacy = process.env[PRIVACY_ENV_KEY];
+ const originalTerms = process.env[TERMS_ENV_KEY];
+ const originalSiteURL = process.env[SITE_URL_ENV_KEY];
+
+ afterEach(() => {
+ if (originalPrivacy === undefined) delete process.env[PRIVACY_ENV_KEY];
+ else process.env[PRIVACY_ENV_KEY] = originalPrivacy;
+ if (originalTerms === undefined) delete process.env[TERMS_ENV_KEY];
+ else process.env[TERMS_ENV_KEY] = originalTerms;
+ if (originalSiteURL === undefined) delete process.env[SITE_URL_ENV_KEY];
+ else process.env[SITE_URL_ENV_KEY] = originalSiteURL;
+ });
+
+ it("defaults both URLs to empty and hides both footer links", () => {
+ delete process.env[PRIVACY_ENV_KEY];
+ delete process.env[TERMS_ENV_KEY];
+ const site = loadSite();
+ expect(site.PRIVACY_URL).toBe("");
+ expect(site.TERMS_URL).toBe("");
+ expect(site.legalFooterLinks()).toEqual([]);
+ });
+
+ it("includes only Privacy when only the privacy URL is configured", () => {
+ process.env[PRIVACY_ENV_KEY] = "/privacy";
+ delete process.env[TERMS_ENV_KEY];
+ const site = loadSite();
+ expect(site.legalFooterLinks()).toEqual([
+ { label: "Privacy", href: "/privacy", external: false },
+ ]);
+ });
+
+ it("includes only Terms when only the terms URL is configured", () => {
+ delete process.env[PRIVACY_ENV_KEY];
+ process.env[TERMS_ENV_KEY] = "/terms";
+ const site = loadSite();
+ expect(site.legalFooterLinks()).toEqual([
+ { label: "Terms", href: "/terms", external: false },
+ ]);
+ });
+
+ it("includes Privacy then Terms, in that order, when both are configured", () => {
+ process.env[PRIVACY_ENV_KEY] = "/privacy";
+ 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 },
+ ]);
+ });
+
+ it("treats a same-origin path as internal", () => {
+ delete process.env[SITE_URL_ENV_KEY];
+ const site = loadSite();
+ expect(site.isExternalURL("/privacy")).toBe(false);
+ });
+
+ it("treats an absolute URL on a different origin as external", () => {
+ process.env[SITE_URL_ENV_KEY] = "https://e2a.dev";
+ process.env[PRIVACY_ENV_KEY] = "https://legal.example.com/privacy";
+ delete process.env[TERMS_ENV_KEY];
+ const site = loadSite();
+ expect(site.legalFooterLinks()).toEqual([
+ {
+ label: "Privacy",
+ href: "https://legal.example.com/privacy",
+ external: true,
+ },
+ ]);
+ });
+
+ it("treats an absolute URL matching SITE_URL's own origin as internal", () => {
+ process.env[SITE_URL_ENV_KEY] = "https://e2a.dev";
+ process.env[TERMS_ENV_KEY] = "https://e2a.dev/terms";
+ delete process.env[PRIVACY_ENV_KEY];
+ const site = loadSite();
+ expect(site.legalFooterLinks()).toEqual([
+ { label: "Terms", href: "https://e2a.dev/terms", external: false },
+ ]);
+ });
+
+ it("treats malformed input as internal rather than throwing", () => {
+ delete process.env[SITE_URL_ENV_KEY];
+ const site = loadSite();
+ expect(site.isExternalURL("not a url")).toBe(false);
+ });
+});
diff --git a/web/src/lib/site.ts b/web/src/lib/site.ts
index 680119ac4..2694f93d7 100644
--- a/web/src/lib/site.ts
+++ b/web/src/lib/site.ts
@@ -60,6 +60,57 @@ export const UMAMI_COLLECTOR_ORIGIN = process.env.NEXT_PUBLIC_UMAMI_COLLECTOR_OR
// and listing a 404 in a sitemap is a crawl-quality problem, not a no-op.
export const PRICING_PATH = process.env.NEXT_PUBLIC_PRICING_PATH || "";
+// Site-relative paths or absolute URLs of the hosted deployment's Privacy
+// Policy and Terms of Service pages.
+//
+// Like pricing, these are NOT part of this app: the hosted deployment's
+// legal pages live in the private ops repo and are baked in as
+// NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL at image build time. A
+// self-host or staging build that hasn't set them gets an empty string —
+// every footer link and the sign-in consent line that reference these stay
+// hidden entirely rather than pointing at a 404 or making a legal claim the
+// deployment can't back up. Either value may be a same-origin path (e.g.
+// "/privacy") or an absolute URL on a different host (e.g. the ops repo's
+// own static route).
+export const PRIVACY_URL = process.env.NEXT_PUBLIC_PRIVACY_URL || "";
+export const TERMS_URL = process.env.NEXT_PUBLIC_TERMS_URL || "";
+
+// True when `url` is an absolute URL whose origin differs from this
+// deployment's own SITE_URL — i.e. it needs target="_blank" + rel=noopener
+// like the other external FOOTER_LINKS, rather than an in-app . A
+// same-origin path (e.g. "/privacy") is always internal, and an absolute URL
+// that happens to resolve to SITE_URL's own origin is treated the same way
+// so a fully-qualified same-origin value behaves identically to a path.
+// Malformed input (neither a path nor a parseable absolute URL) is treated
+// as internal — the safer default for a value that's already misconfigured.
+export function isExternalURL(url: string): boolean {
+ if (!url || url.startsWith("/")) return false;
+ try {
+ return new URL(url).origin !== new URL(SITE_URL).origin;
+ } catch {
+ return false;
+ }
+}
+
+export type LegalLink = { label: string; href: string; external: 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.
+export function legalFooterLinks(): LegalLink[] {
+ const links: LegalLink[] = [];
+ if (PRIVACY_URL) {
+ links.push({ label: "Privacy", href: PRIVACY_URL, external: isExternalURL(PRIVACY_URL) });
+ }
+ if (TERMS_URL) {
+ links.push({ label: "Terms", href: TERMS_URL, external: isExternalURL(TERMS_URL) });
+ }
+ return links;
+}
+
// Sign-in entry point for the dashboard's "Sign in" links. Defaults to the
// legacy Google OAuth door, which every self-host deployment has. The hosted
// deployment bakes in NEXT_PUBLIC_E2A_SIGN_IN_URL=/api/auth/oidc/login at