forked from bai/curriculum-project-hub
feat: secure organization provider credentials
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
export type NetworkFailureCategory =
|
||||
| "timeout"
|
||||
| "dns"
|
||||
| "tls"
|
||||
| "connection"
|
||||
| "aborted"
|
||||
| "network";
|
||||
|
||||
/** Classify transport failures without retaining messages that may contain a URL. */
|
||||
export function classifyNetworkFailure(error: unknown): NetworkFailureCategory {
|
||||
const chain = errorChain(error);
|
||||
const names = chain.map((candidate) => candidate.name);
|
||||
const codes = chain.flatMap((candidate) =>
|
||||
typeof candidate.code === "string" ? [candidate.code.toUpperCase()] : []
|
||||
);
|
||||
if (names.includes("TimeoutError") || codes.some((code) => code === "ETIMEDOUT")) return "timeout";
|
||||
if (names.includes("AbortError") || codes.some((code) => code === "ABORT_ERR")) return "aborted";
|
||||
if (codes.some((code) => code === "ENOTFOUND" || code === "EAI_AGAIN")) return "dns";
|
||||
if (codes.some((code) => code.includes("TLS") || code.includes("CERT") || code.includes("SSL"))) return "tls";
|
||||
if (codes.some((code) => ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"].includes(code))) {
|
||||
return "connection";
|
||||
}
|
||||
return "network";
|
||||
}
|
||||
|
||||
function errorChain(error: unknown): Array<{ readonly name: string; readonly code?: unknown }> {
|
||||
const chain: Array<{ readonly name: string; readonly code?: unknown }> = [];
|
||||
let candidate = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
if (typeof candidate !== "object" || candidate === null) break;
|
||||
const record = candidate as { readonly name?: unknown; readonly code?: unknown; readonly cause?: unknown };
|
||||
chain.push({
|
||||
name: typeof record.name === "string" ? record.name : "Error",
|
||||
...(record.code !== undefined ? { code: record.code } : {}),
|
||||
});
|
||||
candidate = record.cause;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
import {
|
||||
LocalSecretEnvelope,
|
||||
type SecretEnvelopeV1,
|
||||
} from "../security/secretEnvelope.js";
|
||||
import { probeOpenRouterCredential, type ProviderReadinessProbe } from "./providerReadiness.js";
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
export interface ProviderCredentialInput {
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey?: string;
|
||||
}
|
||||
|
||||
export interface RotateByokProviderInput extends ProviderCredentialInput {
|
||||
readonly organizationId: string;
|
||||
readonly providerId: string;
|
||||
readonly actorUserId: string;
|
||||
}
|
||||
|
||||
export interface ProviderConnectionMetadata {
|
||||
readonly id: string;
|
||||
readonly providerId: string;
|
||||
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly activeVersion: number | null;
|
||||
readonly keyId: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ProviderConnectionWriteResult extends ProviderConnectionMetadata {
|
||||
readonly created: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderSecretPayloadV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
}
|
||||
|
||||
export class ProviderConnectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
private readonly readinessProbe: ProviderReadinessProbe = probeOpenRouterCredential,
|
||||
) {}
|
||||
|
||||
async rotateByok(input: RotateByokProviderInput): Promise<ProviderConnectionWriteResult> {
|
||||
const payload = validateProviderCredential(input);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireByokActor(tx, input);
|
||||
});
|
||||
await this.readinessProbe({
|
||||
providerId: input.providerId,
|
||||
baseUrl: payload.baseUrl,
|
||||
authToken: payload.authToken,
|
||||
anthropicApiKey: payload.anthropicApiKey,
|
||||
});
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireByokActor(tx, input);
|
||||
const connection = await tx.organizationProviderConnection.upsert({
|
||||
where: {
|
||||
organizationId_providerId: {
|
||||
organizationId: input.organizationId,
|
||||
providerId: input.providerId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
providerId: input.providerId,
|
||||
mode: "BYOK",
|
||||
status: "DRAFT",
|
||||
},
|
||||
});
|
||||
|
||||
await lockConnection(tx, connection.id);
|
||||
const locked = await tx.organizationProviderConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: {
|
||||
activeSecretVersion: { select: { id: true } },
|
||||
secretVersions: {
|
||||
orderBy: { version: "desc" },
|
||||
take: 1,
|
||||
select: { version: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (locked.organizationId !== input.organizationId || locked.providerId !== input.providerId) {
|
||||
throw new Error("provider connection scope changed while rotating credential");
|
||||
}
|
||||
if (locked.mode !== "BYOK") {
|
||||
throw new Error(
|
||||
"provider connection is managed by platform administrators and cannot be changed through organization API",
|
||||
);
|
||||
}
|
||||
|
||||
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
||||
const secretVersionId = randomUUID();
|
||||
const envelope = this.secrets.encryptJson({
|
||||
purpose: "provider-connection",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: locked.id,
|
||||
secretVersionId,
|
||||
}, payload);
|
||||
const now = new Date();
|
||||
|
||||
const secretVersion = await tx.providerCredentialVersion.create({
|
||||
data: {
|
||||
id: secretVersionId,
|
||||
connectionId: locked.id,
|
||||
version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
});
|
||||
if (locked.activeSecretVersion !== null) {
|
||||
await tx.providerCredentialVersion.update({
|
||||
where: { id: locked.activeSecretVersion.id },
|
||||
data: { retiredAt: now },
|
||||
});
|
||||
}
|
||||
const activated = await tx.organizationProviderConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
activeSecretVersionId: secretVersion.id,
|
||||
activatedAt: now,
|
||||
disabledAt: null,
|
||||
},
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: version === 1 ? "provider_connection.created" : "provider_connection.rotated",
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
providerId: input.providerId,
|
||||
mode: "BYOK",
|
||||
status: "ACTIVE",
|
||||
secretVersion: version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...metadata(activated, { version, keyId: secretVersion.keyId }),
|
||||
created: version === 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<readonly ProviderConnectionMetadata[]> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, organizationId);
|
||||
const connections = await tx.organizationProviderConnection.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ providerId: "asc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
activeSecretVersion: {
|
||||
select: { version: true, keyId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return connections.map((connection) => metadata(connection, connection.activeSecretVersion));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptStoredProviderCredential(
|
||||
secrets: LocalSecretEnvelope,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly connectionId: string;
|
||||
readonly providerId: string;
|
||||
readonly secretVersionId: string;
|
||||
readonly envelopeVersion: number;
|
||||
readonly keyId: string;
|
||||
readonly envelope: Prisma.JsonValue;
|
||||
},
|
||||
): ProviderSecretPayloadV1 {
|
||||
if (input.envelopeVersion !== 1) {
|
||||
throw new Error(`unsupported provider secret envelope version: ${input.envelopeVersion}`);
|
||||
}
|
||||
const envelope = input.envelope as unknown as SecretEnvelopeV1;
|
||||
if (envelope.keyId !== input.keyId || envelope.version !== input.envelopeVersion) {
|
||||
throw new Error("provider secret envelope metadata mismatch");
|
||||
}
|
||||
const payload = secrets.decryptJson<unknown>({
|
||||
purpose: "provider-connection",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: input.connectionId,
|
||||
secretVersionId: input.secretVersionId,
|
||||
}, envelope);
|
||||
return validateStoredProviderPayload(payload, input.providerId);
|
||||
}
|
||||
|
||||
export async function verifyStoredProviderEnvelopes(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
): Promise<number> {
|
||||
const versions = await prisma.providerCredentialVersion.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
envelopeVersion: true,
|
||||
keyId: true,
|
||||
envelope: true,
|
||||
connection: {
|
||||
select: { id: true, organizationId: true, providerId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const version of versions) {
|
||||
decryptStoredProviderCredential(secrets, {
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
providerId: version.connection.providerId,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
}
|
||||
return versions.length;
|
||||
}
|
||||
|
||||
export async function rewrapStoredProviderEnvelopes(
|
||||
prisma: Prisma.TransactionClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
): Promise<{ readonly verified: number; readonly rewrapped: number }> {
|
||||
const versions = await prisma.providerCredentialVersion.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
envelopeVersion: true,
|
||||
keyId: true,
|
||||
envelope: true,
|
||||
connection: {
|
||||
select: { id: true, organizationId: true, providerId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
let rewrapped = 0;
|
||||
for (const version of versions) {
|
||||
const binding = {
|
||||
purpose: "provider-connection",
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
secretVersionId: version.id,
|
||||
} as const;
|
||||
decryptStoredProviderCredential(secrets, {
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
providerId: version.connection.providerId,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
if (version.keyId === secrets.activeKeyId) continue;
|
||||
|
||||
const envelope = version.envelope as unknown as SecretEnvelopeV1;
|
||||
const nextEnvelope = secrets.rewrap(binding, envelope);
|
||||
const updated = await prisma.providerCredentialVersion.updateMany({
|
||||
where: { id: version.id, keyId: version.keyId },
|
||||
data: {
|
||||
keyId: nextEnvelope.keyId,
|
||||
envelope: nextEnvelope as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`provider secret envelope changed during KEK rotation: ${version.id}`);
|
||||
}
|
||||
await prisma.auditEntry.create({
|
||||
data: {
|
||||
organizationId: version.connection.organizationId,
|
||||
action: "provider_secret.kek_rewrapped",
|
||||
metadata: {
|
||||
connectionId: version.connection.id,
|
||||
providerId: version.connection.providerId,
|
||||
secretVersionId: version.id,
|
||||
previousKeyId: version.keyId,
|
||||
keyId: nextEnvelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
rewrapped += 1;
|
||||
}
|
||||
const verified = await verifyStoredProviderEnvelopes(prisma, secrets);
|
||||
const remaining = await prisma.providerCredentialVersion.count({
|
||||
where: { keyId: { not: secrets.activeKeyId } },
|
||||
});
|
||||
if (remaining !== 0) {
|
||||
throw new Error(`KEK rotation left ${remaining} provider envelope(s) on a non-active key`);
|
||||
}
|
||||
return { verified, rewrapped };
|
||||
}
|
||||
|
||||
function validateProviderCredential(input: RotateByokProviderInput): ProviderSecretPayloadV1 {
|
||||
if (!PROVIDER_ID_PATTERN.test(input.providerId)) {
|
||||
throw new Error("invalid provider connection id");
|
||||
}
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(input.baseUrl);
|
||||
} catch (error) {
|
||||
throw new Error("invalid provider base URL", { cause: error });
|
||||
}
|
||||
if (baseUrl.protocol !== "https:") {
|
||||
throw new Error("invalid provider base URL: HTTPS is required");
|
||||
}
|
||||
const authToken = input.authToken.trim();
|
||||
if (authToken === "") {
|
||||
throw new Error("provider auth token is required");
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
baseUrl: baseUrl.toString().replace(/\/$/, ""),
|
||||
authToken,
|
||||
anthropicApiKey: input.anthropicApiKey?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function metadata(
|
||||
connection: {
|
||||
readonly id: string;
|
||||
readonly providerId: string;
|
||||
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
},
|
||||
secret: { readonly version: number; readonly keyId: string } | null,
|
||||
): ProviderConnectionMetadata {
|
||||
return {
|
||||
id: connection.id,
|
||||
providerId: connection.providerId,
|
||||
mode: connection.mode,
|
||||
status: connection.status,
|
||||
activeVersion: secret?.version ?? null,
|
||||
keyId: secret?.keyId ?? null,
|
||||
createdAt: connection.createdAt,
|
||||
updatedAt: connection.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function validateStoredProviderPayload(value: unknown, providerId: string): ProviderSecretPayloadV1 {
|
||||
if (!isRecord(value) || value["schemaVersion"] !== 1 || typeof value["baseUrl"] !== "string" ||
|
||||
typeof value["authToken"] !== "string" || typeof value["anthropicApiKey"] !== "string" ||
|
||||
value["authToken"].trim() === "") {
|
||||
throw new Error(`invalid encrypted provider credential payload: ${providerId}`);
|
||||
}
|
||||
let baseUrl: URL;
|
||||
try {
|
||||
baseUrl = new URL(value["baseUrl"]);
|
||||
} catch (error) {
|
||||
throw new Error(`invalid encrypted provider credential payload: ${providerId}`, { cause: error });
|
||||
}
|
||||
if (baseUrl.protocol !== "https:") {
|
||||
throw new Error(`invalid encrypted provider credential payload: ${providerId}`);
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
baseUrl: value["baseUrl"],
|
||||
authToken: value["authToken"],
|
||||
anthropicApiKey: value["anthropicApiKey"],
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function lockConnection(tx: Prisma.TransactionClient, connectionId: string): Promise<void> {
|
||||
await tx.$queryRaw`SELECT "id" FROM "OrganizationProviderConnection" WHERE "id" = ${connectionId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
async function requireByokActor(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: Pick<RotateByokProviderInput, "organizationId" | "providerId" | "actorUserId">,
|
||||
): Promise<void> {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const [authorizedActor, existingConnection] = await Promise.all([
|
||||
tx.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.actorUserId,
|
||||
role: { in: ["OWNER", "ADMIN"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
tx.organizationProviderConnection.findUnique({
|
||||
where: {
|
||||
organizationId_providerId: {
|
||||
organizationId: input.organizationId,
|
||||
providerId: input.providerId,
|
||||
},
|
||||
},
|
||||
select: { mode: true },
|
||||
}),
|
||||
]);
|
||||
if (authorizedActor === null) {
|
||||
throw new Error("BYOK provider rotation requires an active Organization OWNER or ADMIN");
|
||||
}
|
||||
if (existingConnection?.mode === "PLATFORM_MANAGED") {
|
||||
throw new Error(
|
||||
"provider connection is managed by platform administrators and cannot be changed through organization API",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
||||
|
||||
const MAX_PROVIDER_REQUEST_BYTES = 32 * 1024 * 1024;
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
const REPLACED_REQUEST_HEADERS = new Set([
|
||||
...HOP_BY_HOP_HEADERS,
|
||||
"authorization",
|
||||
"content-length",
|
||||
"host",
|
||||
"x-api-key",
|
||||
]);
|
||||
|
||||
export interface ProviderUpstreamCredential {
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
}
|
||||
|
||||
export interface AgentProviderLease {
|
||||
readonly sdkEnv: Readonly<Record<string, string | undefined>>;
|
||||
readonly sensitiveValues: readonly string[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProviderProxyDiagnostic {
|
||||
readonly code:
|
||||
| "provider_proxy_unauthorized"
|
||||
| "provider_proxy_request_too_large"
|
||||
| "provider_proxy_redirect_refused"
|
||||
| "provider_proxy_upstream_failed";
|
||||
readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect";
|
||||
}
|
||||
|
||||
export interface ProviderProxyOptions {
|
||||
readonly onDiagnostic?: (diagnostic: ProviderProxyDiagnostic) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one loopback-only proxy for one Agent run. The Agent receives only a
|
||||
* random run capability; customer provider credentials stay in this Hub
|
||||
* closure and are attached immediately before the upstream request.
|
||||
*/
|
||||
export async function openProviderProxyLease(
|
||||
upstream: ProviderUpstreamCredential,
|
||||
options: ProviderProxyOptions = {},
|
||||
): Promise<AgentProviderLease> {
|
||||
const capability = randomBytes(32).toString("base64url");
|
||||
const handler = (request: IncomingMessage, response: ServerResponse): void => {
|
||||
void forwardProviderRequest(request, response, capability, upstream, options);
|
||||
};
|
||||
const server = createServer(handler);
|
||||
server.requestTimeout = 5 * 60_000;
|
||||
server.headersTimeout = 30_000;
|
||||
server.maxRequestsPerSocket = 1_000;
|
||||
server.listen(0, "127.0.0.1");
|
||||
await Promise.race([
|
||||
once(server, "listening"),
|
||||
once(server, "error").then(([error]) => Promise.reject(error)),
|
||||
]);
|
||||
server.unref();
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("provider proxy did not bind a TCP address");
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: capability,
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
sensitiveValues: [capability],
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
server.closeAllConnections();
|
||||
if (!server.listening) return;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function forwardProviderRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
capability: string,
|
||||
upstream: ProviderUpstreamCredential,
|
||||
options: ProviderProxyOptions,
|
||||
): Promise<void> {
|
||||
if (!authorized(request.headers.authorization, capability)) {
|
||||
options.onDiagnostic?.({ code: "provider_proxy_unauthorized", category: "authorization" });
|
||||
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestUrl = new URL(request.url ?? "/", "http://provider-proxy.invalid");
|
||||
const upstreamBase = upstream.baseUrl.endsWith("/") ? upstream.baseUrl : `${upstream.baseUrl}/`;
|
||||
const upstreamUrl = new URL(`${requestUrl.pathname.replace(/^\//, "")}${requestUrl.search}`, upstreamBase);
|
||||
const body = await readBoundedBody(request);
|
||||
const headers = forwardedRequestHeaders(request.headers);
|
||||
headers.set("authorization", `Bearer ${upstream.authToken}`);
|
||||
if (upstream.anthropicApiKey !== "") {
|
||||
headers.set("x-api-key", upstream.anthropicApiKey);
|
||||
}
|
||||
headers.set("accept-encoding", "identity");
|
||||
|
||||
const abort = new AbortController();
|
||||
request.once("aborted", () => abort.abort());
|
||||
const upstreamResponse = await fetch(upstreamUrl, {
|
||||
method: request.method ?? "GET",
|
||||
headers,
|
||||
...(body.byteLength === 0 ? {} : { body }),
|
||||
redirect: "manual",
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
|
||||
options.onDiagnostic?.({ code: "provider_proxy_redirect_refused", category: "redirect" });
|
||||
await upstreamResponse.body?.cancel();
|
||||
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("provider redirect refused");
|
||||
return;
|
||||
}
|
||||
response.writeHead(upstreamResponse.status, forwardedResponseHeaders(upstreamResponse.headers));
|
||||
if (upstreamResponse.body === null) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
for await (const chunk of upstreamResponse.body) {
|
||||
if (!response.write(Buffer.from(chunk))) {
|
||||
await once(response, "drain");
|
||||
}
|
||||
}
|
||||
response.end();
|
||||
} catch (error) {
|
||||
const diagnostic = error instanceof ProviderProxyRequestError
|
||||
? { code: error.code, category: error.category } as const
|
||||
: {
|
||||
code: "provider_proxy_upstream_failed",
|
||||
category: classifyNetworkFailure(error),
|
||||
} as const;
|
||||
options.onDiagnostic?.(diagnostic);
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("provider request failed");
|
||||
} else {
|
||||
response.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function authorized(value: string | undefined, capability: string): boolean {
|
||||
if (value === undefined || !value.startsWith("Bearer ")) return false;
|
||||
const supplied = Buffer.from(value.slice("Bearer ".length));
|
||||
const expected = Buffer.from(capability);
|
||||
return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected);
|
||||
}
|
||||
|
||||
function forwardedRequestHeaders(source: IncomingHttpHeaders): Headers {
|
||||
const result = new Headers();
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (value === undefined || REPLACED_REQUEST_HEADERS.has(name.toLowerCase())) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) result.append(name, item);
|
||||
} else {
|
||||
result.set(name, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function forwardedResponseHeaders(source: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [name, value] of source.entries()) {
|
||||
const normalized = name.toLowerCase();
|
||||
if (HOP_BY_HOP_HEADERS.has(normalized) || normalized === "content-length" ||
|
||||
normalized === "content-encoding") continue;
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readBoundedBody(request: IncomingMessage): Promise<Buffer> {
|
||||
const declared = Number(request.headers["content-length"] ?? 0);
|
||||
if (Number.isFinite(declared) && declared > MAX_PROVIDER_REQUEST_BYTES) {
|
||||
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
bytes += buffer.byteLength;
|
||||
if (bytes > MAX_PROVIDER_REQUEST_BYTES) {
|
||||
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
return Buffer.concat(chunks, bytes);
|
||||
}
|
||||
|
||||
class ProviderProxyRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: "provider_proxy_request_too_large",
|
||||
readonly category: "request_limit",
|
||||
) {
|
||||
super(code);
|
||||
this.name = "ProviderProxyRequestError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
||||
|
||||
export interface ProviderReadinessInput {
|
||||
readonly providerId: string;
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
}
|
||||
|
||||
export type ProviderReadinessProbe = (input: ProviderReadinessInput) => Promise<void>;
|
||||
|
||||
export class ProviderReadinessError extends Error {
|
||||
constructor(
|
||||
readonly code: "provider_readiness_unsupported" | "provider_readiness_unreachable" | "provider_readiness_rejected",
|
||||
message: string,
|
||||
readonly category: NetworkFailureCategory | "configuration" | "http",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderReadinessError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify the pilot's OpenRouter credential without starting or billing a model run. */
|
||||
export async function probeOpenRouterCredential(input: ProviderReadinessInput): Promise<void> {
|
||||
if (input.providerId !== "openrouter") {
|
||||
throw new ProviderReadinessError(
|
||||
"provider_readiness_unsupported",
|
||||
`provider readiness probe is unavailable: ${input.providerId}`,
|
||||
"configuration",
|
||||
);
|
||||
}
|
||||
const base = input.baseUrl.endsWith("/") ? input.baseUrl : `${input.baseUrl}/`;
|
||||
const url = new URL("v1/key", base);
|
||||
const headers = new Headers({
|
||||
authorization: `Bearer ${input.authToken}`,
|
||||
accept: "application/json",
|
||||
});
|
||||
if (input.anthropicApiKey !== "") headers.set("x-api-key", input.anthropicApiKey);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ProviderReadinessError(
|
||||
"provider_readiness_unreachable",
|
||||
"provider credential readiness check could not reach provider",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (!response.ok) {
|
||||
throw new ProviderReadinessError(
|
||||
"provider_readiness_rejected",
|
||||
`provider credential readiness check failed: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user