Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { BotSecretDestination, SecretHttpRequest } from "@rakazo/contracts";
|
|
import type { Prisma, PrismaClient } from "@rakazo/db";
|
|
import { combineSignals, redactConnectorPayload } from "./connector-safety.js";
|
|
import { createSafeRemoteFetch, type RemoteTransportDependencies } from "./remote-mcp.js";
|
|
import type { EncryptedSecretStore } from "./secrets.js";
|
|
import { readBodyCapped, withAbort } from "./web-ssrf.js";
|
|
|
|
export type BotSecretScope = { userId: string; spaceId: string; botId: string };
|
|
function scopeFields({ userId, spaceId, botId }: BotSecretScope): BotSecretScope {
|
|
return { userId, spaceId, botId };
|
|
}
|
|
|
|
const metadata = { name: true, origin: true, auth: true } as const;
|
|
|
|
function credentialHeader(destination: BotSecretDestination, plaintext: string) {
|
|
const name = destination.auth.type === "header" ? destination.auth.name : "Authorization";
|
|
const value =
|
|
destination.auth.type === "bearer"
|
|
? `Bearer ${plaintext}`
|
|
: destination.auth.type === "basic"
|
|
? `Basic ${Buffer.from(`${destination.auth.username}:${plaintext}`).toString("base64")}`
|
|
: plaintext;
|
|
try {
|
|
const headers = new Headers({ [name]: value });
|
|
if (headers.get(name) !== value) throw new Error("Header value was normalized");
|
|
} catch {
|
|
throw new Error("Credential cannot be used with this authentication method");
|
|
}
|
|
return { name, value };
|
|
}
|
|
|
|
export function normalizeSecretDestination(value: unknown): BotSecretDestination {
|
|
const destination = BotSecretDestination.parse(value);
|
|
return { ...destination, origin: new URL(destination.origin).origin };
|
|
}
|
|
|
|
export function sameSecretDestination(
|
|
left: BotSecretDestination,
|
|
right: BotSecretDestination,
|
|
): boolean {
|
|
return (
|
|
left.name === right.name &&
|
|
left.origin === right.origin &&
|
|
JSON.stringify(left.auth) === JSON.stringify(right.auth)
|
|
);
|
|
}
|
|
|
|
export async function findBotSecret(prisma: PrismaClient, scope: BotSecretScope, name: string) {
|
|
const row = await prisma.botSecret.findFirst({
|
|
where: { ...scopeFields(scope), name },
|
|
select: metadata,
|
|
});
|
|
return row ? normalizeSecretDestination(row) : null;
|
|
}
|
|
|
|
export async function storeBotSecret(input: {
|
|
tx: Prisma.TransactionClient;
|
|
secretStore: EncryptedSecretStore;
|
|
scope: BotSecretScope;
|
|
destination: BotSecretDestination;
|
|
plaintext: string;
|
|
}): Promise<void> {
|
|
const { tx, secretStore, scope, plaintext } = input;
|
|
if (!plaintext || plaintext.length > 16_384) throw new Error("Invalid credential length");
|
|
const destination = normalizeSecretDestination(input.destination);
|
|
credentialHeader(destination, plaintext);
|
|
// Serialize credential updates and deletions for a bot, including concurrent first saves.
|
|
await tx.$queryRaw`SELECT id FROM bots WHERE id = ${scope.botId} FOR UPDATE`;
|
|
const existing = await tx.botSecret.findFirst({
|
|
where: { ...scopeFields(scope), name: destination.name },
|
|
});
|
|
if (existing && !sameSecretDestination(normalizeSecretDestination(existing), destination)) {
|
|
throw new Error("Remove the existing credential before changing its destination");
|
|
}
|
|
if (!existing && (await tx.botSecret.count({ where: scopeFields(scope) })) >= 100) {
|
|
throw new Error("Credential limit reached");
|
|
}
|
|
const id = existing?.id ?? randomBytes(12).toString("hex");
|
|
const encrypted = await secretStore.put(
|
|
plaintext,
|
|
{
|
|
operationId: id,
|
|
traceId: id,
|
|
userId: scope.userId,
|
|
spaceId: scope.spaceId,
|
|
signal: new AbortController().signal,
|
|
},
|
|
id,
|
|
);
|
|
if (existing) {
|
|
await tx.botSecret.update({ where: { id }, data: { ciphertext: encrypted.ciphertext } });
|
|
} else {
|
|
await tx.botSecret.create({
|
|
data: { id, ...scopeFields(scope), ...destination, ciphertext: encrypted.ciphertext },
|
|
});
|
|
}
|
|
}
|
|
|
|
export function listBotSecrets(prisma: PrismaClient, scope: BotSecretScope) {
|
|
return prisma.botSecret.findMany({
|
|
where: scopeFields(scope),
|
|
select: metadata,
|
|
orderBy: { name: "asc" },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
export async function forgetBotSecret(prisma: PrismaClient, scope: BotSecretScope, name: string) {
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.$queryRaw`SELECT id FROM bots WHERE id = ${scope.botId} FOR UPDATE`;
|
|
await tx.botSecret.deleteMany({ where: { ...scopeFields(scope), name } });
|
|
});
|
|
return { removed: true };
|
|
}
|
|
|
|
/** Credentials are resolved only inside this destination-bound HTTP boundary. */
|
|
export async function requestWithBotSecret(input: {
|
|
prisma: PrismaClient;
|
|
secretStore: EncryptedSecretStore;
|
|
scope: BotSecretScope;
|
|
request: unknown;
|
|
signal: AbortSignal;
|
|
remote?: RemoteTransportDependencies;
|
|
registerRedactions?: (values: string[]) => void;
|
|
}): Promise<unknown> {
|
|
const request = SecretHttpRequest.parse(input.request);
|
|
const row = await input.prisma.botSecret.findFirst({
|
|
where: { ...scopeFields(input.scope), name: request.name },
|
|
});
|
|
if (!row) return { error: "Credential is unavailable. Use request_secret to save it first." };
|
|
const destination = normalizeSecretDestination(row);
|
|
const url = new URL(request.url);
|
|
if (url.origin !== destination.origin || url.username || url.password || url.hash) {
|
|
return { error: "This credential cannot be sent to that destination." };
|
|
}
|
|
const plaintext = input.secretStore.load(row.ciphertext, row.id);
|
|
const headers = new Headers({ accept: "application/json", "content-type": request.contentType });
|
|
const { name: headerName, value: headerValue } = credentialHeader(destination, plaintext);
|
|
const redactions = [
|
|
...new Set([
|
|
plaintext,
|
|
headerValue,
|
|
Buffer.from(plaintext).toString("base64"),
|
|
encodeURIComponent(plaintext),
|
|
headerValue.replace(/^Basic /, ""),
|
|
]),
|
|
].filter(Boolean);
|
|
input.registerRedactions?.(redactions);
|
|
const controller = new AbortController();
|
|
const signal = combineSignals(input.signal, controller.signal, AbortSignal.timeout(30_000));
|
|
const fetch = createSafeRemoteFetch(input.remote?.fetch, input.remote?.resolveHostname);
|
|
try {
|
|
headers.set(headerName, headerValue);
|
|
const response = await withAbort(
|
|
fetch(url, { method: request.method, headers, body: request.body, signal }),
|
|
signal,
|
|
);
|
|
const bytes = await readBodyCapped(response, 1_000_000, signal);
|
|
const text = new TextDecoder().decode(bytes);
|
|
let body: unknown = text;
|
|
try {
|
|
body = JSON.parse(text);
|
|
} catch {
|
|
/* Plain text responses are supported. */
|
|
}
|
|
// Redact before truncating, so an output boundary cannot expose part of a value.
|
|
const safe = JSON.stringify(redactConnectorPayload(body, redactions));
|
|
return {
|
|
status: response.status,
|
|
body: safe.length > 20_000 ? safe.slice(0, 20_000) : JSON.parse(safe),
|
|
truncated: safe.length > 20_000,
|
|
};
|
|
} catch {
|
|
return { error: "Authenticated request failed. Check the destination and credential." };
|
|
} finally {
|
|
controller.abort();
|
|
await withAbort(fetch.close(), AbortSignal.timeout(1000)).catch(() => undefined);
|
|
}
|
|
}
|