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 <AuthProvider>/<RequireAuth> 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 <Login>).
- Tailwind login card at buyer/src/ui/pages/Login.tsx. App.tsx wraps in
  <RequireAuth> 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) <noreply@anthropic.com>
This commit is contained in:
david
2026-05-19 10:52:01 +02:00
parent 5734d9c333
commit facf07c35f
12 changed files with 1122 additions and 29 deletions

228
buyer/src/auth.ts Normal file
View File

@@ -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 `<payloadB64>.<sigB64>`. */
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 `<payloadB64>.<sigB64>` 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<string, Bucket>();
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;
}

View File

@@ -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<string, string | undefined>, name: string): string {
@@ -92,5 +105,38 @@ export function loadConfig(env: Record<string, string | undefined>): 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,
};
}

View File

@@ -99,6 +99,9 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
marketplace,
ttsPiperBaseUrl: config.ttsPiperBaseUrl,
archive,
password: config.password,
sessionSecret: config.sessionSecret,
cookieSecure: config.cookieSecure,
});
const server = app.listen(config.port, () => {

View File

@@ -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": "<string>" }');
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 <Login> 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 {
},
})};</script>`
: "";
const indexBody = cachedTemplate.replace(
const indexWithBoot = cachedTemplate.replace(
"</head>",
`${bootScript}\n </head>`,
);
// 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);
});
}
}

View File

@@ -1,7 +1,7 @@
/**
* buyer/src/ui/App.tsx — router shell.
*
* Routes:
* Routes (all gated by <RequireAuth>):
* / → <Dashboard />
* /tasks → <TaskHistory />
* /pending → <PendingReceipts /> 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 (
<button
type="button"
onClick={() => { void signOut(); }}
className="ml-auto text-sm text-gray-600 hover:text-gray-900 hover:underline"
>
Sign out
</button>
);
}
export default function App() {
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex gap-6">
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
</nav>
<main className="p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/tasks" element={<TaskHistory />} />
<Route path="/pending" element={<PendingReceipts />} />
<Route path="/wallet" element={<Wallet />} />
</Routes>
</main>
</div>
<RequireAuth>
<div className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
<SignOutButton />
</nav>
<main className="p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/tasks" element={<TaskHistory />} />
<Route path="/pending" element={<PendingReceipts />} />
<Route path="/wallet" element={<Wallet />} />
</Routes>
</main>
</div>
</RequireAuth>
);
}

View File

@@ -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(
<StrictMode>
<BrowserRouter>
<MarketplaceProvider marketplace={marketplace}>
<App />
</MarketplaceProvider>
<AuthProvider>
<MarketplaceProvider marketplace={marketplace}>
<App />
</MarketplaceProvider>
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);

View File

@@ -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 <RequireAuth>
* 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<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm bg-white rounded-lg shadow border border-gray-200 p-6 space-y-4"
>
<div>
<h1 className="text-lg font-semibold text-gray-900">Buyer sign in</h1>
<p className="mt-1 text-sm text-gray-500">
Enter the operator password to access this buyer instance.
</p>
</div>
<label className="block">
<span className="text-sm font-medium text-gray-700">Password</span>
<input
ref={inputRef}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={submitting}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-gray-500 focus:outline-none focus:ring-1 focus:ring-gray-500 disabled:bg-gray-100"
/>
</label>
{error !== null ? (
<div role="alert" className="text-sm text-red-600">
{error}
</div>
) : null}
<button
type="submit"
disabled={submitting || password.length === 0}
className="w-full inline-flex justify-center items-center rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-800 disabled:bg-gray-400"
>
{submitting ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
);
}

View File

@@ -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
*
* <RequireAuth>{children}</RequireAuth> is a convenience wrapper used by App.tsx:
* - "checking": renders a minimal blank loader (avoids the login flash)
* - "unauthenticated": renders <Login/>
* - "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<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const v = useContext(AuthContext);
if (!v) {
throw new Error("useAuth: no AuthContext in tree — wrap app in <AuthProvider>");
}
return v;
}
async function probeWhoami(): Promise<boolean> {
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<AuthStatus>(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 (
<AuthContext.Provider value={{ status, refresh, signOut }}>
{children}
</AuthContext.Provider>
);
}
export interface RequireAuthProps {
children: ReactNode;
}
export function RequireAuth({ children }: RequireAuthProps) {
const { status } = useAuth();
if (status === "checking") {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400 text-sm">
Loading…
</div>
);
}
if (status === "unauthenticated") {
return <Login />;
}
return <>{children}</>;
}