From facf07c35ff0e3bc6bbbbbfc2888fa49170188f6 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 19 May 2026 10:52:01 +0200 Subject: [PATCH] buyer: single-operator password gate (BUYER_PASSWORD + signed session cookie) Adds an Express requireAuth middleware on /v1/* + the SPA shell and a Vite/React login screen so anyone who reaches mp-buyers.* must authenticate before driving the operator's wallet via /v1/submit-prompt or /v1/accept. Why: the buyer-app sits on a public HTTP port and signs txs with a server-held private key. Until now the only gate was network reachability. Mechanics: - BUYER_PASSWORD + SESSION_SECRET (>=32 chars) loaded by config.ts and surfaced as createApp() deps. Production refuses to start without both. - buyer/src/auth.ts: HMAC-SHA256 signed buyer_session cookie (HttpOnly, SameSite=Lax, Path=/, optional Secure), 7-day rolling expiry, timing-safe password compare, in-memory 5/15-min per-IP rate limiter. - /v1/auth/{login,logout,whoami}. Login is rate-limited; 6th wrong attempt returns 429 + Retry-After. Success resets the bucket. - SPA boot block (__BUYER_BOOT__ injected by server.ts) is gated by the same cookie so the buyer's address never reaches an unauthenticated browser. - New / in buyer/src/ui/state/AuthContext.tsx probes /v1/auth/whoami on mount and a scoped window.fetch patch drops to the login screen on any mid-session 401 from /v1/* (excluding /v1/auth/* so wrong-password 401s stay local to ). - Tailwind login card at buyer/src/ui/pages/Login.tsx. App.tsx wraps in and adds a "Sign out" button in the nav. Tests: tests/unit/buyer-server-auth.test.ts covers sign/verify, rate limiter, cookie helpers, login flow (400/401/204/429), whoami (no-cookie/valid/tampered/expired), rolling renewal, logout, gated /v1/*, /healthz still open, and the no-auth-deps construction shape. Updated buyer-server-config + buyer-ui-smoke for the new required env vars and the AuthProvider wrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- buyer/.env.example | 12 + buyer/src/auth.ts | 228 +++++++++++++++++ buyer/src/config.ts | 48 +++- buyer/src/index.ts | 3 + buyer/src/server.ts | 179 +++++++++++++- buyer/src/ui/App.tsx | 51 ++-- buyer/src/ui/main.tsx | 9 +- buyer/src/ui/pages/Login.tsx | 107 ++++++++ buyer/src/ui/state/AuthContext.tsx | 145 +++++++++++ tests/unit/buyer-server-auth.test.ts | 327 +++++++++++++++++++++++++ tests/unit/buyer-server-config.test.ts | 33 +++ tests/unit/buyer-ui-smoke.test.tsx | 9 +- 12 files changed, 1122 insertions(+), 29 deletions(-) create mode 100644 buyer/src/auth.ts create mode 100644 buyer/src/ui/pages/Login.tsx create mode 100644 buyer/src/ui/state/AuthContext.tsx create mode 100644 tests/unit/buyer-server-auth.test.ts diff --git a/buyer/.env.example b/buyer/.env.example index a6f4312..3b7db02 100644 --- a/buyer/.env.example +++ b/buyer/.env.example @@ -15,6 +15,14 @@ BUYER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE # Indexer base URL. Inside compose this is the service name. INDEXER_URL=http://indexer:8090 +# Single-operator login password. Anyone who knows this can spend funds +# via /v1/submit-prompt + /v1/accept, so treat it like BUYER_PRIV_KEY_HEX. +BUYER_PASSWORD=CHANGE_ME + +# HMAC key for the buyer_session cookie (HttpOnly, SameSite=Lax, 7-day rolling). +# Minimum 32 chars. Generate with: openssl rand -hex 32 +SESSION_SECRET=PASTE_64_HEX_CHARS_HERE_OR_LONGER + # ─── Optional ───────────────────────────────────────────────────────────── # HTTP listen port (default 3002 in buyer/src/config.ts; we override to @@ -26,6 +34,10 @@ BUYER_PORT=8070 # See ARCHITECTURE.md §9 follow-up — naming drift to be reconciled.) NETWORK_ID=0 +# Set "0" for plain-HTTP local dev so the buyer_session cookie still rides +# along over loopback. Production behind TLS should leave this unset / "1". +# COOKIE_SECURE=1 + # ─── Optional: CIP-33 reference scripts ─────────────────────────────────── # When set, buyer-signed script-spending txs (Accept, Reclaim) read the # escrow validator from an on-chain reference UTxO instead of inlining diff --git a/buyer/src/auth.ts b/buyer/src/auth.ts new file mode 100644 index 0000000..7489dda --- /dev/null +++ b/buyer/src/auth.ts @@ -0,0 +1,228 @@ +/** + * buyer/src/auth.ts — single-operator login primitives. + * + * The buyer-app sits on a public HTTP port and signs txs with a server-held + * private key. Without a gate, anyone who reaches the port can drain the + * operator's wallet via /v1/submit-prompt + /v1/accept. This module provides + * the pieces an Express middleware needs to enforce a password-only gate: + * + * - signSession / verifySession — HMAC-SHA256 signed cookies carrying + * { iat, exp }. No DB; revocation = wait for expiry or rotate SESSION_SECRET. + * - timingSafeCompareStrings — constant-time string equality for the + * password check and cookie signature compare. + * - createRateLimiter — in-memory per-IP brute-force limiter; resets on + * successful login or process restart. + * - SESSION_COOKIE_NAME / SESSION_MAX_AGE_MS / buildSessionCookieHeader — + * cookie header helpers shared by login, logout, and the rolling + * refresh in requireAuth. + * + * Pure (no Express imports). All time inputs are injectable for tests. + */ + +import { createHmac, timingSafeEqual } from "crypto"; + +export const SESSION_COOKIE_NAME = "buyer_session"; +export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +export interface SessionPayload { + /** issued-at, ms since epoch */ + iat: number; + /** expires-at, ms since epoch */ + exp: number; +} + +function base64UrlEncode(input: Buffer | string): string { + const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input; + return buf.toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlDecode(input: string): Buffer | null { + // Reject non-base64url chars rather than silently decoding garbage. + if (!/^[A-Za-z0-9_-]*$/.test(input)) return null; + const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4)); + try { + return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64"); + } catch { + return null; + } +} + +function hmac(secret: string, msg: string): Buffer { + return createHmac("sha256", secret).update(msg).digest(); +} + +/** Sign a session payload as `.`. */ +export function signSession(secret: string, payload: SessionPayload): string { + const payloadB64 = base64UrlEncode(JSON.stringify(payload)); + const sigB64 = base64UrlEncode(hmac(secret, payloadB64)); + return `${payloadB64}.${sigB64}`; +} + +export interface VerifyResult { + valid: boolean; + payload?: SessionPayload; + reason?: "format" | "signature" | "expired"; +} + +/** Verify a `.` string. */ +export function verifySession(secret: string, raw: string, nowMs: number): VerifyResult { + if (typeof raw !== "string" || raw.length === 0) { + return { valid: false, reason: "format" }; + } + const dot = raw.indexOf("."); + if (dot <= 0 || dot === raw.length - 1) { + return { valid: false, reason: "format" }; + } + const payloadB64 = raw.slice(0, dot); + const sigB64 = raw.slice(dot + 1); + const expected = base64UrlEncode(hmac(secret, payloadB64)); + if (!timingSafeCompareStrings(sigB64, expected)) { + return { valid: false, reason: "signature" }; + } + const payloadJson = base64UrlDecode(payloadB64); + if (payloadJson === null) return { valid: false, reason: "format" }; + let parsed: unknown; + try { + parsed = JSON.parse(payloadJson.toString("utf8")); + } catch { + return { valid: false, reason: "format" }; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as { iat?: unknown }).iat !== "number" || + typeof (parsed as { exp?: unknown }).exp !== "number" + ) { + return { valid: false, reason: "format" }; + } + const payload = parsed as SessionPayload; + if (nowMs >= payload.exp) return { valid: false, reason: "expired" }; + return { valid: true, payload }; +} + +/** + * Constant-time string compare. Pads both inputs to a common length so the + * comparison time doesn't leak the secret's length. Always touches both + * strings even when lengths differ. + */ +export function timingSafeCompareStrings(a: string, b: string): boolean { + const aBuf = Buffer.from(a, "utf8"); + const bBuf = Buffer.from(b, "utf8"); + const len = Math.max(aBuf.length, bBuf.length); + const aPad = Buffer.alloc(len); + const bPad = Buffer.alloc(len); + aBuf.copy(aPad); + bBuf.copy(bPad); + const eq = timingSafeEqual(aPad, bPad); + return eq && aBuf.length === bBuf.length; +} + +// ─── Rate limiter ───────────────────────────────────────────────────────── + +export interface RateLimitDecision { + allowed: boolean; + /** ms until the bucket resets. 0 when allowed. */ + retryAfterMs: number; + /** Attempts consumed in the current window (including this one when allowed). */ + count: number; +} + +export interface RateLimiter { + attempt(key: string): RateLimitDecision; + reset(key: string): void; + /** For tests: clear all buckets. */ + clear(): void; +} + +export interface RateLimiterOpts { + max: number; + windowMs: number; + now?: () => number; +} + +interface Bucket { + count: number; + windowStart: number; +} + +/** + * Sliding-window rate limiter. Keyed by arbitrary string (typically req.ip). + * Counts every call; on overflow returns `allowed=false` and the ms remaining + * in the current window. `reset(key)` is called on successful login so a + * legitimate operator who typo'd a few times isn't locked out. + */ +export function createRateLimiter(opts: RateLimiterOpts): RateLimiter { + const buckets = new Map(); + const now = opts.now ?? (() => Date.now()); + + return { + attempt(key: string): RateLimitDecision { + const t = now(); + const existing = buckets.get(key); + if (!existing || t - existing.windowStart >= opts.windowMs) { + const fresh: Bucket = { count: 1, windowStart: t }; + buckets.set(key, fresh); + return { allowed: true, retryAfterMs: 0, count: 1 }; + } + if (existing.count >= opts.max) { + const retryAfterMs = Math.max(0, opts.windowMs - (t - existing.windowStart)); + return { allowed: false, retryAfterMs, count: existing.count }; + } + existing.count += 1; + return { allowed: true, retryAfterMs: 0, count: existing.count }; + }, + reset(key: string): void { + buckets.delete(key); + }, + clear(): void { + buckets.clear(); + }, + }; +} + +// ─── Cookie header helpers ──────────────────────────────────────────────── + +export interface CookieHeaderOpts { + value: string; + maxAgeMs: number; + secure: boolean; +} + +/** Build a Set-Cookie value for the session cookie. Path=/, HttpOnly, SameSite=Lax. */ +export function buildSessionCookieHeader(opts: CookieHeaderOpts): string { + const parts = [ + `${SESSION_COOKIE_NAME}=${opts.value}`, + `Max-Age=${Math.max(0, Math.floor(opts.maxAgeMs / 1000))}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + ]; + if (opts.secure) parts.push("Secure"); + return parts.join("; "); +} + +/** Build a Set-Cookie value that clears the session cookie. */ +export function buildClearCookieHeader(secure: boolean): string { + return buildSessionCookieHeader({ value: "", maxAgeMs: 0, secure }); +} + +/** + * Parse a single cookie value out of a `Cookie:` request header. + * Returns null when the header is missing or the named cookie isn't present. + * Tolerates leading whitespace and the standard `; `-separated format. + */ +export function getCookie(cookieHeader: string | undefined, name: string): string | null { + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + const k = part.slice(0, eq).trim(); + if (k === name) { + return part.slice(eq + 1).trim(); + } + } + return null; +} diff --git a/buyer/src/config.ts b/buyer/src/config.ts index db94ca2..8faff6c 100644 --- a/buyer/src/config.ts +++ b/buyer/src/config.ts @@ -4,6 +4,12 @@ * Required env vars: * BUYER_PRIV_KEY_HEX — 32-byte (64-char) hex Ed25519 private key * INDEXER_URL — base URL of the indexer (e.g. http://localhost:3001) + * BUYER_PASSWORD — plaintext password for the operator login screen. + * Single-operator gate: anyone who knows this password + * can drive the buyer (spend funds via /v1/submit-prompt + * etc). Treat with the same care as BUYER_PRIV_KEY_HEX. + * SESSION_SECRET — HMAC key for signing the buyer_session cookie. + * Must be ≥ 32 chars. Generate with `openssl rand -hex 32`. * * Optional: * BUYER_PORT — port to listen on (default 3002) @@ -11,6 +17,9 @@ * OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1) * LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx). * Default off → ReadOnlyOgmiosProvider (safe). + * COOKIE_SECURE — "1" (default) sets Secure on the session cookie so + * it's only sent over HTTPS. Set "0" for plain-HTTP + * local dev over loopback. * TTS_PIPER_BASE_URL — Base URL of the openedai-speech-min PiperTTS host. * When unset, /v1/synth-speech responds 503 (the * capability stays disabled — the rest of the @@ -24,6 +33,7 @@ const HEX64_RE = /^[0-9a-fA-F]{64}$/; const POS_INT_RE = /^[1-9]\d*$/; +const SESSION_SECRET_MIN_LEN = 32; export interface BuyerConfig { privKeyHex: string; @@ -34,6 +44,9 @@ export interface BuyerConfig { liveChain: boolean; ttsPiperBaseUrl: string; archiveDir: string; + password: string; + sessionSecret: string; + cookieSecure: boolean; } function requireField(env: Record, name: string): string { @@ -92,5 +105,38 @@ export function loadConfig(env: Record): BuyerConfig // bind-mounted /repo/data/archive. const archiveDir = env.ARCHIVE_DIR ?? "./data/archive"; - return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain, ttsPiperBaseUrl, archiveDir }; + const password = requireField(env, "BUYER_PASSWORD"); + const sessionSecret = requireField(env, "SESSION_SECRET"); + if (sessionSecret.length < SESSION_SECRET_MIN_LEN) { + throw new Error( + `loadConfig: SESSION_SECRET must be at least ${SESSION_SECRET_MIN_LEN} chars (generate with: openssl rand -hex 32)`, + ); + } + + // COOKIE_SECURE: defaults to true. Operators running the buyer-app behind + // a plain-HTTP loopback for local dev set "0" so the cookie still rides + // along (browsers drop Secure cookies on http://). Accept only "0" / "1" + // to keep boot-time misconfig loud. + let cookieSecure = true; + const cookieSecureStr = env.COOKIE_SECURE; + if (cookieSecureStr !== undefined && cookieSecureStr !== "") { + if (cookieSecureStr !== "0" && cookieSecureStr !== "1") { + throw new Error('loadConfig: COOKIE_SECURE must be "0" or "1"'); + } + cookieSecure = cookieSecureStr === "1"; + } + + return { + privKeyHex, + indexerUrl, + port, + networkId, + ogmiosUrl, + liveChain, + ttsPiperBaseUrl, + archiveDir, + password, + sessionSecret, + cookieSecure, + }; } diff --git a/buyer/src/index.ts b/buyer/src/index.ts index 1c60890..6baf567 100644 --- a/buyer/src/index.ts +++ b/buyer/src/index.ts @@ -99,6 +99,9 @@ export async function runMain(env: Record): Promise< marketplace, ttsPiperBaseUrl: config.ttsPiperBaseUrl, archive, + password: config.password, + sessionSecret: config.sessionSecret, + cookieSecure: config.cookieSecure, }); const server = app.listen(config.port, () => { diff --git a/buyer/src/server.ts b/buyer/src/server.ts index 2b20402..216db2e 100644 --- a/buyer/src/server.ts +++ b/buyer/src/server.ts @@ -18,7 +18,7 @@ * through the buyer-app keeps the buyer_pkh out of the page bundle. */ -import express, { type Express, type Request, type Response } from "express"; +import express, { type Express, type NextFunction, type Request, type Response } from "express"; import { existsSync, readFileSync, statSync } from "fs"; import { bech32 } from "bech32"; import type { ChainProvider } from "@marketplace/shared/chain"; @@ -28,6 +28,18 @@ import type { ChatMessage } from "@marketplace/shared/tx"; import { canonicalize } from "@marketplace/shared/cbor"; import { runAccept } from "./cli/acceptFlow.js"; import type { ResponseArchive } from "./db/archive.js"; +import { + SESSION_COOKIE_NAME, + SESSION_MAX_AGE_MS, + buildClearCookieHeader, + buildSessionCookieHeader, + createRateLimiter, + getCookie, + signSession, + timingSafeCompareStrings, + verifySession, + type RateLimiter, +} from "./auth.js"; const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/; @@ -74,6 +86,19 @@ export interface AppDeps { archive?: ResponseArchive; /** Injectable fetch for tests. Defaults to globalThis.fetch. */ fetchImpl?: typeof globalThis.fetch; + /** Operator login password (BUYER_PASSWORD). When missing, all /v1/* + * non-auth routes and the SPA shell respond 503 auth_unconfigured. */ + password?: string; + /** HMAC key for the buyer_session cookie (SESSION_SECRET). When missing, + * auth is treated as unconfigured (same 503 behavior as a missing password). */ + sessionSecret?: string; + /** When true, the session cookie carries the Secure attribute. Defaults + * to true; tests and local-loopback dev override to false. */ + cookieSecure?: boolean; + /** Injectable clock for cookie issued-at / expiry calculations and the + * brute-force rate-limit window. Defaults to Date.now. Tests use a fake + * clock to drive expiry and rolling-renewal assertions deterministically. */ + nowMs?: () => number; } interface ResolvedDeps { @@ -125,6 +150,134 @@ export function createApp(deps: AppDeps): Express { res.status(200).json({ ok: true }); }); + // ─── Auth wiring ─────────────────────────────────────────────────────── + // The single-operator login gate. When `password` and `sessionSecret` are + // both configured, every /v1/* route below (other than /v1/auth/*) and the + // SPA boot block require a valid buyer_session cookie. + // + // When either is missing, `authReady` is false and the gate is bypassed + // entirely (no /v1/auth/* routes mounted; no requireAuth middleware). This + // is purely a test-construction shape — the production boot path + // (runMain → loadConfig) refuses to start without both env vars, so a + // deployment with `authReady === false` is impossible. + const authReady = + typeof deps.password === "string" && deps.password.length > 0 && + typeof deps.sessionSecret === "string" && deps.sessionSecret.length > 0; + const cookieSecure = deps.cookieSecure !== false; // default true + const nowMs = deps.nowMs ?? (() => Date.now()); + + // Brute-force limiter: 5 attempts per 15 minutes per IP. In-memory only; + // resets on a successful login (legitimate operator who typo'd) and on + // process restart. For a single-operator service this is the right trade. + const loginLimiter: RateLimiter = createRateLimiter({ + max: 5, + windowMs: 15 * 60 * 1000, + now: nowMs, + }); + + function clientIp(req: Request): string { + // Default Express req.ip honors `trust proxy`; we deliberately don't + // enable trust proxy here, so this is the direct socket peer. Operators + // behind a reverse proxy that masks the client should configure + // trust proxy on the outer Express; for our brute-force gate, even the + // proxy IP is a usable bucket key (it just rate-limits the proxy itself). + return req.ip ?? "unknown"; + } + + function issueSessionCookie(res: Response): void { + if (!authReady) return; + const iat = nowMs(); + const exp = iat + SESSION_MAX_AGE_MS; + const token = signSession(deps.sessionSecret!, { iat, exp }); + res.setHeader( + "Set-Cookie", + buildSessionCookieHeader({ value: token, maxAgeMs: SESSION_MAX_AGE_MS, secure: cookieSecure }), + ); + } + + if (authReady) { + /** + * Express middleware that requires a valid buyer_session cookie. On + * success, refreshes the cookie (rolling SESSION_MAX_AGE_MS expiry). + * On failure responds 401 unauthenticated for /v1/* — the SPA shell + * route lower in the file separately handles the unauth HTML case + * by serving the bundle without the boot block. + */ + const requireAuth = (req: Request, res: Response, next: NextFunction): void => { + const raw = getCookie(req.headers.cookie, SESSION_COOKIE_NAME); + if (raw === null) { + jsonError(res, 401, "unauthenticated", "no session cookie"); + return; + } + const result = verifySession(deps.sessionSecret!, raw, nowMs()); + if (!result.valid) { + jsonError(res, 401, "unauthenticated", `session ${result.reason ?? "invalid"}`); + return; + } + // Rolling renewal — every authenticated request pushes exp out another + // SESSION_MAX_AGE_MS. Keeps active operators logged in without ever + // accumulating long-lived tokens that linger past inactivity. + issueSessionCookie(res); + next(); + }; + + // /v1/auth/* — login, logout, whoami. Mounted before any protected + // /v1/* routes so requireAuth doesn't gate login itself. /v1/auth/whoami + // is intentionally protected — the SPA's AuthProvider uses it as a + // "do I have a valid session?" probe. + app.post("/v1/auth/login", (req: Request, res: Response) => { + const ip = clientIp(req); + const limit = loginLimiter.attempt(ip); + if (!limit.allowed) { + const retryAfterSec = Math.ceil(limit.retryAfterMs / 1000); + res.setHeader("Retry-After", String(retryAfterSec)); + jsonError( + res, + 429, + "rate_limited", + `too many login attempts; try again in ${retryAfterSec}s`, + ); + return; + } + const body = readBody(req.body); + const password = body.password; + if (typeof password !== "string" || password.length === 0) { + jsonError(res, 400, "password_required", 'body must include { "password": "" }'); + return; + } + if (!timingSafeCompareStrings(password, deps.password!)) { + jsonError(res, 401, "invalid_password", "incorrect password"); + return; + } + loginLimiter.reset(ip); + issueSessionCookie(res); + res.status(204).end(); + }); + + app.post("/v1/auth/logout", (_req: Request, res: Response) => { + // Idempotent — safe to call without an existing session. We always + // set the clear cookie header so any stale cookie in the browser is + // overwritten regardless of whether it was valid. + res.setHeader("Set-Cookie", buildClearCookieHeader(cookieSecure)); + res.status(204).end(); + }); + + app.get("/v1/auth/whoami", requireAuth, (_req: Request, res: Response) => { + res.status(200).json({ authenticated: true }); + }); + + // Every other /v1/* route is protected. Mount the gate as a path-prefix + // middleware that skips /v1/auth/* (already mounted above) but applies + // to everything else under /v1/. + app.use("/v1", (req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith("/auth/")) { + next(); + return; + } + requireAuth(req, res, next); + }); + } + // chain/walletKey/indexerUrl are required for /v1/* routes; if any is // missing we still mount the static bundle but the API endpoints respond // 503 service_unavailable so the SPA can render a clear "boot misconfigured" @@ -807,6 +960,11 @@ export function createApp(deps: AppDeps): Express { // can show the user their identity without the server leaking the // private key into the page bundle. The SPA talks to /v1/* on this // origin (relative URLs), so no indexer/ogmios endpoints leak either. + // + // The boot block is ALSO gated by auth: unauthenticated requests get + // the same HTML shell but with NO boot script, so the SPA's + // AuthProvider renders and the buyer's address never reaches + // an unauthenticated browser. const indexPath = `${deps.distPath}/index.html`; const cachedTemplate = existsSync(indexPath) ? readFileSync(indexPath, "utf8") @@ -825,17 +983,28 @@ export function createApp(deps: AppDeps): Express { }, })};` : ""; - const indexBody = cachedTemplate.replace( + const indexWithBoot = cachedTemplate.replace( "", `${bootScript}\n `, ); + // Anonymous shell: same HTML template but no boot block. The SPA's + // AuthProvider handles the rest. + const indexAnonymous = cachedTemplate; - app.get(/^\/(?!healthz|v1\/).*/, (_req: Request, res: Response) => { - if (!indexBody) { + function isAuthenticatedHtmlRequest(req: Request): boolean { + if (!authReady) return true; // auth disabled (test mode): no gating + const raw = getCookie(req.headers.cookie, SESSION_COOKIE_NAME); + if (raw === null) return false; + return verifySession(deps.sessionSecret!, raw, nowMs()).valid; + } + + app.get(/^\/(?!healthz|v1\/).*/, (req: Request, res: Response) => { + if (!cachedTemplate) { return res.status(404).json({ error: "not_found" }); } res.setHeader("Content-Type", "text/html; charset=utf-8"); - return res.send(indexBody); + const body = isAuthenticatedHtmlRequest(req) ? indexWithBoot : indexAnonymous; + return res.send(body); }); } } diff --git a/buyer/src/ui/App.tsx b/buyer/src/ui/App.tsx index 83c9d6d..71b2d93 100644 --- a/buyer/src/ui/App.tsx +++ b/buyer/src/ui/App.tsx @@ -1,7 +1,7 @@ /** * buyer/src/ui/App.tsx — router shell. * - * Routes: + * Routes (all gated by ): * / → * /tasks → * /pending → UX-1: buyer-side Accept button @@ -13,24 +13,41 @@ import Dashboard from "./pages/Dashboard.js"; import TaskHistory from "./pages/TaskHistory.js"; import Wallet from "./pages/Wallet.js"; import PendingReceipts from "./pages/PendingReceipts.js"; +import { RequireAuth, useAuth } from "./state/AuthContext.js"; + +function SignOutButton() { + const { signOut } = useAuth(); + return ( + + ); +} export default function App() { return ( -
- -
- - } /> - } /> - } /> - } /> - -
-
+ +
+ +
+ + } /> + } /> + } /> + } /> + +
+
+
); } diff --git a/buyer/src/ui/main.tsx b/buyer/src/ui/main.tsx index 2b93c4d..910dde4 100644 --- a/buyer/src/ui/main.tsx +++ b/buyer/src/ui/main.tsx @@ -20,6 +20,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App.js"; import { MarketplaceProvider } from "./state/MarketplaceContext.js"; +import { AuthProvider } from "./state/AuthContext.js"; // MemoryTaskHistoryStore is a no-op placeholder for the browser-side SDK // instance — submitPrompt now runs server-side, so the browser never // records anything to a history store. The /tasks page reads directly @@ -77,9 +78,11 @@ if (!rootEl) throw new Error("[buyer] #root element not found"); createRoot(rootEl).render( - - - + + + + + , ); diff --git a/buyer/src/ui/pages/Login.tsx b/buyer/src/ui/pages/Login.tsx new file mode 100644 index 0000000..3853ae3 --- /dev/null +++ b/buyer/src/ui/pages/Login.tsx @@ -0,0 +1,107 @@ +/** + * buyer/src/ui/pages/Login.tsx — operator password gate. + * + * Posts { password } to /v1/auth/login. On 204, calls auth.refresh() which + * flips the AuthContext to "authenticated" and the surrounding + * unmounts this page and mounts the real app. On 401 → inline "incorrect + * password" error. On 429 → reads the Retry-After header and surfaces the + * remaining seconds. + */ + +import { useState, useRef, useEffect, type FormEvent } from "react"; +import { useAuth } from "../state/AuthContext.js"; + +export default function Login() { + const { refresh } = useAuth(); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (submitting) return; + setError(null); + setSubmitting(true); + try { + const res = await fetch("/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + credentials: "same-origin", + }); + if (res.status === 204) { + setPassword(""); + await refresh(); + return; + } + if (res.status === 429) { + const retryAfter = res.headers.get("Retry-After"); + const secs = retryAfter ? Number(retryAfter) : NaN; + setError( + Number.isFinite(secs) && secs > 0 + ? `Too many attempts. Try again in ${secs}s.` + : "Too many attempts. Try again later.", + ); + return; + } + if (res.status === 401) { + setError("Incorrect password."); + return; + } + if (res.status === 503) { + setError("Login is not configured on this server. Check BUYER_PASSWORD/SESSION_SECRET."); + return; + } + setError(`Unexpected response (${res.status}). Try again.`); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+

