diff --git a/app/frontend/src/app/marketplace/loading.tsx b/app/frontend/src/app/marketplace/loading.tsx index f8ff6ba31..59ca5b4fc 100644 --- a/app/frontend/src/app/marketplace/loading.tsx +++ b/app/frontend/src/app/marketplace/loading.tsx @@ -2,7 +2,12 @@ import * as React from "react"; export default function MarketplaceLoading() { return ( -
+
+ Loading marketplace listings…
diff --git a/app/frontend/src/app/marketplace/page.tsx b/app/frontend/src/app/marketplace/page.tsx index dd5856af8..bf2fd4ac1 100644 --- a/app/frontend/src/app/marketplace/page.tsx +++ b/app/frontend/src/app/marketplace/page.tsx @@ -1,7 +1,7 @@ "use client"; import dynamic from "next/dynamic"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { UsernameCard } from "@/components/UsernameCard"; import { ListingDetailModal } from "@/components/ListingDetailModal"; import type { MarketplaceListing } from "@/hooks/marketplaceApi"; @@ -110,13 +110,21 @@ function MarketplacePageContent() { // Stable connection-status derived from the provider const isConnected = realtimeApi.isConnected; + // Monotonic id guarding against stale responses: when a retry is triggered + // while a previous request is still in flight, only the newest request may + // commit listings, error, or loading state. + const loadRequestIdRef = useRef(0); + const loadListings = useCallback(async () => { + const requestId = ++loadRequestIdRef.current; try { setLoading(true); setError(null); const data = await marketplaceApi.fetchListings(); - setListings(data ?? []); + if (requestId !== loadRequestIdRef.current) return; + setListings(Array.isArray(data) ? data : []); } catch (err) { + if (requestId !== loadRequestIdRef.current) return; const captured = err instanceof Error ? err : new Error(String(err)); setError(captured.message || "Failed to load marketplace listings"); errorReporter.captureError(captured, { @@ -125,7 +133,9 @@ function MarketplacePageContent() { extra: { source: "MarketplacePageContent", operation: "fetchListings" }, }); } finally { - setLoading(false); + if (requestId === loadRequestIdRef.current) { + setLoading(false); + } } }, [marketplaceApi]); @@ -317,14 +327,15 @@ function MarketplacePageContent() {
)} - {!loading && !error && } + {listings.length > 0 && !error && } {/* ── CONTROLS ─────────────────────────────── */}
@@ -420,7 +431,7 @@ function MarketplacePageContent() {
{/* ── RESULTS COUNT ─────────────────────────── */} - {!loading && !error && ( + {listings.length > 0 && !error && (

{filtered.length} listing{filtered.length !== 1 ? "s" : ""} found {search && ` for "${search}"`} @@ -428,6 +439,8 @@ function MarketplacePageContent() { )} {/* ── GRID ─────────────────────────────────── */} + {/* Full-page failure only when nothing is on screen yet; with cached + listings the inline banner above handles retry without wiping data. */} {error && listings.length === 0 ? (

⚠️
@@ -443,13 +456,14 @@ function MarketplacePageContent() {
- ) : loading ? ( + ) : loading && listings.length === 0 ? (
{Array.from({ length: 6 }).map((_, i) => (
= minBid; + // Strict numeric parse — "12abc" and "" must both be rejected. + const parsedAmount = amount.trim() === "" ? NaN : Number(amount); + const isValid = Number.isFinite(parsedAmount) && parsedAmount >= minBid; useEffect(() => { if (!listing) return; @@ -43,17 +46,59 @@ export function BidModal({ listing, onClose, onBidSuccess }: BidModalProps) { }, [listing]); async function handleConfirm() { - if (!listing || !isValid) return; + // Double-submit guard: ignore re-entry while a request is in flight. + if (!listing || bidState === "loading") return; + + // Validate the request shape before any network submission. + const validation = validateBidRequest(listing.username, parsedAmount, { + minAmount: minBid, + }); + if (!validation.ok) { + setBidState("error"); + setErrorMsg(validation.reason); + return; + } + setBidState("loading"); setErrorMsg(""); - const result = await placeBid(listing.username, parsedAmount); - if (result.success) { - setBidState("success"); - onBidSuccess(listing.username, parsedAmount); - } else { + try { + const result = await placeBid(listing.username, parsedAmount); + if (result.success) { + setBidState("success"); + onBidSuccess(listing.username, parsedAmount); + return; + } setBidState("error"); setErrorMsg(result.reason); + // Capture the user-visible failure for observability. Only safe fields + // are attached — no request bodies or wallet material. + void errorReporter.captureError( + new Error(`Bid submission failed: ${result.reason}`), + { + route: "/marketplace", + codeOrigin: "BidModal.placeBid", + extra: { + source: "BidModal", + operation: "placeBid", + listingId: listing.id, + username: listing.username, + }, + }, + ); + } catch (err) { + // Defensive: providers resolve with results, but never let an + // unexpected rejection crash the modal. + setBidState("error"); + setErrorMsg("Something went wrong while placing your bid. Please try again."); + void errorReporter.captureError( + err instanceof Error ? err : new Error(String(err)), + { + route: "/marketplace", + codeOrigin: "BidModal.placeBid", + extra: { source: "BidModal", operation: "placeBid", listingId: listing.id }, + }, + ); } } diff --git a/app/frontend/src/hooks/__tests__/mockMarketplaceProvider.test.ts b/app/frontend/src/hooks/__tests__/mockMarketplaceProvider.test.ts index 402c98eee..766352423 100644 --- a/app/frontend/src/hooks/__tests__/mockMarketplaceProvider.test.ts +++ b/app/frontend/src/hooks/__tests__/mockMarketplaceProvider.test.ts @@ -147,6 +147,29 @@ describe("placeBid", () => { reason: expect.stringContaining("rejected"), }); }); + + it.each([ + ["empty username", "", 100], + ["NaN amount", "nova", NaN], + ["zero amount", "nova", 0], + ["negative amount", "nova", -1], + ])("rejects %s immediately, mirroring the production request contract", async (_label, username, amount) => { + let resolved = false; + const promise = mockMarketplaceProvider + .placeBid(username as string, amount as number) + .then((result) => { + resolved = true; + return result; + }); + + // Rejected synchronously — no simulated wallet delay is scheduled. + await Promise.resolve(); + expect(resolved).toBe(true); + await expect(promise).resolves.toMatchObject({ + success: false, + reason: expect.any(String), + }); + }); }); // ── formatCountdown ─────────────────────────────────────────────────────────── diff --git a/app/frontend/src/hooks/marketplaceApi.ts b/app/frontend/src/hooks/marketplaceApi.ts index 3eb33dba8..1aee851a2 100644 --- a/app/frontend/src/hooks/marketplaceApi.ts +++ b/app/frontend/src/hooks/marketplaceApi.ts @@ -49,6 +49,247 @@ export type UserListing = { export type BidResult = { success: true } | { success: false; reason: string }; +// ── Request validation ─────────────────────────────────────────────────────── + +/** Upper bound applied to usernames before they are sent to the backend. */ +export const MAX_USERNAME_LENGTH = 64; + +/** + * Result of validating an outbound request payload. + * + * `ok: false` carries a user-facing `reason` that is safe to render — it is + * produced locally and never contains raw server or network internals. + */ +export type ValidationResult = { ok: true } | { ok: false; reason: string }; + +const CONTROL_OR_WHITESPACE_RE = /[\s\u0000-\u001f\u007f]/; + +/** + * Validate a bid request *before* it reaches any network layer. + * + * Shared by the mock and production providers so local dev enforces exactly + * the same request contract as production. Structural checks only (types, + * bounds, control characters) — never assume a username charset policy that + * belongs to the backend. + */ +export function validateBidRequest( + username: unknown, + amount: unknown, + options?: { minAmount?: number }, +): ValidationResult { + if (typeof username !== "string" || username.trim().length === 0) { + return { ok: false, reason: "A target username is required to place a bid." }; + } + if ( + username.length > MAX_USERNAME_LENGTH || + CONTROL_OR_WHITESPACE_RE.test(username) + ) { + return { + ok: false, + reason: "This listing's username looks invalid. Refresh the page and try again.", + }; + } + if (typeof amount !== "number" || !Number.isFinite(amount)) { + return { ok: false, reason: "Enter a valid bid amount in USDC." }; + } + if (amount <= 0) { + return { ok: false, reason: "Your bid must be greater than zero." }; + } + const minAmount = options?.minAmount; + if (typeof minAmount === "number" && Number.isFinite(minAmount) && amount < minAmount) { + return { ok: false, reason: `Your bid must be at least ${minAmount} USDC.` }; + } + return { ok: true }; +} + +// ── Auth requirements ──────────────────────────────────────────────────────── + +/** + * localStorage key holding the bearer token used for authenticated + * marketplace endpoints (`/marketplace/bids/me`, `/marketplace/listings/me`). + */ +export const MARKETPLACE_AUTH_STORAGE_KEY = "RustAcademy.authToken"; + +/** Raised when an auth-only endpoint is requested while signed out. */ +export class MarketplaceAuthRequiredError extends Error { + constructor( + message = "Sign in with your Stellar wallet to view your bids and listings.", + ) { + super(message); + this.name = "MarketplaceAuthRequiredError"; + } +} + +/** Read the stored session token, or null when signed out / on the server. */ +export function getStoredAuthToken(): string | null { + if (typeof window === "undefined") return null; + try { + const token = window.localStorage.getItem(MARKETPLACE_AUTH_STORAGE_KEY); + if (typeof token !== "string") return null; + const trimmed = token.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +// ── Response sanitisation ──────────────────────────────────────────────────── +/** + * Real backend payloads are untyped JSON: dates arrive as ISO strings, + * numbers may arrive as strings, and fields can be missing entirely. + * Rendering such values unvalidated crashes components that call + * `.getTime()` / `.toLocaleString()`. These coercers turn raw payloads into + * the domain types above, dropping entries that cannot be repaired. + */ + +function coerceFiniteNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function coerceDate(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + if (typeof value === "string" || typeof value === "number") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; +} + +function coerceNonEmptyString(value: unknown, maxLength: number): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > maxLength) return null; + return trimmed; +} + +const LISTING_CATEGORIES: MarketplaceListing["category"][] = [ + "trending", + "short", + "og", + "crypto", + "brand", +]; + +const LISTING_STATUSES: UsernameStatus[] = ["auction", "buyNow", "sold", "listed"]; + +/** Coerce one raw backend listing into a {@link MarketplaceListing}, or null. */ +export function sanitizeListing(raw: unknown): MarketplaceListing | null { + if (typeof raw !== "object" || raw === null) return null; + const record = raw as Record; + + const id = coerceNonEmptyString(record.id, 128); + const username = coerceNonEmptyString(record.username, MAX_USERNAME_LENGTH); + const currentBid = coerceFiniteNumber(record.currentBid); + const ownerAddress = + typeof record.ownerAddress === "string" ? record.ownerAddress : null; + const endsAt = coerceDate(record.endsAt); + const createdAt = coerceDate(record.createdAt); + const status = LISTING_STATUSES.find((s) => s === record.status); + const category = LISTING_CATEGORIES.find((c) => c === record.category); + + if ( + id === null || + username === null || + currentBid === null || + ownerAddress === null || + endsAt === null || + createdAt === null || + status === undefined || + category === undefined + ) { + return null; + } + + const buyNowPrice = + record.buyNowPrice === null || record.buyNowPrice === undefined + ? null + : coerceFiniteNumber(record.buyNowPrice); + + return { + id, + username, + currentBid, + buyNowPrice, + ownerAddress, + endsAt, + createdAt, + status, + category, + bidCount: coerceFiniteNumber(record.bidCount) ?? 0, + watchers: coerceFiniteNumber(record.watchers) ?? 0, + verified: Boolean(record.verified), + }; +} + +/** Sanitize a raw listings response; invalid entries are dropped. */ +export function sanitizeListings(raw: unknown): MarketplaceListing[] { + if (!Array.isArray(raw)) return []; + const sanitized: MarketplaceListing[] = []; + for (const entry of raw) { + const listing = sanitizeListing(entry); + if (listing) sanitized.push(listing); + } + return sanitized; +} + +/** Coerce a raw `/marketplace/bids/me` payload into {@link UserBid}s. */ +export function sanitizeUserBids(raw: unknown): UserBid[] { + if (!Array.isArray(raw)) return []; + const sanitized: UserBid[] = []; + for (const entry of raw) { + if (typeof entry !== "object" || entry === null) continue; + const record = entry as Record; + const username = coerceNonEmptyString(record.username, MAX_USERNAME_LENGTH); + const myBid = coerceFiniteNumber(record.myBid); + const currentBid = coerceFiniteNumber(record.currentBid); + const endsAt = coerceDate(record.endsAt); + if (username === null || myBid === null || currentBid === null || endsAt === null) { + continue; + } + sanitized.push({ + username, + myBid, + currentBid, + endsAt, + isWinning: Boolean(record.isWinning), + }); + } + return sanitized; +} + +/** Coerce a raw `/marketplace/listings/me` payload into {@link UserListing}s. */ +export function sanitizeUserListings(raw: unknown): UserListing[] { + if (!Array.isArray(raw)) return []; + const sanitized: UserListing[] = []; + for (const entry of raw) { + if (typeof entry !== "object" || entry === null) continue; + const record = entry as Record; + const username = coerceNonEmptyString(record.username, MAX_USERNAME_LENGTH); + const minBid = coerceFiniteNumber(record.minBid); + const currentBid = coerceFiniteNumber(record.currentBid); + const bidCount = coerceFiniteNumber(record.bidCount); + const endsAt = coerceDate(record.endsAt); + if ( + username === null || + minBid === null || + currentBid === null || + bidCount === null || + endsAt === null + ) { + continue; + } + sanitized.push({ username, minBid, currentBid, bidCount, endsAt }); + } + return sanitized; +} + /** * Human-readable countdown to the given date. * e.g. "2d 3h", "47m", "Ended" diff --git a/app/frontend/src/hooks/providers/__tests__/productionMarketplaceProvider.test.ts b/app/frontend/src/hooks/providers/__tests__/productionMarketplaceProvider.test.ts new file mode 100644 index 000000000..ae01c7361 --- /dev/null +++ b/app/frontend/src/hooks/providers/__tests__/productionMarketplaceProvider.test.ts @@ -0,0 +1,246 @@ +/** + * Unit tests for productionMarketplaceProvider + * + * Covers the production-safe request lifecycle contract: + * 1. Request-shape validation happens before any network submission. + * 2. Auth-only endpoints fail fast (no doomed network calls) when signed + * out, and attach the Authorization header when a token exists. + * 3. Backend payloads are sanitised into domain types (ISO date strings → + * Date, string numbers → number, invalid entries dropped). + * 4. Network/API failures resolve to safe, user-facing messages that do + * not leak internal paths or status internals. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { productionMarketplaceProvider } from "@/hooks/providers/productionMarketplaceProvider"; +import { + MARKETPLACE_AUTH_STORAGE_KEY, + MarketplaceAuthRequiredError, +} from "@/hooks/marketplaceApi"; + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as unknown as Response; +} + +const VALID_LISTING_JSON = { + id: "listing-1", + username: "nova", + currentBid: 1400, + buyNowPrice: 4000, + ownerAddress: "GBXT...2R7K", + endsAt: new Date("2030-01-01T00:00:00.000Z").toISOString(), + createdAt: new Date("2029-12-01T00:00:00.000Z").toISOString(), + status: "auction", + category: "brand", + bidCount: "8", + watchers: 54, + verified: true, +}; + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse([VALID_LISTING_JSON])), + ); + window.localStorage.clear(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + window.localStorage.clear(); + vi.restoreAllMocks(); +}); + +// ── fetchListings ───────────────────────────────────────────────────────────── + +describe("fetchListings", () => { + it("sanitises raw JSON into domain listings (ISO strings become Dates)", async () => { + const listings = await productionMarketplaceProvider.fetchListings(); + + expect(listings).toHaveLength(1); + const listing = listings[0]; + expect(listing.endsAt).toBeInstanceOf(Date); + expect(listing.endsAt.getTime()).toBe(Date.parse("2030-01-01T00:00:00.000Z")); + expect(listing.createdAt).toBeInstanceOf(Date); + // String-encoded numeric fields are coerced. + expect(listing.bidCount).toBe(8); + expect(typeof listing.currentBid).toBe("number"); + }); + + it("drops entries that cannot be repaired instead of crashing the UI", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse([ + VALID_LISTING_JSON, + { id: "broken", username: "" }, + null, + "garbage", + ]), + ), + ); + + const listings = await productionMarketplaceProvider.fetchListings(); + expect(listings).toHaveLength(1); + expect(listings[0].id).toBe("listing-1"); + }); + + it("throws a sanitized message on server failure without internal details", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ error: "boom" }, 500)), + ); + + await expect(productionMarketplaceProvider.fetchListings()).rejects.toThrow( + /temporarily unavailable/, + ); + }); + + it("maps network failures to a connection message", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), + ); + + await expect(productionMarketplaceProvider.fetchListings()).rejects.toThrow( + /Cannot reach the marketplace/, + ); + }); +}); + +// ── auth requirements ──────────────────────────────────────────────────────── + +describe("authenticated endpoints", () => { + it.each(["fetchUserBids", "fetchUserListings"] as const)( + "%s fails fast with MarketplaceAuthRequiredError when signed out (no network call)", + async (method) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + productionMarketplaceProvider[method](), + ).rejects.toBeInstanceOf(MarketplaceAuthRequiredError); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("fetchUserBids attaches the stored bearer token", async () => { + window.localStorage.setItem(MARKETPLACE_AUTH_STORAGE_KEY, "test-token-123"); + + await productionMarketplaceProvider.fetchUserBids(); + + const calls = vi.mocked(fetch).mock.calls; + expect(calls.length).toBeGreaterThan(0); + const [, init] = calls[calls.length - 1] ?? []; + const headers = new Headers( + (init?.headers ?? undefined) as HeadersInit | undefined, + ); + expect(headers.get("Authorization")).toBe("Bearer test-token-123"); + }); + + it("fetchUserBids sanitises the raw payload", async () => { + window.localStorage.setItem(MARKETPLACE_AUTH_STORAGE_KEY, "tok"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse([ + { + username: "nova", + myBid: 1200, + currentBid: "1400", + endsAt: "2030-01-01T00:00:00.000Z", + isWinning: false, + }, + ]), + ), + ); + + const bids = await productionMarketplaceProvider.fetchUserBids(); + expect(bids).toHaveLength(1); + expect(bids[0].endsAt).toBeInstanceOf(Date); + expect(bids[0].currentBid).toBe(1400); + }); +}); + +// ── placeBid ───────────────────────────────────────────────────────────────── + +describe("placeBid request validation", () => { + it.each([ + ["empty username", "", 100], + ["non-string username", undefined, 100], + ["username with whitespace", "no va", 100], + ["NaN amount", "nova", NaN], + ["zero amount", "nova", 0], + ["negative amount", "nova", -5], + ["infinite amount", "nova", Infinity], + ])("rejects %s before any network submission", async (_label, username, amount) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await productionMarketplaceProvider.placeBid( + username as string, + amount as number, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason.length).toBeGreaterThan(0); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("placeBid error boundaries", () => { + it("returns success for an accepted bid", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ success: true })), + ); + + await expect( + productionMarketplaceProvider.placeBid("nova", 1500), + ).resolves.toEqual({ success: true }); + }); + + it("passes through bounded server-provided rejection reasons", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ success: false, reason: "You have been outbid." }), + ), + ); + + const result = await productionMarketplaceProvider.placeBid("nova", 1500); + expect(result).toEqual({ success: false, reason: "You have been outbid." }); + }); + + it("never leaks internal request details in failure reasons", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({}, 500)), + ); + + const result = await productionMarketplaceProvider.placeBid("nova", 1500); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).not.toMatch(/marketplace\/bids/); + expect(result.reason).not.toMatch(/\b500\b/); + expect(result.reason).toMatch(/temporarily unavailable|could not be placed/); + } + }); + + it("falls back to a default reason for malformed response bodies", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ unexpected: true })), + ); + + const result = await productionMarketplaceProvider.placeBid("nova", 1500); + expect(result.success).toBe(false); + }); +}); diff --git a/app/frontend/src/hooks/providers/mockMarketplaceProvider.ts b/app/frontend/src/hooks/providers/mockMarketplaceProvider.ts index dac0d1ebb..e79f274ed 100644 --- a/app/frontend/src/hooks/providers/mockMarketplaceProvider.ts +++ b/app/frontend/src/hooks/providers/mockMarketplaceProvider.ts @@ -12,6 +12,7 @@ import type { UserBid, UserListing, } from "@/hooks/marketplaceApi"; +import { validateBidRequest } from "@/hooks/marketplaceApi"; // ── Static mock data ───────────────────────────────────────────────────────── @@ -197,6 +198,13 @@ export const mockMarketplaceProvider: MarketplaceApiProvider = { }, async placeBid(username: string, amount: number): Promise { + // Enforce the exact same request-shape contract as the production + // provider so local dev and tests mirror real submission behaviour. + const validation = validateBidRequest(username, amount); + if (!validation.ok) { + return { success: false, reason: validation.reason }; + } + return new Promise((resolve) => { setTimeout(() => { // Simulate ~10 % wallet-rejection rate. diff --git a/app/frontend/src/hooks/providers/productionMarketplaceProvider.ts b/app/frontend/src/hooks/providers/productionMarketplaceProvider.ts index 0e936dac6..d59d73f29 100644 --- a/app/frontend/src/hooks/providers/productionMarketplaceProvider.ts +++ b/app/frontend/src/hooks/providers/productionMarketplaceProvider.ts @@ -1,15 +1,20 @@ /** * Production marketplace provider. * - * Calls the real RustAcademy backend REST API. Replace the TODO stubs - * below with genuine fetch calls once the endpoints are finalised. + * Calls the real RustAcademy backend REST API with production-safe request + * lifecycle management: + * - request-shape validation before any network submission, + * - auth requirements enforced client-side (fail fast, no doomed requests), + * - response sanitisation so malformed payloads never reach the UI, + * - error boundaries that map raw network/API failures to safe, user-facing + * messages without leaking internal request details. * * Environment variable: * NEXT_PUBLIC_RustAcademy_API_URL — backend base URL (no trailing slash) * Defaults to http://localhost:4000 when unset. */ -import { getRustAcademyApiBase } from "@/lib/api"; +import { getRustAcademyApiBase, isNetworkError } from "@/lib/api"; import type { BidResult, MarketplaceApiProvider, @@ -17,46 +22,167 @@ import type { UserBid, UserListing, } from "@/hooks/marketplaceApi"; +import { + getStoredAuthToken, + MarketplaceAuthRequiredError, + sanitizeListings, + sanitizeUserBids, + sanitizeUserListings, + validateBidRequest, +} from "@/hooks/marketplaceApi"; + +const REQUEST_TIMEOUT_MS = 10_000; +const MAX_REASON_LENGTH = 200; +const DEFAULT_BID_FAILURE = + "Your bid could not be placed. Please try again in a moment."; + +/** API-level failure carrying the HTTP status for upstream classification. */ +class MarketplaceApiError extends Error { + readonly status: number; + constructor(status: number, path: string) { + super(`Marketplace API error ${status} on ${path}`); + this.name = "MarketplaceApiError"; + this.status = status; + } +} + +async function apiFetch( + path: string, + init?: RequestInit & { authToken?: string | null }, +): Promise { + const { authToken, ...requestInit } = init ?? {}; + + const headers = new Headers(requestInit.headers); + headers.set("Accept", "application/json"); + if (requestInit.body) { + headers.set("Content-Type", "application/json"); + } + if (authToken) { + // Attached per-request; never logged or embedded in error messages. + headers.set("Authorization", `Bearer ${authToken}`); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let res: Response; + try { + res = await fetch(`${getRustAcademyApiBase()}${path}`, { + ...requestInit, + headers, + signal: requestInit.signal ?? controller.signal, + }); + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error && err.name === "AbortError") { + throw new Error("The marketplace request timed out. Please try again."); + } + throw err; + } + clearTimeout(timeoutId); -async function apiFetch(path: string, init?: RequestInit): Promise { - const url = `${getRustAcademyApiBase()}${path}`; - const res = await fetch(url, { - headers: { "Content-Type": "application/json" }, - ...init, - }); if (!res.ok) { - throw new Error(`API error ${res.status} on ${path}`); + throw new MarketplaceApiError(res.status, path); } return res.json() as Promise; } +/** Map an internal failure to a user-facing message with no internals leaked. */ +function describeFetchFailure(error: unknown, fallback: string): string { + if (error instanceof MarketplaceAuthRequiredError) return error.message; + if ( + isNetworkError(error) || + (error instanceof Error && + (error.name === "AbortError" || /timed out/.test(error.message))) + ) { + return "Cannot reach the marketplace right now. Check your connection and retry."; + } + if (error instanceof MarketplaceApiError) { + if (error.status === 401 || error.status === 403) { + return "Your session has expired. Sign in again to continue."; + } + if (error.status >= 500) { + return "The marketplace is temporarily unavailable. Please retry shortly."; + } + return fallback; + } + return fallback; +} + +/** Extract a bounded, user-safe reason from a bid endpoint response body. */ +function parseBidResponse(raw: unknown): BidResult | null { + if (typeof raw !== "object" || raw === null) return null; + const record = raw as Record; + if (typeof record.success !== "boolean") return null; + if (record.success) return { success: true }; + const reason = + typeof record.reason === "string" && record.reason.trim().length > 0 + ? record.reason.trim().slice(0, MAX_REASON_LENGTH) + : DEFAULT_BID_FAILURE; + return { success: false, reason }; +} + export const productionMarketplaceProvider: MarketplaceApiProvider = { async fetchListings(): Promise { - // TODO: wire up to GET /marketplace/listings - return apiFetch("/marketplace/listings"); + try { + const raw = await apiFetch("/marketplace/listings"); + return sanitizeListings(raw); + } catch (err) { + // Rethrow a sanitized message; the calling page owns user-visible + // state and error reporting, and never sees internal details. + throw new Error(describeFetchFailure(err, "Failed to load marketplace listings.")); + } }, async fetchUserBids(): Promise { - // TODO: wire up to GET /marketplace/bids/me (requires auth header) - return apiFetch("/marketplace/bids/me"); + // Auth-only endpoint — validate the requirement before submitting + // instead of making a doomed network call. + const authToken = getStoredAuthToken(); + if (!authToken) throw new MarketplaceAuthRequiredError(); + + try { + const raw = await apiFetch("/marketplace/bids/me", { authToken }); + return sanitizeUserBids(raw); + } catch (err) { + throw new Error(describeFetchFailure(err, "Failed to load your bids.")); + } }, async fetchUserListings(): Promise { - // TODO: wire up to GET /marketplace/listings/me (requires auth header) - return apiFetch("/marketplace/listings/me"); + const authToken = getStoredAuthToken(); + if (!authToken) throw new MarketplaceAuthRequiredError(); + + try { + const raw = await apiFetch("/marketplace/listings/me", { authToken }); + return sanitizeUserListings(raw); + } catch (err) { + throw new Error(describeFetchFailure(err, "Failed to load your listings.")); + } }, async placeBid(username: string, amount: number): Promise { - // TODO: wire up to POST /marketplace/bids with Stellar wallet signature + // Validate request shape locally — invalid requests never hit the wire. + const validation = validateBidRequest(username, amount); + if (!validation.ok) return { success: false, reason: validation.reason }; + try { - return await apiFetch("/marketplace/bids", { + const raw = await apiFetch("/marketplace/bids", { method: "POST", + authToken: getStoredAuthToken(), body: JSON.stringify({ username, amount }), }); + + const parsed = parseBidResponse(raw); + if (!parsed) { + console.error( + "[productionMarketplaceProvider] unexpected bid response shape", + ); + return { success: false, reason: DEFAULT_BID_FAILURE }; + } + return parsed; } catch (err) { - const reason = - err instanceof Error ? err.message : "Unknown error placing bid."; - return { success: false, reason }; + console.error("[productionMarketplaceProvider] placeBid failed:", err); + return { success: false, reason: describeFetchFailure(err, DEFAULT_BID_FAILURE) }; } }, diff --git a/app/frontend/src/lib/api.ts b/app/frontend/src/lib/api.ts index 387d400fb..ea38c9c06 100644 --- a/app/frontend/src/lib/api.ts +++ b/app/frontend/src/lib/api.ts @@ -74,6 +74,9 @@ export function describeApiError(error: unknown, fallback: string): string { return error.message || fallback; } return fallback; +} + +/** * Read and sanitize the locally stored profile metadata for `username`. * * Recovers gracefully from malformed data: diff --git a/app/frontend/src/lib/errorReporter.ts b/app/frontend/src/lib/errorReporter.ts index 9bce863a9..7c708b6e9 100644 --- a/app/frontend/src/lib/errorReporter.ts +++ b/app/frontend/src/lib/errorReporter.ts @@ -32,7 +32,11 @@ const API_KEY_RE = /\b(api[_-]?key\s*[:=]\s*)[A-Za-z0-9\-._~+/]+/gi; const PASSWORD_RE = /\b(password\s*[:=]\s*)[^\s"',}]+/gi; const SECRET_RE = /\b(secret\s*[:=]\s*)[^\s"',}]+/gi; -const SENSITIVE_KEY_PATTERN = /^(password|secret|token|apiKey|api_key|api-key|privateKey|private_key|secretKey|secret_key|auth|authorization)$/i; +/** + * Keys whose values must never leave the browser. Covers credentials plus + * wallet/transaction material that marketplace bid requests can carry. + */ +const SENSITIVE_KEY_PATTERN = /^(password|secret|token|apiKey|api_key|api-key|privateKey|private_key|secretKey|secret_key|auth|authorization|signature|seed|seedPhrase|mnemonic|walletSecret|wallet_secret|sessionCookie|cookie)$/i; export function extractCodeOrigin(stack?: string): string | undefined { if (!stack) return undefined;