From 9cd8e213bb995eb603d19b0ad400af2e8d48165f Mon Sep 17 00:00:00 2001 From: Senorespecial Date: Fri, 19 Jun 2026 12:15:02 +0000 Subject: [PATCH] fix: harden TextField tests, API base validation, CSP headers, and event log sanitization Resolves the four GrantFox OSS campaign issues assigned to @senorespecial: - #18: Add JSDoc to TextField.tsx and cover label / aria / description / error wiring with role-based queries in src/components/__tests__/TextField.test.tsx (8 tests, 100% component coverage). aria-invalid now always emits true|false so screen readers see a stable signal. - #24: Introduce src/lib/resolveApiBase.ts that validates NEXT_PUBLIC_AGENTPAY_API_BASE: trims whitespace, falls back to http://localhost:3001, strips trailing slashes, requires https in production unless the host is localhost / 127.0.0.1, and warns in development for non-https production-like hosts. Adopted by src/lib/apiClient.ts, src/app/export/page.tsx, and src/app/usage/page.tsx. Co-located tests in src/lib/__tests__/apiClient.test.ts hit 100% statement + branch coverage. - #25: Build a CSP + hardening-header map in src/lib/securityHeaders.ts (buildCsp + defaultSecurityHeaders) and wire it into every response via next.config.ts headers(). The CSP tracks the API origin for connect-src and includes 'unsafe-inline' in script-src so Next.js hydration still works with the static headers() pipeline. HSTS is emitted only in production. Tests in src/__tests__/securityHeaders.test.ts cover prod / dev / fallback paths at 100%. - #26: Add safeStringify + safeFormatTimestamp to src/lib/format.ts. safeStringify handles circular references, BigInt, symbols, functions, undefined, oversized payloads (capped at EVENT_PAYLOAD_MAX_CHARS = 5000 with a visible \u2026(truncated) marker) and never throws. safeFormatTimestamp falls back to '\u2014' for nullish / non-finite values. Adopted by src/app/events/page.tsx along with a safe String() coercion for the React key to avoid duplicate-key warnings. Tests in src/lib/__tests__/format.test.ts cover all branches. Additional fixes: - jest.setup.ts adds a minimal Response polyfill so the new apiClient tests using work in jest-environment-jsdom. - src/lib/apiClient.ts: reordered the fetch options spread so the default Content-Type: application/json is preserved when callers supply init.headers. - src/lib/apiClient.ts: split the throw line into two statements so the v8 coverage engine correctly marks the throw branch as covered. Verification: - npm run typecheck: exit 0 - npx jest: 14/14 suites, 96/96 tests pass - Coverage on new modules: 100% statements / functions / lines (applies to TextField.tsx, apiClient.ts, resolveApiBase.ts, securityHeaders.ts; format.ts is 100% stmt/func/line and 92.7% branch on intentionally-hard-to-hit defensive guards). Note: 5 pre-existing lint errors (react-hooks/set-state-in-effect in search/page.tsx, ThemeToggle.tsx, useApi.ts, useLocalState.ts and react-hooks/purity in TimeAgo.tsx) live on main and are out of scope for this PR; they are documented in the PR description for the maintainers. --- README.md | 18 ++ jest.setup.ts | 24 ++ next.config.ts | 25 +- package-lock.json | 30 +- src/__tests__/securityHeaders.test.ts | 136 +++++++++ src/app/events/page.tsx | 20 +- src/app/export/page.tsx | 5 +- src/app/usage/page.tsx | 4 +- src/components/TextField.tsx | 15 +- src/components/__tests__/TextField.test.tsx | 99 +++++++ src/lib/__tests__/apiClient.test.ts | 291 ++++++++++++++++++++ src/lib/__tests__/format.test.ts | 121 +++++++- src/lib/apiClient.ts | 18 +- src/lib/format.ts | 86 ++++++ src/lib/resolveApiBase.ts | 83 ++++++ src/lib/securityHeaders.ts | 108 ++++++++ 16 files changed, 1047 insertions(+), 36 deletions(-) create mode 100644 src/__tests__/securityHeaders.test.ts create mode 100644 src/components/__tests__/TextField.test.tsx create mode 100644 src/lib/__tests__/apiClient.test.ts create mode 100644 src/lib/resolveApiBase.ts create mode 100644 src/lib/securityHeaders.ts diff --git a/README.md b/README.md index 90152d3..a049986 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,32 @@ agentpay-frontend/ └── ci.yml # CI: build, test ``` +## Environment variables + +| Variable | Visibility | Default | Purpose | +|----------|------------|---------|---------| +| `NEXT_PUBLIC_AGENTPAY_API_BASE` | public (bundled into client JS) | `http://localhost:3001` | Base URL for the AgentPay backend. Validated by `resolveApiBase()` in `src/lib/resolveApiBase.ts` and rejected in production if non-https except for `localhost` / `127.0.0.1`. | + +Because the variable is `NEXT_PUBLIC_*`, its value is exposed to the browser. Never put API secrets in it - it is used only for routing public HTTP requests. + +## 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. + +## Event log rendering + +The `/events` page renders server-supplied JSON payloads. Each payload is serialised through `safeStringify` (`src/lib/format.ts`) with a hard cap (`EVENT_PAYLOAD_MAX_CHARS`, default 5,000 chars) and a visible `…(truncated)` marker. Circular references, `BigInt`, functions, and malformed timestamps are replaced with safe sentinels so a bad payload can't crash the page. + ## Commands | Command | Description | |--------|-------------| | `npm run build` | Production build | | `npm test` | Run Jest tests | +| `npm run test:coverage` | Run Jest with coverage | | `npm run dev` | Development server | | `npm run lint` | Run ESLint | +| `npm run typecheck` | Run the TypeScript compiler | ## CI/CD diff --git a/jest.setup.ts b/jest.setup.ts index d0de870..dca6f06 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1 +1,25 @@ import "@testing-library/jest-dom"; + +// Minimal Response polyfill for the jsdom test environment: jest-environment-jsdom +// 29 sometimes strips the native Response global, so a tiny shim lets the +// apiFetch tests use `new Response(JSON.stringify(body), { status })` without +// pulling in undici. +if (typeof global.Response === "undefined") { + class ResponsePolyfill { + body: string; + status: number; + constructor(body?: BodyInit | null, init?: ResponseInit) { + this.body = + typeof body === "string" ? body : body == null ? "" : String(body); + this.status = init?.status ?? 200; + } + get ok() { + return this.status >= 200 && this.status < 300; + } + async json() { + return JSON.parse(this.body || "null"); + } + } + (global as unknown as { Response: typeof ResponsePolyfill }).Response = + ResponsePolyfill as unknown as typeof global.Response; +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..4fa7670 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,30 @@ import type { NextConfig } from "next"; +import { defaultSecurityHeaders } from "./src/lib/securityHeaders"; +import { resolveApiBase } from "./src/lib/resolveApiBase"; + +// Resolve the API base once at build time so the CSP matches what the client +// will fetch against at runtime. +const apiBase = resolveApiBase(); const nextConfig: NextConfig = { - /* config options here */ + // Apply the security headers to every response served by Next.js. Routes + // can override individually later, but the baseline locks the dashboard + // down by default. + async headers() { + const headers = defaultSecurityHeaders({ + apiBase, + isDev: process.env.NODE_ENV !== "production", + }); + return [ + { + source: "/:path*", + headers: Object.entries(headers).map(([key, value]) => ({ + key, + value, + })), + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index e14abca..f27c228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2340,6 +2339,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2360,6 +2360,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2473,7 +2474,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2642,7 +2644,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2653,7 +2654,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2664,7 +2664,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2745,7 +2744,6 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -3279,7 +3277,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3830,7 +3827,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4439,6 +4435,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4501,7 +4498,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/domexception": { "version": "4.0.0", @@ -4825,7 +4823,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5011,7 +5008,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8095,6 +8091,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8916,6 +8913,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8931,6 +8929,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8943,7 +8942,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prompts": { "version": "2.4.2", @@ -9044,7 +9044,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9054,7 +9053,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9996,7 +9994,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10072,7 +10069,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10263,7 +10259,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10780,7 +10775,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/__tests__/securityHeaders.test.ts b/src/__tests__/securityHeaders.test.ts new file mode 100644 index 0000000..8fe04b8 --- /dev/null +++ b/src/__tests__/securityHeaders.test.ts @@ -0,0 +1,136 @@ +import { + buildCsp, + defaultSecurityHeaders, + originOf, + type BuildSecurityHeadersOptions, +} from "../lib/securityHeaders"; + +const prod: BuildSecurityHeadersOptions = { + apiBase: "https://api.example.com", +}; +const dev: BuildSecurityHeadersOptions = { + apiBase: "https://api.example.com", + isDev: true, +}; + +describe("originOf", () => { + it("returns origin for a fully qualified URL", () => { + expect(originOf("https://api.example.com/v1")).toBe("https://api.example.com"); + }); + + it("returns origin for a URL with a port", () => { + expect(originOf("http://localhost:3001")).toBe("http://localhost:3001"); + }); + + it("falls back to the localhost default origin for unparseable input", () => { + expect(originOf("not a url")).toBe("http://localhost:3001"); + }); +}); + +describe("buildCsp", () => { + it("includes default-src 'self'", () => { + expect(buildCsp(prod)).toMatch(/default-src 'self'/); + }); + + it("includes the api origin in connect-src", () => { + const csp = buildCsp({ apiBase: "https://api.example.com" }); + expect(csp).toContain("connect-src 'self' https://api.example.com"); + }); + + it("preserves a localhost origin in connect-src", () => { + const csp = buildCsp({ apiBase: "http://localhost:3001" }); + expect(csp).toContain("connect-src 'self' http://localhost:3001"); + }); + + it("includes 'self' for script-src in production", () => { + expect(buildCsp(prod)).toMatch(/script-src 'self'/); + expect(buildCsp(prod)).not.toMatch(/script-src[^;]*'unsafe-eval'/); + }); + + it("includes 'unsafe-inline' in production script-src so Next.js hydration scripts run", () => { + // next.config.ts headers() does not participate in the middleware nonce + // pipeline; without 'unsafe-inline' the inline __NEXT_DATA__ block would + // be blocked. If a future change removes this, add nonce-aware middleware + // at the same time. + expect(buildCsp(prod)).toMatch(/script-src[^;]*'unsafe-inline'/); + }); + + it("adds 'unsafe-eval' to script-src in development for Fast Refresh", () => { + expect(buildCsp(dev)).toContain("'unsafe-eval'"); + }); + + it("includes 'unsafe-inline' for style-src because next/font injects styles", () => { + expect(buildCsp(prod)).toContain("style-src 'self' 'unsafe-inline'"); + }); + + it("adds font data: uri support for icon fonts and font subsets", () => { + expect(buildCsp(prod)).toContain("font-src 'self' data:"); + }); + + it("adds img-src data: support", () => { + expect(buildCsp(prod)).toContain("img-src 'self' data:"); + }); + + it("disallows framing (clickjacking protection) via frame-ancestors", () => { + expect(buildCsp(prod)).toContain("frame-ancestors 'none'"); + }); + + it("disallows object-src entirely", () => { + expect(buildCsp(prod)).toContain("object-src 'none'"); + }); + + it("locks down form-action and base-uri to self", () => { + expect(buildCsp(prod)).toContain("form-action 'self'"); + expect(buildCsp(prod)).toContain("base-uri 'self'"); + }); + + it("does not include a `navigate-to` directive so external links open normally", () => { + expect(buildCsp(prod)).not.toMatch(/navigate-to/); + }); + + it("separates directives with semicolons", () => { + const csp = buildCsp(prod); + const segments = csp.split("; "); + expect(segments.length).toBeGreaterThanOrEqual(8); + }); +}); + +describe("defaultSecurityHeaders", () => { + it("returns every required hardening header (including HSTS) in production", () => { + const headers = defaultSecurityHeaders(prod); + expect(headers["Content-Security-Policy"]).toBeTruthy(); + expect(headers["X-Content-Type-Options"]).toBe("nosniff"); + expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin"); + expect(headers["X-Frame-Options"]).toBe("DENY"); + expect(headers["Permissions-Policy"]).toBeTruthy(); + expect(headers["Strict-Transport-Security"]).toMatch(/max-age=/); + }); + + it("omits Strict-Transport-Security in development so browsers don't cache the upgrade", () => { + const headers = defaultSecurityHeaders({ apiBase: "http://localhost:3001", isDev: true }); + expect(headers["Strict-Transport-Security"]).toBeUndefined(); + // Baseline headers should still be present in dev. + expect(headers["X-Content-Type-Options"]).toBe("nosniff"); + expect(headers["X-Frame-Options"]).toBe("DENY"); + expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin"); + expect(headers["Content-Security-Policy"]).toContain("script-src 'self' 'unsafe-inline' 'unsafe-eval'"); + }); + + it("disables a wide set of potentially sensitive browser features by default", () => { + const pp = defaultSecurityHeaders(prod)["Permissions-Policy"]; + expect(pp).toContain("camera=()"); + expect(pp).toContain("microphone=()"); + expect(pp).toContain("geolocation=()"); + expect(pp).toContain("payment=()"); + expect(pp).toContain("interest-cohort=()"); + }); + + it("derives the CSP connect-src from the api base", () => { + const headers = defaultSecurityHeaders({ + apiBase: "https://api.staging.agentpay.io/v2", + }); + expect(headers["Content-Security-Policy"]).toContain( + "connect-src 'self' https://api.staging.agentpay.io" + ); + }); +}); diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index ace2edc..d396482 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -2,6 +2,10 @@ import { useEffect, useState } from "react"; import { apiGet } from "@/lib/apiClient"; +import { + safeFormatTimestamp, + safeStringify, +} from "@/lib/format"; type AppEvent = { id: string; @@ -38,17 +42,19 @@ export default function EventsPage() { )} {items && items.length > 0 && (
    - {items.map((e) => ( + {items.map((e, i) => (
  1. -
    - {e.type} - {new Date(e.ts).toISOString()} +
    + {String(e.type ?? "")} + {safeFormatTimestamp(e.ts)}
    -
    -                {JSON.stringify(e.payload, null, 2)}
    +              
    +                {safeStringify(e.payload)}
                   
  2. ))} diff --git a/src/app/export/page.tsx b/src/app/export/page.tsx index 1a161bb..a311556 100644 --- a/src/app/export/page.tsx +++ b/src/app/export/page.tsx @@ -1,5 +1,6 @@ -const API_BASE = - process.env.NEXT_PUBLIC_AGENTPAY_API_BASE ?? "http://localhost:3001"; +import { resolveApiBase } from "@/lib/resolveApiBase"; + +const API_BASE = resolveApiBase(); export const metadata = { title: "Export" }; diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 185ba27..8320672 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { resolveApiBase } from "@/lib/resolveApiBase"; type QueryResult = { agent: string; @@ -8,8 +9,7 @@ type QueryResult = { total: number; } | null; -const API_BASE = - process.env.NEXT_PUBLIC_AGENTPAY_API_BASE ?? "http://localhost:3001"; +const API_BASE = resolveApiBase(); export default function UsagePage() { const [agent, setAgent] = useState(""); diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index cbd82bc..283a5b1 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,11 +1,24 @@ import { type InputHTMLAttributes, type ReactNode, useId } from "react"; type TextFieldProps = InputHTMLAttributes & { + /** Visible label rendered above the input. */ label: ReactNode; + /** Optional helper text rendered beneath the input. Linked via aria-describedby. */ description?: ReactNode; + /** Optional error message; when present flips aria-invalid and exposes the message via aria-describedby. */ error?: ReactNode; }; +/** + * Accessible text input with a label, optional description, and optional + * error message. Generates a stable internal id when none is supplied so the + * label, description, and error message all stay linked to the input via + * `htmlFor` / `id` / `aria-describedby` / `aria-invalid`. + * + * @example + * + * + */ export function TextField({ label, description, @@ -27,7 +40,7 @@ export function TextField({ diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx new file mode 100644 index 0000000..8fecfb3 --- /dev/null +++ b/src/components/__tests__/TextField.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from "@testing-library/react"; +import { TextField } from "../TextField"; + +// Query through the accessible name (via