/** * IPC surface for minting one-time credential-collection links. * * The daemon (settings page via its credential-requests route, and the * secret prompter's non-vellum-channel fallback) calls * `create_credential_request` over the gateway IPC socket. The gateway owns * all link/token state; the plaintext token leaves this process only inside * the returned URL. */ import { randomUUID } from "node:crypto"; import { z } from "zod"; import { generateInviteToken, hashInviteToken } from "@vellumai/gateway-client"; import type { GatewayConfig } from "../config.js"; import type { ConfigFileCache } from "../config-file-cache.js"; import type { CredentialCache } from "../credential-cache.js"; import { credentialKey } from "../credential-key.js"; import { CredentialRequestStore, MAX_ACTIVE_CREDENTIAL_REQUESTS, } from "../db/credential-request-store.js"; import { getLogger } from "../logger.js"; import { resolvePublicHttpBaseUrl } from "../runtime/client.js"; import type { IpcRoute } from "./server.js"; const log = getLogger("credential-requests"); export const DEFAULT_CREDENTIAL_REQUEST_TTL_MS = 30 * 60_000; const MAX_CREDENTIAL_REQUEST_TTL_MS = 24 * 60 * 60_000; // Sliding-window creation limiter (mirrors the remote-web pairing store). const RATE_LIMIT_MAX_CREATIONS = 20; const RATE_LIMIT_WINDOW_MS = 60_000; const creationTimestamps: number[] = []; function creationRateLimited(now: number): boolean { const windowStart = now - RATE_LIMIT_WINDOW_MS; while ( creationTimestamps.length > 0 && creationTimestamps[0] <= windowStart ) { creationTimestamps.shift(); } if (creationTimestamps.length >= RATE_LIMIT_MAX_CREATIONS) { return true; } creationTimestamps.push(now); return false; } export function resetCredentialRequestRateLimiterForTests(): void { creationTimestamps.length = 0; } const CreateCredentialRequestSchema = z.object({ service: z.string().min(1), field: z.string().min(1), label: z.string().optional(), purpose: z.enum(["standalone", "prompt"]).optional(), secretPromptId: z.string().optional(), policyJson: z.string().optional(), ttlMs: z.number().int().positive().optional(), }); export type CreateCredentialRequestResult = | { ok: true; token: string; url: string; expiresAt: number } | { ok: false; error: "no_public_base_url" | "rate_limited" | "too_many_active"; }; export interface CreateCredentialRequestDeps { /** * Lazily read `vellum:platform_assistant_id` — only invoked for the Velay * fallback, so a config-resolved (self-hosted/manual) URL never touches CES. */ getPlatformAssistantId?: () => Promise; /** * Enable public ingress (if disabled) and start the Velay tunnel so the * minted link is actually reachable. Wired by the gateway entrypoint, which * owns the tunnel client. */ ensurePublicIngressLive?: () => Promise; } export function createCredentialRequestIpcRoutes( config: GatewayConfig, configFile: ConfigFileCache, credentials: CredentialCache, ensurePublicIngressLive: () => Promise, ): IpcRoute[] { return [ { method: "create_credential_request", schema: CreateCredentialRequestSchema, handler: async (params?: Record) => { const parsed = CreateCredentialRequestSchema.parse(params ?? {}); return createCredentialRequest(config, configFile, parsed, { getPlatformAssistantId: () => credentials .get(credentialKey("vellum", "platform_assistant_id")) .then((value) => value?.trim()), ensurePublicIngressLive, }); }, }, ]; } export async function createCredentialRequest( config: GatewayConfig, configFile: ConfigFileCache, params: z.infer, deps: CreateCredentialRequestDeps = {}, ): Promise { const publicBaseUrl = await resolvePublicHttpBaseUrl( config, configFile, deps.getPlatformAssistantId, ); if (!publicBaseUrl) { return { ok: false, error: "no_public_base_url" }; } const now = Date.now(); if (creationRateLimited(now)) { return { ok: false, error: "rate_limited" }; } const store = new CredentialRequestStore(); if (store.countActive(now) >= MAX_ACTIVE_CREDENTIAL_REQUESTS) { return { ok: false, error: "too_many_active" }; } // All guards passed — the link WILL be minted, so now make ingress live: // enable it if it was explicitly disabled AND start the Velay tunnel (on // platform the tunnel otherwise only starts for Twilio / live voice, so the const ttlMs = Math.min( params.ttlMs ?? DEFAULT_CREDENTIAL_REQUEST_TTL_MS, MAX_CREDENTIAL_REQUEST_TTL_MS, ); const token = generateInviteToken(); const row = store.create({ id: randomUUID(), tokenHash: hashInviteToken(token), purpose: params.purpose ?? "standalone", service: params.service, field: params.field, label: params.label ?? null, secretPromptId: params.secretPromptId ?? null, policyJson: params.policyJson ?? null, expiresAt: now + ttlMs, }); // The link row is persisted, so now make ingress live: enable it if it was // explicitly disabled AND start the Velay tunnel (on platform the tunnel // otherwise only starts for Twilio / live voice, so the config bit alone // would leave the link unreachable). Ordered after store.create so a failed // insert — and every earlier guard (no resolvable URL, rate limit, too many // active) — never flips public ingress on without a usable link. The link // becomes reachable within a few seconds once the tunnel registers. await deps.ensurePublicIngressLive?.(); log.info( { requestId: row.id, service: row.service, field: row.field, purpose: row.purpose, expiresAt: row.expiresAt, }, "Credential request minted", ); // The token rides in the URL FRAGMENT: browsers never send fragments over // HTTP, so it cannot land in reverse-proxy/access logs or Referer headers. return { ok: true, token, url: `${publicBaseUrl}/assistant/credentials/enter#token=${encodeURIComponent(token)}`, expiresAt: row.expiresAt, }; }