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

View File

@@ -15,6 +15,14 @@ BUYER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
# Indexer base URL. Inside compose this is the service name. # Indexer base URL. Inside compose this is the service name.
INDEXER_URL=http://indexer:8090 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 ───────────────────────────────────────────────────────────── # ─── Optional ─────────────────────────────────────────────────────────────
# HTTP listen port (default 3002 in buyer/src/config.ts; we override to # 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.) # See ARCHITECTURE.md §9 follow-up — naming drift to be reconciled.)
NETWORK_ID=0 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 ─────────────────────────────────── # ─── Optional: CIP-33 reference scripts ───────────────────────────────────
# When set, buyer-signed script-spending txs (Accept, Reclaim) read the # When set, buyer-signed script-spending txs (Accept, Reclaim) read the
# escrow validator from an on-chain reference UTxO instead of inlining # escrow validator from an on-chain reference UTxO instead of inlining

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: * Required env vars:
* BUYER_PRIV_KEY_HEX — 32-byte (64-char) hex Ed25519 private key * BUYER_PRIV_KEY_HEX — 32-byte (64-char) hex Ed25519 private key
* INDEXER_URL — base URL of the indexer (e.g. http://localhost:3001) * 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: * Optional:
* BUYER_PORT — port to listen on (default 3002) * BUYER_PORT — port to listen on (default 3002)
@@ -11,6 +17,9 @@
* OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1) * OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1)
* LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx). * LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx).
* Default off → ReadOnlyOgmiosProvider (safe). * 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. * TTS_PIPER_BASE_URL — Base URL of the openedai-speech-min PiperTTS host.
* When unset, /v1/synth-speech responds 503 (the * When unset, /v1/synth-speech responds 503 (the
* capability stays disabled — the rest of the * capability stays disabled — the rest of the
@@ -24,6 +33,7 @@
const HEX64_RE = /^[0-9a-fA-F]{64}$/; const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const POS_INT_RE = /^[1-9]\d*$/; const POS_INT_RE = /^[1-9]\d*$/;
const SESSION_SECRET_MIN_LEN = 32;
export interface BuyerConfig { export interface BuyerConfig {
privKeyHex: string; privKeyHex: string;
@@ -34,6 +44,9 @@ export interface BuyerConfig {
liveChain: boolean; liveChain: boolean;
ttsPiperBaseUrl: string; ttsPiperBaseUrl: string;
archiveDir: string; archiveDir: string;
password: string;
sessionSecret: string;
cookieSecure: boolean;
} }
function requireField(env: Record<string, string | undefined>, name: string): string { 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. // bind-mounted /repo/data/archive.
const archiveDir = env.ARCHIVE_DIR ?? "./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, marketplace,
ttsPiperBaseUrl: config.ttsPiperBaseUrl, ttsPiperBaseUrl: config.ttsPiperBaseUrl,
archive, archive,
password: config.password,
sessionSecret: config.sessionSecret,
cookieSecure: config.cookieSecure,
}); });
const server = app.listen(config.port, () => { 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. * 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 { existsSync, readFileSync, statSync } from "fs";
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import type { ChainProvider } from "@marketplace/shared/chain"; 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 { canonicalize } from "@marketplace/shared/cbor";
import { runAccept } from "./cli/acceptFlow.js"; import { runAccept } from "./cli/acceptFlow.js";
import type { ResponseArchive } from "./db/archive.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+)$/; const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/;
@@ -74,6 +86,19 @@ export interface AppDeps {
archive?: ResponseArchive; archive?: ResponseArchive;
/** Injectable fetch for tests. Defaults to globalThis.fetch. */ /** Injectable fetch for tests. Defaults to globalThis.fetch. */
fetchImpl?: typeof 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 { interface ResolvedDeps {
@@ -125,6 +150,134 @@ export function createApp(deps: AppDeps): Express {
res.status(200).json({ ok: true }); 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 // chain/walletKey/indexerUrl are required for /v1/* routes; if any is
// missing we still mount the static bundle but the API endpoints respond // missing we still mount the static bundle but the API endpoints respond
// 503 service_unavailable so the SPA can render a clear "boot misconfigured" // 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 // can show the user their identity without the server leaking the
// private key into the page bundle. The SPA talks to /v1/* on this // private key into the page bundle. The SPA talks to /v1/* on this
// origin (relative URLs), so no indexer/ogmios endpoints leak either. // 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 indexPath = `${deps.distPath}/index.html`;
const cachedTemplate = existsSync(indexPath) const cachedTemplate = existsSync(indexPath)
? readFileSync(indexPath, "utf8") ? readFileSync(indexPath, "utf8")
@@ -825,17 +983,28 @@ export function createApp(deps: AppDeps): Express {
}, },
})};</script>` })};</script>`
: ""; : "";
const indexBody = cachedTemplate.replace( const indexWithBoot = cachedTemplate.replace(
"</head>", "</head>",
`${bootScript}\n </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) => { function isAuthenticatedHtmlRequest(req: Request): boolean {
if (!indexBody) { 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" }); return res.status(404).json({ error: "not_found" });
} }
res.setHeader("Content-Type", "text/html; charset=utf-8"); 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. * buyer/src/ui/App.tsx — router shell.
* *
* Routes: * Routes (all gated by <RequireAuth>):
* / → <Dashboard /> * / → <Dashboard />
* /tasks → <TaskHistory /> * /tasks → <TaskHistory />
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button * /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 TaskHistory from "./pages/TaskHistory.js";
import Wallet from "./pages/Wallet.js"; import Wallet from "./pages/Wallet.js";
import PendingReceipts from "./pages/PendingReceipts.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() { export default function App() {
return ( return (
<div className="min-h-screen bg-gray-50 text-gray-900"> <RequireAuth>
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex gap-6"> <div className="min-h-screen bg-gray-50 text-gray-900">
<Link to="/" className="font-medium hover:underline">Dashboard</Link> <nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link> <Link to="/" className="font-medium hover:underline">Dashboard</Link>
<Link to="/pending" className="font-medium hover:underline">Pending</Link> <Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link> <Link to="/pending" className="font-medium hover:underline">Pending</Link>
</nav> <Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
<main className="p-6"> <SignOutButton />
<Routes> </nav>
<Route path="/" element={<Dashboard />} /> <main className="p-6">
<Route path="/tasks" element={<TaskHistory />} /> <Routes>
<Route path="/pending" element={<PendingReceipts />} /> <Route path="/" element={<Dashboard />} />
<Route path="/wallet" element={<Wallet />} /> <Route path="/tasks" element={<TaskHistory />} />
</Routes> <Route path="/pending" element={<PendingReceipts />} />
</main> <Route path="/wallet" element={<Wallet />} />
</div> </Routes>
</main>
</div>
</RequireAuth>
); );
} }

View File

@@ -20,6 +20,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App.js"; import App from "./App.js";
import { MarketplaceProvider } from "./state/MarketplaceContext.js"; import { MarketplaceProvider } from "./state/MarketplaceContext.js";
import { AuthProvider } from "./state/AuthContext.js";
// MemoryTaskHistoryStore is a no-op placeholder for the browser-side SDK // MemoryTaskHistoryStore is a no-op placeholder for the browser-side SDK
// instance — submitPrompt now runs server-side, so the browser never // instance — submitPrompt now runs server-side, so the browser never
// records anything to a history store. The /tasks page reads directly // 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( createRoot(rootEl).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<MarketplaceProvider marketplace={marketplace}> <AuthProvider>
<App /> <MarketplaceProvider marketplace={marketplace}>
</MarketplaceProvider> <App />
</MarketplaceProvider>
</AuthProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </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}</>;
}

View File

@@ -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);
});
});

View File

@@ -19,6 +19,8 @@ describe("loadConfig(env)", () => {
INDEXER_URL: "http://localhost:3001", INDEXER_URL: "http://localhost:3001",
BUYER_PORT: "3002", BUYER_PORT: "3002",
NETWORK_ID: "0", NETWORK_ID: "0",
BUYER_PASSWORD: "hunter2",
SESSION_SECRET: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}; };
it("returns a BuyerConfig when all required env vars are present", () => { 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.indexerUrl).toBe(VALID_ENV.INDEXER_URL);
expect(config.port).toBe(3002); expect(config.port).toBe(3002);
expect(config.networkId).toBe(0); 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", () => { it("throws when BUYER_PRIV_KEY_HEX is missing", () => {
@@ -54,6 +59,34 @@ describe("loadConfig(env)", () => {
const config = loadConfig(env); const config = loadConfig(env);
expect(config.networkId).toBe(0); expect(config.networkId).toBe(0);
}); });
it("throws when BUYER_PASSWORD is missing", () => {
const env = { ...VALID_ENV };
delete (env as Record<string, string | undefined>).BUYER_PASSWORD;
expect(() => loadConfig(env)).toThrow(/BUYER_PASSWORD/);
});
it("throws when SESSION_SECRET is missing", () => {
const env = { ...VALID_ENV };
delete (env as Record<string, string | undefined>).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 ────────────────────────────────────────────────────────── // ─── createApp tests ──────────────────────────────────────────────────────────

View File

@@ -30,6 +30,7 @@ import Wallet from "../../buyer/src/ui/pages/Wallet.js";
import SupplierCard from "../../buyer/src/ui/components/SupplierCard.js"; import SupplierCard from "../../buyer/src/ui/components/SupplierCard.js";
import PromptForm from "../../buyer/src/ui/components/PromptForm.js"; import PromptForm from "../../buyer/src/ui/components/PromptForm.js";
import { MarketplaceProvider } from "../../buyer/src/ui/state/MarketplaceContext.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 type { SupplierView } from "../../buyer/src/sdk/types.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js"; import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
import { import {
@@ -83,9 +84,11 @@ function makeSampleSupplierView(overrides: Partial<SupplierView> = {}): Supplier
function wrap(element: React.ReactElement, mp: Marketplace) { function wrap(element: React.ReactElement, mp: Marketplace) {
return ( return (
<MemoryRouter> <MemoryRouter>
<MarketplaceProvider marketplace={mp}> <AuthProvider initialStatus="authenticated">
{element} <MarketplaceProvider marketplace={mp}>
</MarketplaceProvider> {element}
</MarketplaceProvider>
</AuthProvider>
</MemoryRouter> </MemoryRouter>
); );
} }