- ) : 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;