diff --git a/apps/agent-client/package.json b/apps/agent-client/package.json index 4d670bb..8bc2932 100644 --- a/apps/agent-client/package.json +++ b/apps/agent-client/package.json @@ -12,7 +12,7 @@ "validate:real": "tsx src/validate-real.ts", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run", + "test": "vitest run --test-timeout=30000", "lint": "eslint src", "format": "prettier --write src", "format:check": "prettier --check src" diff --git a/apps/agent-client/src/cli.test.ts b/apps/agent-client/src/cli.test.ts index 4125076..7cbb875 100644 --- a/apps/agent-client/src/cli.test.ts +++ b/apps/agent-client/src/cli.test.ts @@ -5,7 +5,7 @@ import { resolve } from "path"; const execAsync = promisify(exec); // Workaround for Windows cross-platform testing -const tsx = process.platform === "win32" ? "npx.cmd tsx" : "npx tsx"; +const tsx = process.platform === "win32" ? "npx.cmd --yes tsx" : "npx --yes tsx"; const cliPath = resolve(__dirname, "cli.ts"); describe("CLI Validation", () => { diff --git a/apps/api/package.json b/apps/api/package.json index 72e0010..d89af72 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,8 +10,8 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run", - "test:coverage": "vitest run --coverage", + "test": "vitest run --test-timeout=30000", + "test:coverage": "vitest run --coverage --test-timeout=30000", "test:watch": "vitest", "migrate:analytics": "tsx src/scripts/migrate-db-json.ts", "lint": "eslint src", diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index f7dd956..a31a772 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -34,7 +34,7 @@ const envSchema = z.object({ STELLAR_RPC_URL: z.string().url().default("https://soroban-testnet.stellar.org"), X402_FACILITATOR_URL: z.string().url().default("https://channels.openzeppelin.com/x402/testnet"), X402_FACILITATOR_API_KEY: z.string().optional(), - X402_PAY_TO_ADDRESS: z.string().min(10, "X402_PAY_TO_ADDRESS is required"), + X402_PAY_TO_ADDRESS: z.string().min(10, "X402_PAY_TO_ADDRESS is required").optional(), API_BASE_URL: z.string().url().default("http://localhost:3001"), CORS_ORIGINS: z.string().optional(), DEMO_CLIENT_SECRET_KEY: z.string().optional(), @@ -93,6 +93,8 @@ export interface ConfigSnapshot { facilitatorApiKeyConfigured: boolean; /** Whether a pay-to Stellar address is configured */ payToConfigured: boolean; + /** The configured pay-to Stellar public address if available */ + payToAddress?: string; /** Whether sponsorship/subsidy mode is enabled */ sponsorshipEnabled: boolean; /** Whether a sponsorship signing secret is configured (value never exposed) */ @@ -112,6 +114,7 @@ export function getConfigSnapshot(): ConfigSnapshot { facilitatorConfigured: Boolean(config.X402_FACILITATOR_URL), facilitatorApiKeyConfigured: Boolean(config.X402_FACILITATOR_API_KEY), payToConfigured: Boolean(config.X402_PAY_TO_ADDRESS), + payToAddress: config.X402_PAY_TO_ADDRESS, sponsorshipEnabled: config.sponsorshipEnabled, sponsorshipSigningSecretConfigured: Boolean(config.SPONSORSHIP_SIGNING_SECRET), anyProviderKeyConfigured: Boolean( diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts index 65ffc07..d734332 100644 --- a/apps/api/src/lib/logger.ts +++ b/apps/api/src/lib/logger.ts @@ -1,4 +1,4 @@ -import pino from "pino"; +import { pino } from "pino"; export const logger = pino({ level: process.env.NODE_ENV === "development" ? "debug" : "info", diff --git a/apps/api/src/lib/payment-evidence.ts b/apps/api/src/lib/payment-evidence.ts index 74622c1..9c051f5 100644 --- a/apps/api/src/lib/payment-evidence.ts +++ b/apps/api/src/lib/payment-evidence.ts @@ -295,7 +295,7 @@ export function buildDemoPaymentEvidence(req: Request): DemoPaymentEvidence { providerId, amountUsd, network: config.STELLAR_NETWORK, - payTo: config.X402_PAY_TO_ADDRESS, + payTo: config.X402_PAY_TO_ADDRESS ?? "", facilitatorUrl: config.X402_FACILITATOR_URL, payer: req.header("x-demo-payer") ?? "demo-agent" }; diff --git a/apps/api/src/lib/persistence.ts b/apps/api/src/lib/persistence.ts index 6dcc79c..5a55166 100644 --- a/apps/api/src/lib/persistence.ts +++ b/apps/api/src/lib/persistence.ts @@ -49,7 +49,7 @@ function buildPaymentAttempt( amountUsd: input.priceUsd, network: config.STELLAR_NETWORK, payerPublicKey: input.payerPublicKey, - payToAddress: config.X402_PAY_TO_ADDRESS, + payToAddress: config.X402_PAY_TO_ADDRESS ?? "", facilitatorUrl: config.X402_FACILITATOR_URL, status: "settled", transactionHash: input.paymentResponseHeader ?? undefined, diff --git a/apps/api/src/lib/storage/memory.test.ts b/apps/api/src/lib/storage/memory.test.ts index 4ef1d91..1e08da5 100644 --- a/apps/api/src/lib/storage/memory.test.ts +++ b/apps/api/src/lib/storage/memory.test.ts @@ -6,8 +6,8 @@ import { buildTestPaymentAttempt, buildTestUsageEvent } from "../../test/storage describe("storage paths", () => { it("resolves relative data paths from the API package root", () => { - expect(resolveApiDataPath("data/analytics.db")).toMatch(/apps\/api\/data\/analytics\.db$/); - expect(resolveApiDataPath("data/analytics.db")).not.toContain("/src/data/"); + expect(resolveApiDataPath("data/analytics.db").replace(/\\/g, "/")).toMatch(/apps\/api\/data\/analytics\.db$/); + expect(resolveApiDataPath("data/analytics.db").replace(/\\/g, "/")).not.toContain("/src/data/"); }); }); diff --git a/apps/api/src/lib/x402.ts b/apps/api/src/lib/x402.ts index 5bf6315..b8a23fd 100644 --- a/apps/api/src/lib/x402.ts +++ b/apps/api/src/lib/x402.ts @@ -105,7 +105,7 @@ function demoMode402Middleware(req: Request, res: Response, next: NextFunction) scheme: "exact", network: config.STELLAR_NETWORK, price, - payTo: config.X402_PAY_TO_ADDRESS, + payTo: config.X402_PAY_TO_ADDRESS ?? "", facilitator: config.X402_FACILITATOR_URL }, instructions: @@ -184,7 +184,7 @@ export function createX402Middleware() { scheme: "exact", network, price: (context: HTTPRequestContext) => resolveRoutePrice(context, "search"), - payTo: config.X402_PAY_TO_ADDRESS + payTo: config.X402_PAY_TO_ADDRESS ?? "" }, description: "Paid search endpoint on Query402", settlementFailedResponseBody @@ -194,7 +194,7 @@ export function createX402Middleware() { scheme: "exact", network, price: (context: HTTPRequestContext) => resolveRoutePrice(context, "news"), - payTo: config.X402_PAY_TO_ADDRESS + payTo: config.X402_PAY_TO_ADDRESS ?? "" }, description: "Paid news endpoint on Query402", settlementFailedResponseBody @@ -204,7 +204,7 @@ export function createX402Middleware() { scheme: "exact", network, price: (context: HTTPRequestContext) => resolveRoutePrice(context, "scrape"), - payTo: config.X402_PAY_TO_ADDRESS + payTo: config.X402_PAY_TO_ADDRESS ?? "" }, description: "Paid scrape endpoint on Query402", settlementFailedResponseBody diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index a9262cf..b2b6d31 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -78,6 +78,29 @@ describe("public routes", () => { expect(response.body.diagnostics.payToConfigured).toBe(true); // TEST_WALLET is set by applySponsorshipTestEnv }); + it("health diagnostics exposes payToAddress when configured", async () => { + applyApiTestEnv({ X402_PAY_TO_ADDRESS: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" }); + const app = await createPublicApp(); + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + expect(response.body.diagnostics.payToConfigured).toBe(true); + expect(response.body.diagnostics.payToAddress).toBe("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + }); + + it("health diagnostics handles missing/empty payToAddress safely", async () => { + delete process.env.X402_PAY_TO_ADDRESS; + applyApiTestEnv({ X402_PAY_TO_ADDRESS: "" }); + delete process.env.X402_PAY_TO_ADDRESS; + + const app = await createPublicApp(); + const response = await request(app).get("/health"); + + expect(response.status).toBe(200); + expect(response.body.diagnostics.payToConfigured).toBe(false); + expect(response.body.diagnostics.payToAddress).toBeUndefined(); + }); + describe("health diagnostics — secret redaction", () => { it("never exposes raw secret values in health response body", async () => { // Set all secret-like env vars to recognisable sentinel values, diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index 78144f6..e39d5cd 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -12,7 +12,10 @@ import { ShieldCheck, Sparkles, TerminalSquare, - XCircle + XCircle, + Copy, + Check, + AlertTriangle } from "lucide-react"; import { Link } from "react-router-dom"; import type { AnalyticsResponse, PaidQueryResponse } from "../types.js"; @@ -45,6 +48,64 @@ function toTokenBaseUnits(amountUsd: number) { return normalizedAmount.replace(".", "").replace(/^0+/, "") || "0"; } +function PayToAddressDisplay({ + address, + network, + configured +}: { + address?: string; + network?: string; + configured?: boolean; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!address) return; + try { + await navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy address", err); + } + }; + + const isTestnet = + network?.toLowerCase().includes("testnet") || + network?.toLowerCase().includes("test sdf network") || + network?.toLowerCase().includes("september 2015"); + + if (!configured || !address) { + return ( + + + Missing pay-to address + + ); + } + + return ( + + + {address.slice(0, 6)}...{address.slice(-6)} + + {isTestnet && Testnet} + + + ); +} + export default function ControlDeckPage() { const [mode, setMode] = useState("search"); const [paymentMode, setPaymentMode] = useState<"wallet" | "sponsored">("wallet"); @@ -69,6 +130,7 @@ export default function ControlDeckPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [sponsorshipEnabled, setSponsorshipEnabled] = useState(false); + const [healthDiagnostics, setHealthDiagnostics] = useState<{ network?: string; payToConfigured?: boolean; payToAddress?: string } | null>(null); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(null); const [isPreviewLoading, setIsPreviewLoading] = useState(false); @@ -127,13 +189,28 @@ export default function ControlDeckPage() { useEffect(() => { async function bootstrap() { - const [providersResponse, sponsorshipActive] = await Promise.all([ + const [providersResponse, healthResponse] = await Promise.all([ fetchJson<{ providers: ProviderDefinition[] }>(`${API_BASE_URL}/api/providers`), - fetchSponsorshipEnabled(API_BASE_URL) + fetchJson<{ + sponsorshipEnabled?: boolean; + network?: string; + diagnostics?: { + network: string; + demoMode: boolean; + payToConfigured: boolean; + payToAddress?: string; + sponsorshipEnabled: boolean; + }; + }>(`${API_BASE_URL}/health`) ]); setProviders(providersResponse.providers); setSelectedProvider(modeDefaultProvider.search); - setSponsorshipEnabled(sponsorshipActive); + setSponsorshipEnabled(healthResponse.sponsorshipEnabled === true); + setHealthDiagnostics({ + network: healthResponse.network ?? healthResponse.diagnostics?.network, + payToConfigured: healthResponse.diagnostics?.payToConfigured ?? false, + payToAddress: healthResponse.diagnostics?.payToAddress + }); await refreshMetrics(); } @@ -455,7 +532,12 @@ export default function ControlDeckPage() { units)

- Pay-to: dynamic via x402 + Pay-to:{" "} +