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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
32
buyer/src/ui/gateway.ts
Normal 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}`;
|
||||
}
|
||||
205
buyer/src/ui/pages/ApiKeys.tsx
Normal file
205
buyer/src/ui/pages/ApiKeys.tsx
Normal 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"><model></code> with a model from{" "}
|
||||
<code className="font-mono">{baseUrl}/models</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user