Buyer sign in

+

+ Enter the operator password to access this buyer instance. +

+
+ + {error !== null ? ( +
+ {error} +
+ ) : null} + +
+
+ ); +} diff --git a/buyer/src/ui/state/AuthContext.tsx b/buyer/src/ui/state/AuthContext.tsx new file mode 100644 index 0000000..4d75a86 --- /dev/null +++ b/buyer/src/ui/state/AuthContext.tsx @@ -0,0 +1,145 @@ +/** + * buyer/src/ui/state/AuthContext.tsx — React context for the operator login gate. + * + * On mount, probes /v1/auth/whoami to learn whether the existing buyer_session + * cookie is valid. Exposes: + * + * - status: "checking" | "authenticated" | "unauthenticated" + * - refresh: re-probes /v1/auth/whoami (call after a successful login POST) + * - signOut: POSTs /v1/auth/logout, clears the cookie, drops to login screen + * + * {children} is a convenience wrapper used by App.tsx: + * - "checking": renders a minimal blank loader (avoids the login flash) + * - "unauthenticated": renders + * - "authenticated": renders children + */ + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import Login from "../pages/Login.js"; + +export type AuthStatus = "checking" | "authenticated" | "unauthenticated"; + +export interface AuthContextValue { + status: AuthStatus; + refresh: () => Promise; + signOut: () => Promise; +} + +const AuthContext = createContext(null); + +export function useAuth(): AuthContextValue { + const v = useContext(AuthContext); + if (!v) { + throw new Error("useAuth: no AuthContext in tree — wrap app in "); + } + return v; +} + +async function probeWhoami(): Promise { + try { + const res = await fetch("/v1/auth/whoami", { + method: "GET", + credentials: "same-origin", + }); + return res.ok; + } catch { + return false; + } +} + +export interface AuthProviderProps { + children: ReactNode; + /** + * Optional initial status. When set, the provider skips the /v1/auth/whoami + * probe and seeds the status directly. Useful for tests and for any future + * server-side render path where the auth state is already known at mount. + */ + initialStatus?: AuthStatus; +} + +export function AuthProvider({ children, initialStatus }: AuthProviderProps) { + const [status, setStatus] = useState(initialStatus ?? "checking"); + + const refresh = useCallback(async () => { + const ok = await probeWhoami(); + setStatus(ok ? "authenticated" : "unauthenticated"); + }, []); + + const signOut = useCallback(async () => { + try { + await fetch("/v1/auth/logout", { + method: "POST", + credentials: "same-origin", + }); + } catch { + // best-effort: if the network is down we still want to render the + // login screen locally so the operator doesn't keep operating on a + // dead session. + } + setStatus("unauthenticated"); + }, []); + + useEffect(() => { + if (initialStatus !== undefined) return; // caller-provided status; don't probe + void refresh(); + }, [refresh, initialStatus]); + + // Listen for 401s from any /v1/* call and drop to the login screen. + // We patch window.fetch once on mount and restore on unmount so other + // code in the app keeps using the normal global. Scoped to /v1/* paths + // so third-party fetches (e.g. asset URLs) don't accidentally trip the + // gate. The /v1/auth/login probe itself can return 401 legitimately + // (wrong password) — Login.tsx handles that locally, and we only flip + // state when an authenticated session expires mid-use (a NON-/auth call + // returns 401 while status === "authenticated"). + useEffect(() => { + const original = window.fetch.bind(window); + const patched: typeof window.fetch = async (input, init) => { + const res = await original(input, init); + const url = typeof input === "string" + ? input + : input instanceof URL ? input.toString() + : (input as Request).url; + if (res.status === 401 && url.includes("/v1/") && !url.includes("/v1/auth/")) { + setStatus("unauthenticated"); + } + return res; + }; + window.fetch = patched; + return () => { + window.fetch = original; + }; + }, []); + + return ( + + {children} + + ); +} + +export interface RequireAuthProps { + children: ReactNode; +} + +export function RequireAuth({ children }: RequireAuthProps) { + const { status } = useAuth(); + if (status === "checking") { + return ( +
+ Loading… +
+ ); + } + if (status === "unauthenticated") { + return ; + } + return <>{children}; +} diff --git a/tests/unit/buyer-server-auth.test.ts b/tests/unit/buyer-server-auth.test.ts new file mode 100644 index 0000000..4f207a1 --- /dev/null +++ b/tests/unit/buyer-server-auth.test.ts @@ -0,0 +1,327 @@ +/** + * buyer-server-auth.test.ts — coverage for the operator login gate. + * + * Two flavors: + * 1. Pure-function tests for buyer/src/auth.ts (sign/verify, rate limit, + * cookie header helpers). + * 2. Supertest-driven integration over createApp({password, sessionSecret}) + * covering /v1/auth/{login,logout,whoami}, the /v1/* gate, and rolling + * cookie renewal. + */ + +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { createApp } from "../../buyer/src/server.js"; +import { + SESSION_COOKIE_NAME, + SESSION_MAX_AGE_MS, + buildClearCookieHeader, + buildSessionCookieHeader, + createRateLimiter, + getCookie, + signSession, + timingSafeCompareStrings, + verifySession, +} from "../../buyer/src/auth.js"; + +// ─── Pure helpers ───────────────────────────────────────────────────────── + +describe("auth: signSession / verifySession", () => { + const SECRET = "0".repeat(64); + + it("round-trips a signed session", () => { + const now = 1_700_000_000_000; + const payload = { iat: now, exp: now + 1000 }; + const token = signSession(SECRET, payload); + const r = verifySession(SECRET, token, now); + expect(r.valid).toBe(true); + expect(r.payload).toEqual(payload); + }); + + it("rejects an expired session", () => { + const iat = 1_700_000_000_000; + const exp = iat + 1000; + const token = signSession(SECRET, { iat, exp }); + const r = verifySession(SECRET, token, exp); // now === exp ⇒ expired + expect(r.valid).toBe(false); + expect(r.reason).toBe("expired"); + }); + + it("rejects a tampered signature", () => { + const iat = 1_700_000_000_000; + const token = signSession(SECRET, { iat, exp: iat + 1000 }); + // Flip the last char of the signature segment. + const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"); + const r = verifySession(SECRET, tampered, iat); + expect(r.valid).toBe(false); + expect(r.reason).toBe("signature"); + }); + + it("rejects a payload that doesn't parse", () => { + const r = verifySession(SECRET, "not-a-session", 0); + expect(r.valid).toBe(false); + }); + + it("rejects an empty string", () => { + const r = verifySession(SECRET, "", 0); + expect(r.valid).toBe(false); + }); +}); + +describe("auth: timingSafeCompareStrings", () => { + it("returns true for equal strings", () => { + expect(timingSafeCompareStrings("hunter2", "hunter2")).toBe(true); + }); + it("returns false for different strings of equal length", () => { + expect(timingSafeCompareStrings("hunter2", "hunter3")).toBe(false); + }); + it("returns false for strings of different lengths", () => { + expect(timingSafeCompareStrings("a", "ab")).toBe(false); + expect(timingSafeCompareStrings("ab", "a")).toBe(false); + }); +}); + +describe("auth: createRateLimiter", () => { + it("allows up to max attempts then denies until window elapses", () => { + let t = 0; + const limiter = createRateLimiter({ max: 3, windowMs: 1000, now: () => t }); + expect(limiter.attempt("ip").allowed).toBe(true); + expect(limiter.attempt("ip").allowed).toBe(true); + expect(limiter.attempt("ip").allowed).toBe(true); + const denied = limiter.attempt("ip"); + expect(denied.allowed).toBe(false); + expect(denied.retryAfterMs).toBeGreaterThan(0); + // After the window slides past, the bucket resets. + t += 1001; + expect(limiter.attempt("ip").allowed).toBe(true); + }); + + it("reset() clears the bucket for a key", () => { + let t = 0; + const limiter = createRateLimiter({ max: 2, windowMs: 1000, now: () => t }); + limiter.attempt("a"); + limiter.attempt("a"); + expect(limiter.attempt("a").allowed).toBe(false); + limiter.reset("a"); + expect(limiter.attempt("a").allowed).toBe(true); + }); + + it("buckets are per-key", () => { + const limiter = createRateLimiter({ max: 1, windowMs: 1000 }); + expect(limiter.attempt("a").allowed).toBe(true); + expect(limiter.attempt("b").allowed).toBe(true); // different key, fresh + expect(limiter.attempt("a").allowed).toBe(false); + }); +}); + +describe("auth: cookie header helpers", () => { + it("buildSessionCookieHeader sets HttpOnly/SameSite=Lax/Path=/", () => { + const h = buildSessionCookieHeader({ value: "abc", maxAgeMs: 1000, secure: false }); + expect(h).toContain(`${SESSION_COOKIE_NAME}=abc`); + expect(h).toContain("HttpOnly"); + expect(h).toContain("SameSite=Lax"); + expect(h).toContain("Path=/"); + expect(h).toContain("Max-Age=1"); + expect(h).not.toContain("Secure"); + }); + + it("buildSessionCookieHeader appends Secure when requested", () => { + const h = buildSessionCookieHeader({ value: "abc", maxAgeMs: 1000, secure: true }); + expect(h).toContain("Secure"); + }); + + it("buildClearCookieHeader produces Max-Age=0", () => { + const h = buildClearCookieHeader(false); + expect(h).toContain("Max-Age=0"); + expect(h).toContain(`${SESSION_COOKIE_NAME}=`); + }); + + it("getCookie parses the named cookie out of a header", () => { + expect(getCookie("a=1; buyer_session=xyz; b=2", "buyer_session")).toBe("xyz"); + expect(getCookie("buyer_session=xyz", "buyer_session")).toBe("xyz"); + expect(getCookie("a=1; b=2", "buyer_session")).toBeNull(); + expect(getCookie(undefined, "buyer_session")).toBeNull(); + }); +}); + +// ─── Integration via supertest ──────────────────────────────────────────── + +const PASSWORD = "hunter2"; +const SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function makeAuthApp(overrides: { nowMs?: () => number } = {}) { + return createApp({ + password: PASSWORD, + sessionSecret: SECRET, + cookieSecure: false, // tests run over plain HTTP + nowMs: overrides.nowMs, + }); +} + +/** Pull the buyer_session value out of a Set-Cookie response header. */ +function extractSessionCookie(setCookieHeader: string | string[] | undefined): string | null { + if (!setCookieHeader) return null; + const headers = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; + for (const h of headers) { + const m = /buyer_session=([^;]*)/.exec(h); + if (m && m[1].length > 0) return m[1]; + } + return null; +} + +describe("createApp({password, sessionSecret}): /v1/auth/login", () => { + it("rejects a missing body field with 400", async () => { + const app = makeAuthApp(); + const res = await request(app).post("/v1/auth/login").send({}); + expect(res.status).toBe(400); + expect(res.body.error).toBe("password_required"); + }); + + it("rejects a wrong password with 401", async () => { + const app = makeAuthApp(); + const res = await request(app).post("/v1/auth/login").send({ password: "wrong" }); + expect(res.status).toBe(401); + expect(res.body.error).toBe("invalid_password"); + }); + + it("accepts the right password with 204 + HttpOnly Set-Cookie", async () => { + const app = makeAuthApp(); + const res = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + expect(res.status).toBe(204); + const setCookie = res.headers["set-cookie"]; + expect(setCookie).toBeDefined(); + const cookieHeader = Array.isArray(setCookie) ? setCookie.join(";") : setCookie; + expect(cookieHeader).toContain("HttpOnly"); + expect(cookieHeader).toContain("SameSite=Lax"); + expect(extractSessionCookie(setCookie)).toBeTruthy(); + }); + + it("rate-limits after 5 wrong attempts with 429 + Retry-After", async () => { + const app = makeAuthApp(); + for (let i = 0; i < 5; i++) { + const r = await request(app).post("/v1/auth/login").send({ password: "wrong" }); + expect(r.status).toBe(401); + } + const sixth = await request(app).post("/v1/auth/login").send({ password: "wrong" }); + expect(sixth.status).toBe(429); + expect(sixth.headers["retry-after"]).toBeDefined(); + // Even the correct password is rejected while the bucket is full. + const seventh = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + expect(seventh.status).toBe(429); + }); + + it("a successful login resets the rate-limit bucket", async () => { + const app = makeAuthApp(); + for (let i = 0; i < 3; i++) { + await request(app).post("/v1/auth/login").send({ password: "wrong" }); + } + const ok = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + expect(ok.status).toBe(204); + // After reset, four more wrong attempts shouldn't trip the limit. + for (let i = 0; i < 4; i++) { + const r = await request(app).post("/v1/auth/login").send({ password: "wrong" }); + expect(r.status).toBe(401); + } + }); +}); + +describe("createApp: /v1/auth/whoami + protected /v1/* gate", () => { + it("whoami without cookie returns 401", async () => { + const app = makeAuthApp(); + const res = await request(app).get("/v1/auth/whoami"); + expect(res.status).toBe(401); + expect(res.body.error).toBe("unauthenticated"); + }); + + it("whoami with a valid session returns 200 and refreshes the cookie", async () => { + const app = makeAuthApp(); + const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + const token = extractSessionCookie(login.headers["set-cookie"]); + expect(token).not.toBeNull(); + const res = await request(app) + .get("/v1/auth/whoami") + .set("Cookie", `${SESSION_COOKIE_NAME}=${token}`); + expect(res.status).toBe(200); + expect(res.body.authenticated).toBe(true); + // Rolling renewal: a fresh Set-Cookie should accompany the response. + expect(res.headers["set-cookie"]).toBeDefined(); + }); + + it("whoami with a tampered cookie returns 401", async () => { + const app = makeAuthApp(); + const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + const token = extractSessionCookie(login.headers["set-cookie"])!; + const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"); + const res = await request(app) + .get("/v1/auth/whoami") + .set("Cookie", `${SESSION_COOKIE_NAME}=${tampered}`); + expect(res.status).toBe(401); + }); + + it("whoami with an expired cookie returns 401", async () => { + // Issue a cookie at t=0, then probe at t=SESSION_MAX_AGE_MS+1 to exceed exp. + const start = 1_700_000_000_000; + let now = start; + const app = makeAuthApp({ nowMs: () => now }); + const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + const token = extractSessionCookie(login.headers["set-cookie"])!; + now = start + SESSION_MAX_AGE_MS + 1; + const res = await request(app) + .get("/v1/auth/whoami") + .set("Cookie", `${SESSION_COOKIE_NAME}=${token}`); + expect(res.status).toBe(401); + }); + + it("a protected /v1/* route returns 401 without a cookie", async () => { + const app = makeAuthApp(); + const res = await request(app).get("/v1/pending-receipts"); + expect(res.status).toBe(401); + }); + + it("a protected /v1/* route is reachable with a valid cookie", async () => { + // The route still 503s on missing chain deps, but it gets past the gate. + const app = makeAuthApp(); + const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + const token = extractSessionCookie(login.headers["set-cookie"])!; + const res = await request(app) + .get("/v1/pending-receipts") + .set("Cookie", `${SESSION_COOKIE_NAME}=${token}`); + expect(res.status).not.toBe(401); + }); + + it("GET /healthz is open even without a cookie", async () => { + const app = makeAuthApp(); + const res = await request(app).get("/healthz"); + expect(res.status).toBe(200); + }); +}); + +describe("createApp: /v1/auth/logout", () => { + it("logout clears the cookie (Max-Age=0) and is idempotent", async () => { + const app = makeAuthApp(); + const login = await request(app).post("/v1/auth/login").send({ password: PASSWORD }); + const token = extractSessionCookie(login.headers["set-cookie"])!; + const out = await request(app) + .post("/v1/auth/logout") + .set("Cookie", `${SESSION_COOKIE_NAME}=${token}`); + expect(out.status).toBe(204); + const setCookie = out.headers["set-cookie"]; + const flat = Array.isArray(setCookie) ? setCookie.join(";") : setCookie; + expect(flat).toContain("Max-Age=0"); + // Idempotent: same call without a session still 204s. + const again = await request(app).post("/v1/auth/logout"); + expect(again.status).toBe(204); + }); +}); + +describe("createApp without auth deps (test/library shape)", () => { + it("does not gate /v1/* and does not mount /v1/auth/* routes", async () => { + const app = createApp({}); + // No /v1/auth/login route → 404. + const login = await request(app).post("/v1/auth/login").send({ password: "any" }); + expect(login.status).toBe(404); + // /healthz still works. + const health = await request(app).get("/healthz"); + expect(health.status).toBe(200); + }); +}); diff --git a/tests/unit/buyer-server-config.test.ts b/tests/unit/buyer-server-config.test.ts index b47eda3..a706785 100644 --- a/tests/unit/buyer-server-config.test.ts +++ b/tests/unit/buyer-server-config.test.ts @@ -19,6 +19,8 @@ describe("loadConfig(env)", () => { INDEXER_URL: "http://localhost:3001", BUYER_PORT: "3002", NETWORK_ID: "0", + BUYER_PASSWORD: "hunter2", + SESSION_SECRET: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }; it("returns a BuyerConfig when all required env vars are present", () => { @@ -27,6 +29,9 @@ describe("loadConfig(env)", () => { expect(config.indexerUrl).toBe(VALID_ENV.INDEXER_URL); expect(config.port).toBe(3002); expect(config.networkId).toBe(0); + expect(config.password).toBe(VALID_ENV.BUYER_PASSWORD); + expect(config.sessionSecret).toBe(VALID_ENV.SESSION_SECRET); + expect(config.cookieSecure).toBe(true); }); it("throws when BUYER_PRIV_KEY_HEX is missing", () => { @@ -54,6 +59,34 @@ describe("loadConfig(env)", () => { const config = loadConfig(env); expect(config.networkId).toBe(0); }); + + it("throws when BUYER_PASSWORD is missing", () => { + const env = { ...VALID_ENV }; + delete (env as Record).BUYER_PASSWORD; + expect(() => loadConfig(env)).toThrow(/BUYER_PASSWORD/); + }); + + it("throws when SESSION_SECRET is missing", () => { + const env = { ...VALID_ENV }; + delete (env as Record).SESSION_SECRET; + expect(() => loadConfig(env)).toThrow(/SESSION_SECRET/); + }); + + it("throws when SESSION_SECRET is shorter than 32 chars", () => { + const env = { ...VALID_ENV, SESSION_SECRET: "too-short" }; + expect(() => loadConfig(env)).toThrow(/SESSION_SECRET/); + }); + + it('honours COOKIE_SECURE="0" for plain-HTTP loopback dev', () => { + const env = { ...VALID_ENV, COOKIE_SECURE: "0" }; + const config = loadConfig(env); + expect(config.cookieSecure).toBe(false); + }); + + it("throws when COOKIE_SECURE is not 0 or 1", () => { + const env = { ...VALID_ENV, COOKIE_SECURE: "true" }; + expect(() => loadConfig(env)).toThrow(/COOKIE_SECURE/); + }); }); // ─── createApp tests ────────────────────────────────────────────────────────── diff --git a/tests/unit/buyer-ui-smoke.test.tsx b/tests/unit/buyer-ui-smoke.test.tsx index e38160b..ac9847e 100644 --- a/tests/unit/buyer-ui-smoke.test.tsx +++ b/tests/unit/buyer-ui-smoke.test.tsx @@ -30,6 +30,7 @@ import Wallet from "../../buyer/src/ui/pages/Wallet.js"; import SupplierCard from "../../buyer/src/ui/components/SupplierCard.js"; import PromptForm from "../../buyer/src/ui/components/PromptForm.js"; import { MarketplaceProvider } from "../../buyer/src/ui/state/MarketplaceContext.js"; +import { AuthProvider } from "../../buyer/src/ui/state/AuthContext.js"; import type { SupplierView } from "../../buyer/src/sdk/types.js"; import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js"; import { @@ -83,9 +84,11 @@ function makeSampleSupplierView(overrides: Partial = {}): Supplier function wrap(element: React.ReactElement, mp: Marketplace) { return ( - - {element} - + + + {element} + + ); }