diff --git a/README.md b/README.md
index 6c73d99..5ed3377 100644
--- a/README.md
+++ b/README.md
@@ -134,8 +134,26 @@ the hooks in `src/lib`.
### Route-level boundary (`src/app/error.tsx`)
Catches exceptions thrown inside individual route segments. Renders inside the
-root layout so the Header, Footer, and ToastProvider remain visible. Shows the
-error message and a "Try again" button that calls Next.js's `reset()`.
+root layout so the Header, Footer, and ToastProvider remain visible.
+
+Key features:
+
+- **Accessible error presentation:** Error message wrapped in `role="alert"` so
+ screen readers announce it immediately.
+- **Recovery action:** "Try again" button (using the shared `Button` component)
+ wired to Next.js's `reset()` callback allows users to retry without a full page
+ reload — essential for transient failures like flaky fetches.
+- **Production safety:** Only `error.message` is rendered; stack traces never
+ leak into the DOM.
+- **Debug support:** `error.digest` (if present) is logged to the console for
+ debugging but not prominently displayed to users.
+- **Keyboard accessible:** Button includes `focus-visible` outline and is fully
+ operable via keyboard.
+- **Dark mode compatible:** All styling supports both light and dark themes.
+
+Behaviour is covered by `src/app/error.test.tsx`, which asserts the alert region
+renders, the reset callback is invoked on click, no stack traces appear, and edge
+cases like empty messages are handled gracefully.
### Global boundary (`src/app/global-error.tsx`)
@@ -234,6 +252,10 @@ The following frontend routes are defined under `src/app/`:
The home page (`src/app/page.tsx`) renders the primary navigation entry points (Manage services, View stats, Record usage, Agents, Docs) and the external Stellar link inside a `` landmark with a semantic `` / `` list structure. This improves discoverability for screen-reader users.
+## 404 page recovery links
+
+The 404 page (`src/app/not-found.tsx`) renders a `` landmark below the error message with quick-return links to four primary surfaces (Home, Services, Stats, Docs). This gives users a semantic, keyboard-accessible path back into the app without relying on the browser back button. The navigation uses a `` / `` list structure and all links include `focus-visible` outlines for keyboard accessibility.
+
## Security headers
A baseline security header set (CSP, `X-Frame-Options: DENY`, `Referrer-Policy`, `X-Content-Type-Options`, `Permissions-Policy`, HSTS) is wired up in `next.config.ts` via `src/lib/securityHeaders.ts`. The CSP `connect-src` directive tracks `NEXT_PUBLIC_AGENTPAY_API_BASE` automatically; `` links to external sites (`https://stellar.org`, etc.) remain navigable.
diff --git a/src/app/error.test.tsx b/src/app/error.test.tsx
new file mode 100644
index 0000000..0fef198
--- /dev/null
+++ b/src/app/error.test.tsx
@@ -0,0 +1,306 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import ErrorBoundary from "./error";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeError(
+ message: string,
+ digest?: string
+): Error & { digest?: string } {
+ const err = new Error(message) as Error & { digest?: string };
+ if (digest !== undefined) err.digest = digest;
+ return err;
+}
+
+// Silence console.error noise produced by the useEffect log inside the
+// component so Jest output stays clean.
+beforeEach(() => {
+ jest.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — rendering", () => {
+ it("renders the heading", () => {
+ render( {}} />);
+ expect(
+ screen.getByRole("heading", { name: /something went wrong/i })
+ ).toBeInTheDocument();
+ });
+
+ it("renders the error message when one is provided", () => {
+ render(
+ {}} />
+ );
+ expect(screen.getByText("Network timeout")).toBeInTheDocument();
+ });
+
+ it("renders fallback copy when error.message is empty", () => {
+ render( {}} />);
+ expect(
+ screen.getByText(/an unexpected error occurred/i)
+ ).toBeInTheDocument();
+ });
+
+ it("renders a Try again button", () => {
+ render( {}} />);
+ expect(
+ screen.getByRole("button", { name: /try again/i })
+ ).toBeInTheDocument();
+ });
+
+ it("wraps error message in a role=alert region", () => {
+ render( {}} />);
+ const alert = screen.getByRole("alert");
+ expect(alert).toBeInTheDocument();
+ expect(alert).toHaveTextContent("oops");
+ });
+
+ it("renders the main landmark with id=main-content", () => {
+ render( {}} />);
+ const main = screen.getByRole("main");
+ expect(main).toHaveAttribute("id", "main-content");
+ expect(main).toHaveAttribute("tabIndex", "-1");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Button component integration
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — Button component", () => {
+ it("uses the Button component for Try again action", () => {
+ render( {}} />);
+ const button = screen.getByRole("button", { name: /try again/i });
+ expect(button).toHaveAttribute("type", "button");
+ // Button component applies specific classes
+ expect(button.className).toMatch(/rounded-full/);
+ expect(button.className).toMatch(/px-5/);
+ expect(button.className).toMatch(/py-2/);
+ });
+
+ it("Button has primary variant styling", () => {
+ render( {}} />);
+ const button = screen.getByRole("button", { name: /try again/i });
+ expect(button.className).toMatch(/bg-black/);
+ expect(button.className).toMatch(/text-white/);
+ });
+
+ it("Button has focus-visible outline", () => {
+ render( {}} />);
+ const button = screen.getByRole("button", { name: /try again/i });
+ expect(button.className).toMatch(/focus-visible:outline/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// reset callback
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — reset interaction", () => {
+ it("calls reset once when Try again is clicked", () => {
+ const reset = jest.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /try again/i }));
+ expect(reset).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls reset again on each subsequent click", () => {
+ const reset = jest.fn();
+ render( );
+ const btn = screen.getByRole("button", { name: /try again/i });
+ fireEvent.click(btn);
+ fireEvent.click(btn);
+ fireEvent.click(btn);
+ expect(reset).toHaveBeenCalledTimes(3);
+ });
+
+ it("does not call reset on render, only on click", () => {
+ const reset = jest.fn();
+ render( );
+ expect(reset).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Production safety — no stack traces in the DOM
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — production safety", () => {
+ it("does not render the error stack trace", () => {
+ const err = makeError("bad thing");
+ err.stack = "Error: bad thing\n at Component (file.tsx:10:5)";
+ const { container } = render(
+ {}} />
+ );
+ // The raw stack string must never appear anywhere in the rendered output.
+ expect(container.textContent).not.toContain(err.stack);
+ expect(container.textContent).not.toMatch(/at Component/);
+ });
+
+ it("does not render any stack-like content even when error has a long stack", () => {
+ const err = makeError("crash");
+ err.stack = [
+ "Error: crash",
+ " at ErrorBoundary (error.tsx:10:5)",
+ " at renderWithHooks (react-dom.development.js:14985:18)",
+ " at mountIndeterminateComponent (react-dom.development.js:17811:13)",
+ ].join("\n");
+ const { container } = render(
+ {}} />
+ );
+ expect(container.textContent).not.toMatch(/renderWithHooks/);
+ expect(container.textContent).not.toMatch(/mountIndeterminateComponent/);
+ });
+
+ it("only renders error.message, never error.stack", () => {
+ const err = makeError("User-friendly message");
+ err.stack = "Error: User-friendly message\n at dangerous stack trace";
+ const { container } = render(
+ {}} />
+ );
+ expect(container.textContent).toContain("User-friendly message");
+ expect(container.textContent).not.toContain("dangerous stack trace");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// console.error logging
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — error logging", () => {
+ it("logs the error via console.error on mount", () => {
+ const err = makeError("logged error");
+ render( {}} />);
+ expect(console.error).toHaveBeenCalledWith(
+ "App error boundary caught:",
+ err
+ );
+ });
+
+ it("logs the error digest when present", () => {
+ const err = makeError("crash", "abc-123");
+ render( {}} />);
+ expect(console.error).toHaveBeenCalledWith("Error digest:", "abc-123");
+ });
+
+ it("does not log digest when digest is absent", () => {
+ const err = makeError("crash");
+ render( {}} />);
+ expect(console.error).not.toHaveBeenCalledWith(
+ expect.stringMatching(/digest/),
+ expect.anything()
+ );
+ });
+
+ it("does not log digest when digest is undefined", () => {
+ const err = makeError("crash", undefined);
+ render( {}} />);
+ const calls = (console.error as jest.Mock).mock.calls;
+ const digestCalls = calls.filter((call) =>
+ call.some((arg: unknown) => typeof arg === "string" && arg.includes("digest"))
+ );
+ expect(digestCalls).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Dark mode styling
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — dark mode", () => {
+ it("applies dark mode classes to the error message", () => {
+ render( {}} />);
+ const alert = screen.getByRole("alert");
+ expect(alert.className).toMatch(/dark:text-zinc-400/);
+ });
+
+ it("main landmark supports dark mode focus styles", () => {
+ render( {}} />);
+ const main = screen.getByRole("main");
+ expect(main.className).toMatch(/focus:outline-none/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Accessibility
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — accessibility", () => {
+ it("error message is announced via role=alert", () => {
+ render( {}} />);
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Critical error");
+ });
+
+ it("Try again button is keyboard operable", () => {
+ const reset = jest.fn();
+ render( );
+ const button = screen.getByRole("button", { name: /try again/i });
+
+ // Simulate keyboard activation
+ button.focus();
+ fireEvent.keyDown(button, { key: "Enter", code: "Enter" });
+ fireEvent.click(button);
+
+ expect(reset).toHaveBeenCalled();
+ });
+
+ it("main landmark can receive focus for skip links", () => {
+ render( {}} />);
+ const main = screen.getByRole("main");
+ expect(main).toHaveAttribute("tabIndex", "-1");
+ expect(main).toHaveAttribute("id", "main-content");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Edge cases
+// ---------------------------------------------------------------------------
+
+describe("ErrorBoundary — edge cases", () => {
+ it("handles error with undefined message", () => {
+ const err = new Error() as Error & { digest?: string };
+ render( {}} />);
+ expect(
+ screen.getByText(/an unexpected error occurred/i)
+ ).toBeInTheDocument();
+ });
+
+ it("handles error with null-like properties", () => {
+ const err = {
+ message: "",
+ name: "Error",
+ } as Error & { digest?: string };
+ render( {}} />);
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ /an unexpected error occurred/i
+ );
+ });
+
+ it("handles very long error messages without breaking layout", () => {
+ const longMessage = "A".repeat(500);
+ render( {}} />);
+ expect(screen.getByRole("alert")).toHaveTextContent(longMessage);
+ });
+
+ it("renders correctly when error message contains HTML-like characters", () => {
+ render(
+ alert('xss')")}
+ reset={() => {}}
+ />
+ );
+ const alert = screen.getByRole("alert");
+ // Text content should be escaped, not parsed as HTML
+ expect(alert.textContent).toBe("");
+ });
+});
diff --git a/src/app/error.tsx b/src/app/error.tsx
index c10688e..bbf62ce 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
+import { Button } from "@/components/Button";
export default function ErrorBoundary({
error,
@@ -11,6 +12,9 @@ export default function ErrorBoundary({
}) {
useEffect(() => {
console.error("App error boundary caught:", error);
+ if (error.digest) {
+ console.error("Error digest:", error.digest);
+ }
}, [error]);
return (
@@ -20,16 +24,12 @@ export default function ErrorBoundary({
className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center gap-4 p-8 text-center focus:outline-none"
>
Something went wrong.
-
- {error.message || "Unexpected error."}
-
-
+
+ {error.message || "An unexpected error occurred."}
+
+
Try again
-
+
);
}
diff --git a/src/app/not-found.test.tsx b/src/app/not-found.test.tsx
new file mode 100644
index 0000000..cd03a6d
--- /dev/null
+++ b/src/app/not-found.test.tsx
@@ -0,0 +1,129 @@
+import { render, screen } from "@testing-library/react";
+import NotFound from "./not-found";
+
+describe("NotFound", () => {
+ it("renders the 404 heading", () => {
+ render( );
+ expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
+ });
+
+ it("renders the explanatory text", () => {
+ render( );
+ expect(screen.getByText("That page does not exist.")).toBeInTheDocument();
+ });
+
+ it("renders the main landmark", () => {
+ render( );
+ const main = screen.getByRole("main");
+ expect(main).toBeInTheDocument();
+ expect(main).toHaveAttribute("id", "main-content");
+ expect(main).toHaveAttribute("tabIndex", "-1");
+ });
+
+ it("renders the primary Back to home button", () => {
+ render( );
+ const backButton = screen.getByRole("link", { name: "Back to home" });
+ expect(backButton).toBeInTheDocument();
+ expect(backButton).toHaveAttribute("href", "/");
+ });
+
+ it("renders the helpful links navigation landmark", () => {
+ render( );
+ const nav = screen.getByRole("navigation", { name: "Helpful links" });
+ expect(nav).toBeInTheDocument();
+ });
+
+ it("renders a semantic list inside the navigation", () => {
+ render( );
+ const nav = screen.getByRole("navigation", { name: "Helpful links" });
+ const list = screen.getByRole("list");
+ expect(nav).toContainElement(list);
+ });
+
+ it("renders all four recovery links with correct labels and hrefs", () => {
+ render( );
+
+ const recoveryLinks = [
+ { name: "Home", href: "/" },
+ { name: "Services", href: "/services" },
+ { name: "Stats", href: "/stats" },
+ { name: "Docs", href: "/docs" },
+ ] as const;
+
+ const nav = screen.getByRole("navigation", { name: "Helpful links" });
+
+ for (const { name, href } of recoveryLinks) {
+ const link = screen.getByRole("link", { name });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", href);
+ expect(nav).toContainElement(link);
+ }
+ });
+
+ it("renders exactly four list items in the navigation", () => {
+ render( );
+ const nav = screen.getByRole("navigation", { name: "Helpful links" });
+ const listItems = screen.getAllByRole("listitem");
+ expect(listItems).toHaveLength(4);
+ listItems.forEach((item) => {
+ expect(nav).toContainElement(item);
+ });
+ });
+
+ it("does not render any links to routes that do not exist", () => {
+ render( );
+ // Ensure we're only linking to routes that actually exist
+ const allLinks = screen.getAllByRole("link");
+ const hrefs = allLinks.map((link) => link.getAttribute("href"));
+
+ // These are valid routes
+ expect(hrefs).toContain("/");
+ expect(hrefs).toContain("/services");
+ expect(hrefs).toContain("/stats");
+ expect(hrefs).toContain("/docs");
+
+ // Ensure we don't link to non-existent routes
+ expect(hrefs).not.toContain("/nonexistent");
+ expect(hrefs).not.toContain("/fake-route");
+ });
+
+ it("makes the primary button keyboard-accessible with focus-visible outline", () => {
+ render( );
+ const backButton = screen.getByRole("link", { name: "Back to home" });
+ expect(backButton).toHaveClass("focus-visible:outline");
+ });
+
+ it("makes all recovery links keyboard-accessible with focus-visible outline", () => {
+ render( );
+ const nav = screen.getByRole("navigation", { name: "Helpful links" });
+ const links = screen.getAllByRole("link").filter((link) => nav.contains(link));
+
+ links.forEach((link) => {
+ expect(link).toHaveClass("focus-visible:outline");
+ });
+ });
+
+ it("applies dark mode classes to the primary button", () => {
+ render( );
+ const backButton = screen.getByRole("link", { name: "Back to home" });
+ expect(backButton).toHaveClass("dark:bg-white");
+ expect(backButton).toHaveClass("dark:text-black");
+ });
+
+ it("applies dark mode classes to recovery links", () => {
+ render( );
+ const nav = screen.getByRole("navigation", { name: "Helpful links" });
+ const links = screen.getAllByRole("link").filter((link) => nav.contains(link));
+
+ links.forEach((link) => {
+ expect(link).toHaveClass("dark:text-blue-400");
+ expect(link).toHaveClass("dark:hover:text-blue-300");
+ });
+ });
+
+ it("renders exactly five total links (one primary + four recovery)", () => {
+ render( );
+ const allLinks = screen.getAllByRole("link");
+ expect(allLinks).toHaveLength(5);
+ });
+});
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index e1561b6..db32802 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -13,10 +13,47 @@ export default function NotFound() {
Back to home
+
+
+
+
+
+ Home
+
+
+
+
+ Services
+
+
+
+
+ Stats
+
+
+
+
+ Docs
+
+
+
+
);
}