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:
@@ -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 !== ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user