feat(gateway,buyer): self-serve "Generate API key" page in the frontend

Add an "API Keys" page to the buyer SPA (marketplace.vector.apexfusion.org)
that mints a custodial gateway API key by POSTing directly to the gateway's
public /signup, then shows the key once + deposit address + a base_url/curl
usage snippet. Page lives behind the SPA's existing operator login; per-user
registration is a later feature.

Gateway:
- GATEWAY_CORS_ORIGINS env -> corsOrigins allowlist; corsMiddleware echoes
  Access-Control-* only for allowlisted Origins, answers OPTIONS preflight
  204, no credentials. Empty allowlist = CORS off (default safe).

Buyer:
- GATEWAY_PUBLIC_URL env -> injected into __BUYER_BOOT__.gatewayUrl (only in
  the authenticated boot block). SPA resolveGatewayUrl() falls back to
  deriving "api." + location.host so the post-login-no-reload case (boot
  block absent) still works.
- New ApiKeys page + /api-keys route + nav link.

Deploy (mainnet compose): gateway GATEWAY_CORS_ORIGINS and buyer
GATEWAY_PUBLIC_URL set to the marketplace/api hosts. NOTE: both images must be
REBUILT (gateway runs tsx from copied source; buyer SPA is vite-built at image
build) — an env-only recreate would silently ship the old code.

Typecheck + vite build clean; 205 tests pass. Adversarial multi-agent review
(4 dimensions, per-finding verification) surfaced only the rebuild-required
deploy note above; no code/security defects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-22 11:43:02 +02:00
parent f4f35866f5
commit 9b5e5b5710
10 changed files with 311 additions and 1 deletions

View File

@@ -36,6 +36,12 @@
* Defaults to "./data/archive" relative to cwd.
* When unset, archive is disabled (responses are
* not persisted; /v1/responses* return 503).
* GATEWAY_PUBLIC_URL — Public base URL of the OpenAI-compatible gateway
* (e.g. https://api.marketplace.vector.apexfusion.org).
* Injected into the SPA boot block so the "Generate
* API key" page knows where to POST /signup. When
* unset, the SPA falls back to deriving "api." +
* current host.
*/
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
@@ -56,6 +62,7 @@ export interface BuyerConfig {
password: string;
sessionSecret: string;
cookieSecure: boolean;
gatewayPublicUrl: string;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -141,6 +148,11 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
cookieSecure = cookieSecureStr === "1";
}
// GATEWAY_PUBLIC_URL — optional. Public base URL of the gateway, surfaced to
// the SPA via the boot block (no trailing slash). Empty string means the SPA
// derives the URL from its own host ("api." + location.host).
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
return {
privKeyHex,
indexerUrl,
@@ -155,5 +167,6 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
password,
sessionSecret,
cookieSecure,
gatewayPublicUrl,
};
}

View File

@@ -133,6 +133,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
password: config.password,
sessionSecret: config.sessionSecret,
cookieSecure: config.cookieSecure,
gatewayPublicUrl: config.gatewayPublicUrl,
jobStore,
pdfCaps,
});

View File

@@ -109,6 +109,11 @@ export interface AppDeps {
* brute-force rate-limit window. Defaults to Date.now. Tests use a fake
* clock to drive expiry and rolling-renewal assertions deterministically. */
nowMs?: () => number;
/** Public base URL of the OpenAI-compatible gateway, injected into the SPA
* boot block so the "Generate API key" page can POST cross-origin to
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
* SPA derives "api." + its own host. */
gatewayPublicUrl?: string;
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
* are live; when omitted they respond 503 (feature disabled). Built in
* runMain from the same marketplace/chain/wallet/archive deps. */
@@ -1310,6 +1315,9 @@ export function createApp(deps: AppDeps): Express {
pubKeyHash: resolved.walletKey.pubKeyHash,
address: displayAddress,
},
// Public gateway base URL for the "Generate API key" page. Empty
// string when unconfigured — the SPA then derives "api." + host.
gatewayUrl: deps.gatewayPublicUrl ?? "",
})};</script>`
: "";
const indexWithBoot = cachedTemplate.replace(

View File

@@ -6,6 +6,7 @@
* /tasks → <TaskHistory />
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
* /wallet → <Wallet />
* /api-keys → <ApiKeys /> self-serve gateway API key generation
*/
import { Routes, Route, Link } from "react-router-dom";
@@ -14,6 +15,7 @@ import TaskHistory from "./pages/TaskHistory.js";
import Wallet from "./pages/Wallet.js";
import PendingReceipts from "./pages/PendingReceipts.js";
import BookSummarizer from "./pages/BookSummarizer.js";
import ApiKeys from "./pages/ApiKeys.js";
import { RequireAuth, useAuth } from "./state/AuthContext.js";
function SignOutButton() {
@@ -39,6 +41,7 @@ export default function App() {
<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>
<Link to="/api-keys" className="font-medium hover:underline">API Keys</Link>
<SignOutButton />
</nav>
<main className="p-6">
@@ -48,6 +51,7 @@ export default function App() {
<Route path="/tasks" element={<TaskHistory />} />
<Route path="/pending" element={<PendingReceipts />} />
<Route path="/wallet" element={<Wallet />} />
<Route path="/api-keys" element={<ApiKeys />} />
</Routes>
</main>
</div>

32
buyer/src/ui/gateway.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* buyer/src/ui/gateway.ts — resolve the OpenAI-compatible gateway base URL the
* SPA should call for the "Generate API key" page.
*
* Resolution order:
* 1. window.__BUYER_BOOT__.gatewayUrl — injected by the buyer server from the
* GATEWAY_PUBLIC_URL env (the configured value). Present on any page load
* made while the buyer_session cookie is valid.
* 2. Derived "api." + current host — the deploy convention is exactly
* api.<buyer-host> (marketplace.… → api.marketplace.…). This covers the
* gap right after a fresh login, when the SPA flips auth state WITHOUT a
* page reload so the boot block (which is only injected into
* server-rendered HTML) hasn't been re-fetched yet.
*
* The result never has a trailing slash.
*/
interface BuyerBootMaybeGateway {
gatewayUrl?: string;
}
function stripTrailingSlashes(url: string): string {
return url.replace(/\/+$/, "");
}
export function resolveGatewayUrl(): string {
const boot = (window as unknown as { __BUYER_BOOT__?: BuyerBootMaybeGateway })
.__BUYER_BOOT__;
const configured = boot?.gatewayUrl?.trim();
if (configured) return stripTrailingSlashes(configured);
return `${window.location.protocol}//api.${window.location.host}`;
}

