diff --git a/buyer/.env.example b/buyer/.env.example index eb7de34..9100863 100644 --- a/buyer/.env.example +++ b/buyer/.env.example @@ -38,6 +38,9 @@ NETWORK_ID=0 # along over loopback. Production behind TLS should leave this unset / "1". # COOKIE_SECURE=1 +# Internal, redacted data source for the authenticated /dashboard page. +# SURPLUS_DASHBOARD_URL=http://surplus-seller:8080/internal/resale-dashboard + # ─── Optional: PDF Book Summarizer (/book page, /v1/pdf-* routes) ───────── # Upload a PDF → chunk it → pay suppliers to summarize each chunk (one escrow # per map + reduce call) → full-book summary. All knobs are optional; defaults diff --git a/buyer/src/config.ts b/buyer/src/config.ts index 9c512ff..caf0e77 100644 --- a/buyer/src/config.ts +++ b/buyer/src/config.ts @@ -42,6 +42,9 @@ * API key" page knows where to POST /signup. When * unset, the SPA falls back to deriving "api." + * current host. + * SURPLUS_DASHBOARD_URL — Internal URL of the private Surplus dashboard + * snapshot endpoint. When unset, /v1/resale-dashboard + * responds 503 while the rest of the buyer stays live. * PDF_ENABLED — "1" (default) serves the PDF book summarizer * (/v1/pdf-* routes). Set "0" to disable the feature * entirely: no PDF job store is opened and every @@ -68,6 +71,7 @@ export interface BuyerConfig { cookieSecure: boolean; pdfEnabled: boolean; gatewayPublicUrl: string; + resaleDashboardUrl: string; } function requireField(env: Record, name: string): string { @@ -169,6 +173,20 @@ export function loadConfig(env: Record): BuyerConfig // the SPA via the boot block (no trailing slash). Empty string means the SPA // derives the URL from its own host ("api." + location.host). const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, ""); + const resaleDashboardUrl = (env.SURPLUS_DASHBOARD_URL ?? "") + .trim() + .replace(/\/+$/, ""); + if (resaleDashboardUrl !== "") { + let parsed: URL; + try { + parsed = new URL(resaleDashboardUrl); + } catch { + throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must be a valid URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must use http or https"); + } + } return { privKeyHex, @@ -186,5 +204,6 @@ export function loadConfig(env: Record): BuyerConfig cookieSecure, pdfEnabled, gatewayPublicUrl, + resaleDashboardUrl, }; } diff --git a/buyer/src/index.ts b/buyer/src/index.ts index 4e0be24..a204acb 100644 --- a/buyer/src/index.ts +++ b/buyer/src/index.ts @@ -141,6 +141,7 @@ export async function runMain(env: Record): Promise< sessionSecret: config.sessionSecret, cookieSecure: config.cookieSecure, gatewayPublicUrl: config.gatewayPublicUrl, + resaleDashboardUrl: config.resaleDashboardUrl, jobStore, pdfCaps, }); diff --git a/buyer/src/server.ts b/buyer/src/server.ts index 904d1ea..a5cbba5 100644 --- a/buyer/src/server.ts +++ b/buyer/src/server.ts @@ -114,6 +114,9 @@ export interface AppDeps { * `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the * SPA derives "api." + its own host. */ gatewayPublicUrl?: string; + /** Private supplier-network endpoint used to populate the resale dashboard. + * The browser only sees the redacted same-origin /v1/resale-dashboard proxy. */ + resaleDashboardUrl?: string; /** PDF book summarizer job registry. When provided, the /v1/pdf-* routes * are live; when omitted they respond 503 (feature disabled). Built in * runMain from the same marketplace/chain/wallet/archive deps. */ @@ -317,6 +320,66 @@ export function createApp(deps: AppDeps): Express { } : null; + app.get("/v1/resale-dashboard", async (_req: Request, res: Response) => { + const dashboardUrl = (deps.resaleDashboardUrl ?? "").replace(/\/+$/, ""); + if (dashboardUrl === "") { + return jsonError( + res, + 503, + "service_unavailable", + "buyer-app booted without SURPLUS_DASHBOARD_URL", + ); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + const upstream = await (deps.fetchImpl ?? globalThis.fetch)(dashboardUrl, { + headers: { accept: "application/json" }, + redirect: "error", + signal: controller.signal, + }); + const text = await upstream.text(); + if (Buffer.byteLength(text, "utf8") > 1024 * 1024) { + return jsonError( + res, + 502, + "dashboard_response_too_large", + "resale dashboard response exceeded 1 MiB", + ); + } + if (!upstream.ok) { + return jsonError( + res, + 502, + "dashboard_upstream_error", + `resale dashboard upstream returned ${upstream.status}`, + ); + } + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return jsonError( + res, + 502, + "dashboard_invalid_json", + "resale dashboard upstream did not return JSON", + ); + } + res.setHeader("Cache-Control", "no-store"); + return res.status(200).json(payload); + } catch { + return jsonError( + res, + 502, + "dashboard_unreachable", + "resale dashboard upstream is unavailable", + ); + } finally { + clearTimeout(timer); + } + }); + app.get("/v1/pending-receipts", async (_req: Request, res: Response) => { if (!resolved) { return jsonError( diff --git a/buyer/src/ui/App.tsx b/buyer/src/ui/App.tsx index d4eabf7..9ec532f 100644 --- a/buyer/src/ui/App.tsx +++ b/buyer/src/ui/App.tsx @@ -4,6 +4,7 @@ * Routes (all gated by ): * / → * /tasks → + * /dashboard → unified Surplus + Vector metrics * /pending → UX-1: buyer-side Accept button * /wallet → * /api-keys → self-serve gateway API key generation @@ -16,6 +17,7 @@ import Wallet from "./pages/Wallet.js"; import PendingReceipts from "./pages/PendingReceipts.js"; import BookSummarizer from "./pages/BookSummarizer.js"; import ApiKeys from "./pages/ApiKeys.js"; +import ResaleDashboard from "./pages/ResaleDashboard.js"; import { RequireAuth, useAuth } from "./state/AuthContext.js"; function SignOutButton() { @@ -37,6 +39,7 @@ export default function App() {