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

@@ -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) => {