View File

@@ -0,0 +1,205 @@
/**
* buyer/src/ui/pages/ApiKeys.tsx — self-serve "Generate API key" page.
*
* Mints a custodial gateway API key by POSTing directly to the gateway's
* public /signup endpoint (cross-origin; the gateway CORS-allows this origin).
* The raw key is shown ONCE — it is never stored server-side in retrievable
* form, so it cannot be listed or recovered later. The page also surfaces the
* funding deposit address and a ready-to-paste usage snippet.
*
* Access is gated by the SPA's existing operator login (this page only renders
* inside <RequireAuth>). The gateway /signup itself stays public + IP
* rate-limited; per-user accounts are a later feature.
*/
import { useState } from "react";
import { resolveGatewayUrl } from "../gateway.js";
interface SignupResult {
api_key: string;
key_prefix: string;
deposit_address: string;
}
/** Pull a human-readable message out of whatever error shape the gateway
* returns (OpenAI `{error:{message}}`, plain `{error|message}`, or neither). */
function errorMessage(body: unknown, status: number, statusText: string): string {
if (body && typeof body === "object") {
const b = body as Record<string, unknown>;
const err = b.error;
if (err && typeof err === "object" && typeof (err as Record<string, unknown>).message === "string") {
return (err as Record<string, string>).message;
}
if (typeof err === "string") return err;
if (typeof b.message === "string") return b.message;
}
return `${status} ${statusText}`.trim() || "request failed";
}
function CopyButton({ value, label }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={() => {
void navigator.clipboard
?.writeText(value)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {
/* clipboard blocked (insecure context / permissions) — no-op */
});
}}
className="rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
{copied ? "Copied!" : (label ?? "Copy")}
</button>
);
}
export default function ApiKeys() {
const [label, setLabel] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SignupResult | null>(null);
const gatewayUrl = resolveGatewayUrl();
const baseUrl = `${gatewayUrl}/openai/v1`;
async function generate() {
setLoading(true);
setError(null);
setResult(null);
try {
const res = await fetch(`${gatewayUrl}/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(label.trim() ? { label: label.trim() } : {}),
});
const body = (await res.json().catch(() => null)) as unknown;
if (!res.ok) {
throw new Error(errorMessage(body, res.status, res.statusText));
}
const r = body as Partial<SignupResult> | null;
if (!r || typeof r.api_key !== "string" || typeof r.deposit_address !== "string") {
throw new Error("unexpected response from gateway");
}
setResult({
api_key: r.api_key,
key_prefix: r.key_prefix ?? r.api_key.slice(0, 12),
deposit_address: r.deposit_address,
});
} catch (e) {
setError(e instanceof Error ? e.message : "could not reach the gateway");
} finally {
setLoading(false);
}
}
const curlSnippet =
result === null
? ""
: [
`curl ${baseUrl}/chat/completions \\`,
` -H "Authorization: Bearer ${result.api_key}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '{"model": "<model>", "messages": [{"role": "user", "content": "Hello"}]}'`,
].join("\n");
return (
<div className="max-w-3xl space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">API Keys</h1>
<p className="text-sm text-gray-600">
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
custodial wallet — fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
<code className="font-mono text-xs">{baseUrl}</code>.
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4 space-y-3">
<label className="block text-sm font-medium text-gray-700">
Label <span className="font-normal text-gray-400">(optional)</span>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. my-laptop"
maxLength={120}
disabled={loading}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none disabled:opacity-50"
/>
</label>
<button
type="button"
onClick={() => void generate()}
disabled={loading}
className="rounded bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
data-testid="generate-api-key"
>
{loading ? "Generating…" : "Generate API key"}
</button>
{error !== null && (
<p className="text-sm text-red-600" data-testid="api-key-error">
{error}
</p>
)}
</div>
{result !== null && (
<div className="space-y-4 rounded-lg border border-amber-300 bg-amber-50 p-4" data-testid="api-key-result">
<div className="rounded border border-amber-400 bg-amber-100 px-3 py-2 text-sm text-amber-900">
Copy your API key now — it is shown <strong>only once</strong> and cannot be recovered. If you
lose it, generate a new one.
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">API key</span>
<CopyButton value={result.api_key} />
</div>
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
{result.api_key}
</code>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Deposit address (fund with AP3X)</span>
<CopyButton value={result.deposit_address} />
</div>
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
{result.deposit_address}
</code>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Base URL</span>
<CopyButton value={baseUrl} />
</div>
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
{baseUrl}
</code>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Quick start</span>
<CopyButton value={curlSnippet} />
</div>
<pre className="mt-1 overflow-x-auto rounded bg-gray-900 px-3 py-2 font-mono text-xs text-gray-100">
{curlSnippet}
</pre>
<p className="mt-1 text-xs text-gray-500">
Replace <code className="font-mono">&lt;model&gt;</code> with a model from{" "}
<code className="font-mono">{baseUrl}/models</code>.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -29,6 +29,11 @@ services:
# the response archive + PDF job store (past summaries) across container
# recreation/redeploys.
ARCHIVE_DIR: /repo/data/archive
# Public base URL of the OpenAI gateway, surfaced to the SPA boot block so
# the "API Keys" page can POST cross-origin to ${gatewayUrl}/signup. The
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
# NETWORK_ID intentionally not pinned — see supplier compose comment.
volumes:
- buyer-mainnet-archive:/repo/data/archive

View File

@@ -34,6 +34,10 @@ services:
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
# Browser Origins allowed to call the gateway cross-origin. The buyer
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
# /signup here. Comma-separated; empty disables CORS entirely.
GATEWAY_CORS_ORIGINS: https://marketplace.vector.apexfusion.org
volumes:
- gateway-mainnet-data:/repo/data/gateway
healthcheck:

View File

@@ -20,6 +20,10 @@
* KEY_RATE_MAX / KEY_RATE_WINDOW_MS — per-key request limiter.
* SWEEPER_INTERVAL_MS / WALLET_HEALTH_INTERVAL_MS — background tick cadences.
* SDK_REGISTRY_MAX — max cached per-key Marketplace instances (LRU).
* GATEWAY_CORS_ORIGINS — comma-separated allowlist of browser Origins that may
* call the gateway cross-origin (e.g. the marketplace
* frontend's "Generate API key" page). Default empty =
* CORS off (no Access-Control-* headers emitted).
*/
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
@@ -38,6 +42,7 @@ export interface GatewayConfig {
sweeperIntervalMs: number;
walletHealthIntervalMs: number;
sdkRegistryMax: number;
corsOrigins: string[];
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -109,5 +114,9 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
sweeperIntervalMs: posInt(env, "SWEEPER_INTERVAL_MS", 60 * 1000),
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",")
.map((o) => o.trim().replace(/\/+$/, ""))
.filter((o) => o !== ""),
};
}

View File

@@ -7,7 +7,7 @@
* POST /openai/v1/chat/sessions[/:id/messages|/close].
*/
import express, { type Express, type Request, type Response } from "express";
import express, { type Express, type NextFunction, type Request, type Response } from "express";
import type { GatewayDeps } from "./deps.js";
import { makeApiKeyAuth } from "./middleware/apiKeyAuth.js";
import { ipRateLimit, keyRateLimit } from "./middleware/rateLimit.js";
@@ -23,10 +23,39 @@ import {
import { makeSignupHandler, makeAccountHandler, makeWithdrawHandler } from "./account/routes.js";
import { INDEX_HTML } from "./ui/page.js";
/**
* CORS for browser clients on an allowlisted Origin (e.g. the marketplace
* frontend's "Generate API key" page, served from a different host than the
* gateway). Only echoes Access-Control-* headers when the request's Origin is
* in the configured allowlist; an empty allowlist disables CORS entirely.
* No credentials are allowed — the signup call is a plain bearer-less POST, so
* cookies never ride along cross-origin. Preflight OPTIONS is answered 204.
*/
function corsMiddleware(allowOrigins: string[]) {
const allow = new Set(allowOrigins);
return (req: Request, res: Response, next: NextFunction): void => {
const origin = req.headers.origin;
if (typeof origin === "string" && allow.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "600");
}
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
next();
};
}
export function createApp(deps: GatewayDeps): Express {
const app = express();
// Behind traefik/Cloudflare — trust the proxy chain so req.ip is the client.
app.set("trust proxy", true);
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
app.use(corsMiddleware(deps.config.corsOrigins));
app.use(express.json({ limit: "1mb" }));
app.get("/healthz", (_req: Request, res: Response) => {