SysDeck/klanker-gate/apps/gateway/routes/admin.ts

403 lines
13 KiB
TypeScript
Executable File

import type { ProviderAccountConfig } from "../../../packages/contracts/src/mod.ts";
import {
GatewayConfigSchema,
GlobalProxyConfigSchema,
ProviderAccountConfigSchema,
redactGlobalProxy,
redactProviderAccount,
} from "../../../packages/contracts/src/mod.ts";
import {
errorResponse,
GatewayError,
type Router,
} from "../../../packages/core/src/mod.ts";
import type { AppContext } from "../context.ts";
import {
jsonResponse,
mapDispatchError,
parseJsonBody,
validationErrorResponse,
} from "./helpers.ts";
/** Browser-safe view for create/update responses: identical redaction to the
* provider listing (drops apiKey, cloud creds, proxyUrl, proxy.proxyPassword,
* network.caCertPem; surfaces hasApiKey/hasProxyPassword/hasCaCert flags). */
function publicView(config: ProviderAccountConfig) {
return redactProviderAccount(config);
}
/**
* Operator EUR-per-USD display rate (FROSTY_EUR_RATE). Cost stays canonical
* micro-USD in the gateway; the UI multiplies by this to present euros. Defaults
* to 0.92 and ignores non-positive / non-finite values.
*/
function eurRateFromEnv(): number {
const raw = Number(Deno.env.get("FROSTY_EUR_RATE"));
return Number.isFinite(raw) && raw > 0 ? raw : 0.92;
}
/** Live provider health, mirroring the MCP health surface. */
interface ProviderHealth {
id: string;
type: string;
status: "ok" | "error" | "unknown" | "disabled";
lastError?: string;
checkedAt: string;
}
interface HealthProbeInput {
id: string;
type: string;
enabled: boolean;
hasApiKey?: boolean;
hasCloudCredentials?: boolean;
}
const HEALTH_TTL_MS = 60_000;
const HEALTH_PROBE_TIMEOUT_MS = 5_000;
/** Collapse whitespace + cap length so a probe error never leaks a big body;
* adapter list-model errors carry status/URL text, never the stored key. */
function sanitizeHealthError(error: unknown): string {
const msg = error instanceof Error ? error.message : String(error);
return msg.replace(/\s+/g, " ").trim().slice(0, 200);
}
/**
* Probe one provider's reachability with a cheap live model list. Disabled
* accounts and accounts with no credentials are reported without a network call
* (nothing to probe); "unknown" means "cannot determine", never "healthy".
*/
async function probeProvider(
ctx: AppContext,
p: HealthProbeInput,
): Promise<ProviderHealth> {
const base = { id: p.id, type: p.type, checkedAt: new Date().toISOString() };
if (!p.enabled) {
return { ...base, status: "disabled" };
}
if (!p.hasApiKey && !p.hasCloudCredentials) {
return { ...base, status: "unknown" };
}
let adapter;
try {
adapter = ctx.providers.accountTarget(p.id).adapter;
} catch {
return { ...base, status: "unknown" };
}
if (!adapter.listModels) {
return { ...base, status: "unknown" };
}
try {
await adapter.listModels({
signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS),
});
return { ...base, status: "ok" };
} catch (error) {
return { ...base, status: "error", lastError: sanitizeHealthError(error) };
}
}
async function persist(
ctx: AppContext,
config: ProviderAccountConfig,
): Promise<void> {
ctx.providers.upsert(config);
await ctx.config?.upsertProvider(config);
}
export function registerAdminRoutes(router: Router, ctx: AppContext): void {
router.get("/api/providers", () => {
return jsonResponse({ providers: ctx.providers.listPublic() });
});
router.post("/api/providers", async (req) => {
const parsed = ProviderAccountConfigSchema.safeParse(
await parseJsonBody(req),
);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
await persist(ctx, parsed.data);
return jsonResponse(publicView(parsed.data), 201);
});
router.put("/api/providers/:id", async (req, match) => {
const id = match.pathname.groups.id!;
const existing = ctx.providers.get(id);
if (!existing) {
return errorResponse(404, `Unknown provider "${id}".`);
}
const patch = await parseJsonBody(req);
if (typeof patch !== "object" || patch === null) {
return errorResponse(400, "Request body must be a JSON object.");
}
const merged = ProviderAccountConfigSchema.safeParse({
...existing,
...(patch as Record<string, unknown>),
id, // the path segment wins
});
if (!merged.success) {
return validationErrorResponse(merged.error);
}
await persist(ctx, merged.data);
return jsonResponse(publicView(merged.data));
});
router.delete("/api/providers/:id", async (_req, match) => {
const id = match.pathname.groups.id!;
if (!ctx.providers.get(id)) {
return errorResponse(404, `Unknown provider "${id}".`);
}
ctx.providers.remove(id);
await ctx.config?.deleteProvider(id);
return new Response(null, { status: 204 });
});
router.post("/api/providers/:id/refresh-models", async (req, match) => {
const id = match.pathname.groups.id!;
const existing = ctx.providers.get(id);
if (!existing) {
return errorResponse(404, `Unknown provider "${id}".`);
}
try {
const target = ctx.providers.resolve(`${id}/_refresh`);
if (!target.adapter.listModels) {
throw new GatewayError(
400,
`Provider type "${existing.type}" does not support live model listing.`,
);
}
const models = await target.adapter.listModels({ signal: req.signal });
const updated = { ...existing, models };
await persist(ctx, updated);
return jsonResponse({ id, models });
} catch (error) {
return mapDispatchError(error);
}
});
// Read-only companion to refresh-models: returns the provider's full live
// model list WITHOUT persisting it. The account's `models` stays the enabled
// set the gateway routes on; the Model Catalog grid diffs the two so an
// operator can toggle individual models on and off.
router.get("/api/providers/:id/available-models", async (req, match) => {
const id = match.pathname.groups.id!;
const existing = ctx.providers.get(id);
if (!existing) {
return errorResponse(404, `Unknown provider "${id}".`);
}
try {
const target = ctx.providers.resolve(`${id}/_list`);
if (!target.adapter.listModels) {
throw new GatewayError(
400,
`Provider type "${existing.type}" does not support live model listing.`,
);
}
const models = await target.adapter.listModels({ signal: req.signal });
return jsonResponse({ id, models });
} catch (error) {
return mapDispatchError(error);
}
});
// Live provider health for the Providers-page status badge. Probes are cached
// per provider (60s TTL) so polling the badge never hammers upstreams; each
// probe is a cheap list-models call with a short timeout.
const healthCache = new Map<
string,
{ entry: ProviderHealth; expiresAt: number }
>();
router.get("/api/providers/health", async () => {
const now = Date.now();
const health = await Promise.all(
ctx.providers.listPublic().map(async (p) => {
const cached = healthCache.get(p.id);
if (cached && cached.expiresAt > now) {
return cached.entry;
}
const entry = await probeProvider(ctx, p);
healthCache.set(p.id, { entry, expiresAt: now + HEALTH_TTL_MS });
return entry;
}),
);
// Drop cache rows for providers that no longer exist.
const live = new Set(health.map((h) => h.id));
for (const id of [...healthCache.keys()]) {
if (!live.has(id)) {
healthCache.delete(id);
}
}
return jsonResponse({ health });
});
router.get("/api/config", () => {
return jsonResponse({
defaultProvider: ctx.providers.getDefaultProvider(),
providers: ctx.providers.listPublic(),
eurRate: eurRateFromEnv(),
});
});
router.put("/api/config", async (req) => {
const body = await parseJsonBody(req);
const parsed = GatewayConfigSchema.pick({ defaultProvider: true })
.strip()
.safeParse(body);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
ctx.providers.setDefaultProvider(parsed.data.defaultProvider);
await ctx.config?.setDefaultProvider(parsed.data.defaultProvider);
return jsonResponse({ defaultProvider: parsed.data.defaultProvider });
});
router.get("/api/proxy-config", () => {
return jsonResponse(redactGlobalProxy(ctx.providers.getGlobalProxy()));
});
router.put("/api/proxy-config", async (req) => {
if (!ctx.config) {
return errorResponse(400, "No persistent config store is attached.");
}
if (!ctx.config.hasActiveEncryption()) {
return errorResponse(
409,
"Global proxy configuration requires FROSTY_ENCRYPTION_KEY.",
);
}
const parsed = GlobalProxyConfigSchema.safeParse(await parseJsonBody(req));
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
const previous = ctx.providers.getGlobalProxy();
try {
// Prepare/swap runtime clients before writing, then roll back that swap if
// persistence fails. Neither rejected input nor a failed KV write changes
// the effective proxy policy.
ctx.providers.configureGlobalProxy(parsed.data);
try {
await ctx.config.setGlobalProxy(parsed.data);
} catch (error) {
ctx.providers.configureGlobalProxy(previous);
throw error;
}
return jsonResponse(redactGlobalProxy(parsed.data));
} catch (error) {
return mapDispatchError(error);
}
});
router.delete("/api/proxy-config", async () => {
if (!ctx.config) {
return errorResponse(400, "No persistent config store is attached.");
}
const previous = ctx.providers.getGlobalProxy();
try {
ctx.providers.configureGlobalProxy(undefined);
try {
await ctx.config.deleteGlobalProxy();
} catch (error) {
ctx.providers.configureGlobalProxy(previous);
throw error;
}
return new Response(null, { status: 204 });
} catch (error) {
return mapDispatchError(error);
}
});
router.post("/api/config/reload", async () => {
if (!ctx.config) {
return errorResponse(400, "No persistent config store is attached.");
}
const loaded = await ctx.config.loadAll();
ctx.providers.configureGlobalProxy(await ctx.config.getGlobalProxy());
for (const existing of ctx.providers.list()) {
ctx.providers.remove(existing.id);
}
for (const provider of loaded.providers) {
ctx.providers.upsert(provider);
}
ctx.providers.setDefaultProvider(loaded.defaultProvider);
return jsonResponse({
reloaded: true,
providers: loaded.providers.length,
defaultProvider: loaded.defaultProvider,
});
});
router.get("/api/config/export", async (req) => {
if (!ctx.config) {
return errorResponse(400, "No persistent config store is attached.");
}
const includeSecrets =
new URL(req.url).searchParams.get("include_secrets") === "true";
return jsonResponse(await ctx.config.exportConfig(includeSecrets));
});
router.post("/api/config/import", async (req) => {
if (!ctx.config) {
return errorResponse(400, "No persistent config store is attached.");
}
try {
const imported = await ctx.config.importConfig(await parseJsonBody(req));
for (const existing of ctx.providers.list()) {
ctx.providers.remove(existing.id);
}
for (const provider of imported.providers) {
ctx.providers.upsert(provider);
}
ctx.providers.setDefaultProvider(imported.defaultProvider);
return jsonResponse({
imported: true,
providers: imported.providers.length,
});
} catch (error) {
if (error instanceof GatewayError) {
return mapDispatchError(error);
}
// Log the detail server-side; return a fixed message so an internal
// error/stack is never echoed to the client (cf. sanitizeHealthError).
console.error(`config import failed: ${String(error)}`);
return errorResponse(
400,
"Import failed: invalid configuration payload.",
);
}
});
}
/** Bearer-token gate for /api/* when FROSTY_ADMIN_TOKEN is configured. */
export function adminAuthMiddleware(
adminToken: string | undefined,
): (
req: Request,
next: (req: Request) => Promise<Response>,
) => Promise<Response> {
return async (req, next) => {
if (!adminToken) {
return await next(req); // explicit local-admin mode
}
const url = new URL(req.url);
// /metrics carries per-key traffic labels, so a locked-down control
// plane locks it too (Prometheus supports bearer_token scrape config).
const isAdminSurface = (url.pathname.startsWith("/api/") &&
url.pathname !== "/api/version") ||
url.pathname === "/metrics";
if (!isAdminSurface) {
return await next(req);
}
const auth = req.headers.get("Authorization");
if (auth !== `Bearer ${adminToken}`) {
return errorResponse(
401,
"Missing or invalid admin token.",
"auth_error",
);
}
return await next(req);
};
}