Ctrl K
ComunidadGuías…

src/domain/hosting-edge-profiles.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createSearchImageFiles, searchEnvironment } from "./hosting-deployment-common.js";

const WORKER_DATE = "2026-09-27";
const WRANGLER_VERSION = "4.141.0";
const CONTAINERS_VERSION = "0.3.7";

const endpointFields = [{
  name: "upstreamOrigin",
  label: "Existing authenticated model HTTPS origin",
  type: "text",
  required: true,
  pattern: "https://[A-Za-z0-9.\\-]+",
  description: "Origin only, without a path, query, credentials, IP literal, or port. The gateway always calls /v1/chat/completions.",
}];

function file(name, content, mediaType = "text/plain") {
  return { name, content, mediaType };
}

function validateUpstreamOrigin(input) {
  let url;
  try {
    url = new URL(input);
  } catch {
    throw new Error("An HTTPS model origin is required.");
  }
  const hostname = url.hostname.toLowerCase();
  const labels = hostname.split(".");
  if (url.protocol !== "https:" || input !== url.origin || url.port ||
      !/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(hostname) ||
      labels.every((part) => /^[0-9]+$/.test(part)) ||
      labels.some((part) => part.startsWith("-") || part.endsWith("-") || part.length > 63) ||
      /\.(?:local|localhost|internal|test|invalid|example)$/.test(hostname) ||
      hostname === "localhost" || hostname.endsWith(".localhost") ||
      hostname.length > 253) {
    throw new Error("Provide a public DNS HTTPS origin without a port, path, or credentials.");
  }
  return url.origin;
}

// This module is copied into each generated deployment; the HTTPS upstream is fixed at plan time.
// Only the caller credential is accepted at ingress. The upstream bearer is read from the
// server-side runtime secret store and sent to that upstream, not embedded in generated
// artifacts or deliberately included in responses or logs.
function proxySource(origin) {
  return `const UPSTREAM = ${JSON.stringify(origin + "/v1/chat/completions")};
const MAX_BODY = 262144;
const encoder = new TextEncoder();

function reply(status) {
  return new Response(null, { status, headers: { "cache-control": "no-store" } });
}

async function sameSecret(provided, expected) {
  const [a, b] = await Promise.all([
    crypto.subtle.digest("SHA-256", encoder.encode(provided)),
    crypto.subtle.digest("SHA-256", encoder.encode(expected)),
  ]);
  const left = new Uint8Array(a);
  const right = new Uint8Array(b);
  let difference = 0;
  for (let i = 0; i < left.length; i++) difference |= left[i] ^ right[i];
  return difference === 0;
}

async function readBoundedBody(request) {
  const length = request.headers.get("content-length");
  if (length !== null && (!/^[0-9]+$/.test(length) || Number(length) > MAX_BODY)) return null;
  if (!request.body) return null;
  const reader = request.body.getReader();
  const chunks = [];
  let total = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      total += value.byteLength;
      if (total > MAX_BODY) {
        await reader.cancel();
        return null;
      }
      chunks.push(value);
    }
  } catch {
    return null;
  } finally {
    reader.releaseLock();
  }
  const body = new Uint8Array(total);
  let offset = 0;
  for (const chunk of chunks) {
    body.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return body;
}

export async function handleChatRequest(request, secrets, fetchUpstream = fetch) {
  if (typeof secrets?.incoming !== "string" || !secrets.incoming ||
      typeof secrets.upstream !== "string" || !secrets.upstream) return reply(503);
  const authorization = request.headers.get("authorization");
  if (!authorization?.startsWith("Bearer ") || authorization.length > 1024 ||
      !(await sameSecret(authorization.slice(7), secrets.incoming))) return reply(401);
  const url = new URL(request.url);
  if (request.method !== "POST" || url.pathname !== "/api/chat" || url.search || url.hash) return reply(404);
  if (!/^application\\/json(?:\\s*;|$)/i.test(request.headers.get("content-type") ?? "") ||
      ![null, "identity"].includes(request.headers.get("content-encoding"))) return reply(415);
  const body = await readBoundedBody(request);
  if (!body) return reply(413);
  try {
    const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
    if (!parsed || Array.isArray(parsed) || typeof parsed !== "object" ||
        typeof parsed.model !== "string" || !parsed.model || !Array.isArray(parsed.messages)) return reply(400);
  } catch {
    return reply(400);
  }
  try {
    const upstream = await fetchUpstream(UPSTREAM, {
      method: "POST",
      headers: {
        "authorization": \`Bearer \${secrets.upstream}\`,
        "content-type": "application/json",
        "accept": "application/json, text/event-stream",
      },
      body,
      redirect: "manual",
      signal: request.signal,
    });
    if (upstream.status < 200 || upstream.status >= 300) {
      await upstream.body?.cancel();
      return reply(502);
    }
    const contentType = upstream.headers.get("content-type") ?? "";
    const safeType = /^application\\/json(?:\\s*;|$)/i.test(contentType) ? "application/json" :
      /^text\\/event-stream(?:\\s*;|$)/i.test(contentType) ? "text/event-stream" : null;
    if (!safeType || !upstream.body) {
      await upstream.body?.cancel();
      return reply(502);
    }
    return new Response(upstream.body, {
      status: upstream.status,
      headers: { "content-type": safeType, "cache-control": "no-store", "x-content-type-options": "nosniff" },
    });
  } catch {
    return reply(502);
  }
}
`;
}

function endpointPlan({ providerId, resourceName, inputs }) {
  const origin = validateUpstreamOrigin(inputs.upstreamOrigin);
  const common = file("proxy.mjs", proxySource(origin), "text/javascript");
  const secrets = ["RELMIO_INCOMING_TOKEN", "RELMIO_UPSTREAM_TOKEN"];
  const commonSteps = [
    "Review the selected existing model service and its exact HTTPS origin; it must implement POST /v1/chat/completions and require its own bearer credential. This gateway never hosts a model.",
    "Generate a strong independent caller token outside Relmio for RELMIO_INCOMING_TOKEN, and obtain an already issued valid API credential for the selected upstream as RELMIO_UPSTREAM_TOKEN (or configure an owned upstream to accept a separate token). Store each only in its respective runtime secret/credential store; never place values in source, build variables, URLs, or downloads.",
    "Deploy only this new endpoint project; review naming/collisions and preserve any existing n8n deployment. Confirm the endpoint URL and test a fresh authenticated Chat Completions request; a deployment/health response is not inference proof.",
    "In n8n, use an HTTP Request POST to the actual deployed HTTPS /api/chat with an Authorization: Bearer credential kept in n8n, Content-Type: application/json, and JSON Chat Completions body (model and messages). For streaming clients this endpoint forwards successful SSE; do not enable Responses mode.",
  ];
  let files;
  let providerStep;
  if (providerId === "vercel") {
    files = [common, file("api/chat.mjs", `import { handleChatRequest } from "../proxy.mjs";\n\nexport default {\n  fetch(request) {\n    return handleChatRequest(request, { incoming: process.env.RELMIO_INCOMING_TOKEN, upstream: process.env.RELMIO_UPSTREAM_TOKEN });\n  },\n};\n`, "text/javascript"), file("package.json", JSON.stringify({ private: true, type: "module" }, null, 2) + "\n", "application/json")];
    providerStep = "Create an isolated Vercel Node.js Functions project from these files; configure both runtime Environment Variables as sensitive values for the target environment before deployment. Use the actual deployment's /api/chat URL; preview and production secrets are separate.";
  } else if (providerId === "netlify") {
    files = [common, file("netlify/functions/chat.mjs", `import { handleChatRequest } from "../../proxy.mjs";\n\nexport default function handler(request) {\n  return handleChatRequest(request, { incoming: process.env.RELMIO_INCOMING_TOKEN, upstream: process.env.RELMIO_UPSTREAM_TOKEN });\n}\n\nexport const config = { path: "/api/chat" };\n`, "text/javascript"), file("package.json", JSON.stringify({ private: true, type: "module" }, null, 2) + "\n", "application/json")];
    providerStep = "Create an isolated Netlify Functions site from these files; configure both variables in Netlify's runtime Functions environment scope, not netlify.toml. Redeploy after setting/rotating secrets; use its actual HTTPS /api/chat URL.";
  } else {
    files = [common, file("src/index.mjs", `import { handleChatRequest } from "../proxy.mjs";\n\nexport default {\n  fetch(request, env) {\n    return handleChatRequest(request, { incoming: env.RELMIO_INCOMING_TOKEN, upstream: env.RELMIO_UPSTREAM_TOKEN });\n  },\n};\n`, "text/javascript"), file("package.json", JSON.stringify({ private: true, type: "module", devDependencies: { wrangler: WRANGLER_VERSION } }, null, 2) + "\n", "application/json"), file("wrangler.jsonc", JSON.stringify({ name: resourceName, main: "src/index.mjs", compatibility_date: WORKER_DATE, workers_dev: true, preview_urls: false, secrets: { required: secrets }, observability: { enabled: true, logs: { enabled: true, head_sampling_rate: 0.1 }, traces: { enabled: true, head_sampling_rate: 0.01 } } }, null, 2) + "\n", "application/json")];
    providerStep = "Create a new Cloudflare Worker project from these files; configure both required Worker secrets using private interactive secret input or dashboard, then review and deploy. Keep the authenticated workers.dev route enabled for n8n's off-platform HTTPS requests; never serve unprotected raw model endpoints.";
  }
  return {
    files,
    settings: [
      { name: "RELMIO_INCOMING_TOKEN", value: "Independent operator-generated caller token stored as a runtime secret; n8n stores its matching credential." },
      { name: "RELMIO_UPSTREAM_TOKEN", value: "Already issued and approved upstream API bearer credential stored as a runtime secret; not a generated OAuth/session token." },
    ],
    steps: [providerStep, ...commonSteps],
    connection: { path: "/api/chat", method: "POST", authentication: "Bearer RELMIO_INCOMING_TOKEN (n8n credential)", upstream: origin + "/v1/chat/completions", usage: "HTTP Request Chat Completions; use the actual deployed HTTPS hostname" },
    requirements: ["An existing, approved, reachable HTTPS OpenAI-compatible Chat Completions upstream with bearer authentication and model access.", "Separate server-side runtime secrets and an n8n-held caller credential."],
    limitations: ["Not a model deployment or a universal URL relay; only POST /api/chat forwards to the fixed upstream path.", "No OAuth/ChatGPT/Codex/SuperGrok session credential is generated or transferred. Confirm your upstream's permitted use and network policy.", "Proxy success is not model/tool quality proof; cloud inference, function limits, and provider costs remain operator responsibilities."],
  };
}

function n8nCloudPlan() {
  return {
    files: [],
    settings: [
      { name: "Remote model base URL", value: "Use the actual approved externally reachable HTTPS model gateway URL, not localhost or another provider's private DNS." },
      { name: "n8n credential", value: "Store the gateway's caller bearer token in n8n's credential store, never in the plan or a workflow URL." },
      { name: "Model", value: "Select the exact available model identifier returned by the remote service; do not assume Relmio's local catalog is hosted there." },
      { name: "Remote search HTTP Request", value: "If using search, connect to an independently operated authenticated HTTPS /search?q=...&format=json route; store its separate bearer credential in n8n." },
    ],
    steps: [
      "Operate an independently approved, authenticated remote model gateway before connecting n8n Cloud. For the generated Vercel/Netlify/Cloudflare Workers endpoint, its exact callable path is POST /api/chat and it implements Chat Completions only.",
      "For that endpoint, create an n8n Cloud HTTP Request node (or AI Agent HTTP Request tool): POST the gateway's actual HTTPS /api/chat, choose a stored Header Auth credential with Authorization: Bearer <caller token>, send Content-Type application/json and a Chat Completions JSON body containing the actual model and messages; read choices[0].message.content. For an upstream remote Ollama service with an authenticated Ollama API proxy (not this restricted Chat-only endpoint), use n8n's Ollama credential with actual remote Base URL and optional API Key bearer authentication.",
      "For search from n8n Cloud, create a separate HTTP Request node or AI Agent HTTP Request tool: GET the actual approved HTTPS search gateway /search with URL-encoded q and format=json, using a stored Header Auth credential for Authorization: Bearer <search token>. Require a JSON object with a results array; this is not a native Assistant search-provider setting.",
      "Run a fresh workflow and inspect the completion rather than treating a credential save or health check as proof. Never expose raw unauthenticated Ollama or model-management paths to make Cloud reach a private service.",
      "n8n Cloud's native Assistant and sandbox entitlement are n8n-managed. Self-hosted instance-ai environment settings and a private SearXNG Docker hostname are not configuration for a Cloud tenant.",
    ],
    connection: { protocol: "OpenAI-compatible Chat Completions", path: "/api/chat", method: "POST", authentication: "n8n Header Auth credential: Authorization Bearer caller token", usage: "HTTP Request node/tool from n8n Cloud to an operator-approved HTTPS gateway", search: "Separate authenticated HTTP Request GET /search?q=<encoded query>&format=json; use an independently deployed search gateway, not the Chat-only proxy" },
    requirements: ["An already deployed HTTPS endpoint reachable from n8n Cloud with a valid stored caller credential and available model."],
    limitations: ["No n8n Cloud host SSH, Docker, or native managed companion install; private provider DNS and localhost are not n8n Cloud endpoints.", "This guidance does not provision the remote service, modify n8n, or claim native Assistant support for custom HTTP auth headers."],
  };
}

function cloudflareSearchPlan({ resourceName }) {
  const files = createSearchImageFiles();
  const vars = searchEnvironment();
  const searchWorker = `import { Container } from "@cloudflare/containers";
import { env as bindings } from "cloudflare:workers";
import { handleSearchRequest } from "../search-handler.mjs";

export class SearchContainer extends Container {
  defaultPort = 8080;
  sleepAfter = "10m";
  envVars = {
    GRANIAN_HOST: ${JSON.stringify(vars.GRANIAN_HOST)},
    GRANIAN_PORT: ${JSON.stringify(vars.GRANIAN_PORT)},
    FORCE_OWNERSHIP: ${JSON.stringify(vars.FORCE_OWNERSHIP)},
    SEARXNG_SECRET: bindings.SEARXNG_SECRET,
  };
}

export default {
  fetch(request, env) {
    return handleSearchRequest(request, env, (name) => env.SEARCH_CONTAINER.getByName(name));
  },
};
`;
  const searchHandler = `const encoder = new TextEncoder();
const MAX_QUERY_BYTES = 512;
const MAX_RESULT_BYTES = 1048576;

function reply(status) {
  return new Response(null, { status, headers: { "cache-control": "no-store" } });
}

function boundedStream(body) {
  let count = 0;
  return body.pipeThrough(new TransformStream({
    transform(chunk, controller) {
      count += chunk.byteLength;
      if (count > MAX_RESULT_BYTES) throw new Error("Search response exceeds limit");
      controller.enqueue(chunk);
    },
  }));
}

// The only destination is the configured container binding; caller paths never become upstream URLs.
export async function handleSearchRequest(request, env, getContainer) {
  if (typeof env.RELMIO_SEARCH_TOKEN !== "string" || !env.RELMIO_SEARCH_TOKEN ||
      typeof env.SEARXNG_SECRET !== "string" || !env.SEARXNG_SECRET) return reply(503);
  const authorization = request.headers.get("authorization");
  if (!authorization?.startsWith("Bearer ") || authorization.length > 1024 ||
      !(await sameSecret(authorization.slice(7), env.RELMIO_SEARCH_TOKEN))) return reply(401);
  const url = new URL(request.url);
  if (request.method !== "GET" || url.pathname !== "/search" || url.hash ||
      url.search.length > 1024) return reply(404);
  const pairs = [...url.searchParams];
  if (pairs.length !== 2 || pairs.some(([key]) => key !== "q" && key !== "format") ||
      url.searchParams.getAll("q").length !== 1 || url.searchParams.getAll("format").length !== 1 ||
      url.searchParams.get("format") !== "json") return reply(400);
  const query = url.searchParams.get("q");
  if (!query?.trim() || encoder.encode(query).length > MAX_QUERY_BYTES) return reply(400);
  const target = new URL("http://localhost:8080/search");
  target.searchParams.set("q", query);
  target.searchParams.set("format", "json");
  try {
    const response = await getContainer(${JSON.stringify(resourceName)}).fetch(new Request(target, { method: "GET" }));
    const length = response.headers.get("content-length");
    if (response.status !== 200 || !response.body ||
        !/^application\\/json(?:\\s*;|$)/i.test(response.headers.get("content-type") ?? "") ||
        (length !== null && (!/^[0-9]+$/.test(length) || Number(length) > MAX_RESULT_BYTES))) {
      await response.body?.cancel();
      return reply(502);
    }
    return new Response(boundedStream(response.body), {
      status: 200,
      headers: { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" },
    });
  } catch {
    return reply(502);
  }
}


async function sameSecret(provided, expected) {
  const [a, b] = await Promise.all([
    crypto.subtle.digest("SHA-256", encoder.encode(provided)),
    crypto.subtle.digest("SHA-256", encoder.encode(expected)),
  ]);
  const left = new Uint8Array(a);
  const right = new Uint8Array(b);
  let difference = 0;
  for (let i = 0; i < left.length; i++) difference |= left[i] ^ right[i];
  return difference === 0;
}
`;
  const config = {
    name: resourceName,
    main: "src/index.mjs",
    compatibility_date: WORKER_DATE,
    workers_dev: true,
    preview_urls: false,
    containers: [{ class_name: "SearchContainer", image: "./Dockerfile", instance_type: "standard-1", max_instances: 1 }],
    durable_objects: { bindings: [{ name: "SEARCH_CONTAINER", class_name: "SearchContainer" }] },
    migrations: [{ tag: "v1", new_sqlite_classes: ["SearchContainer"] }],
    secrets: { required: ["SEARXNG_SECRET", "RELMIO_SEARCH_TOKEN"] },
    observability: { enabled: true, logs: { enabled: true, head_sampling_rate: 0.1 }, traces: { enabled: true, head_sampling_rate: 0.01 } },
  };
  files.push(file("src/index.mjs", searchWorker, "text/javascript"));
  files.push(file("search-handler.mjs", searchHandler, "text/javascript"));
  files.push(file("wrangler.jsonc", JSON.stringify(config, null, 2) + "\n", "application/json"));
  files.push(file("package.json", JSON.stringify({ private: true, type: "module", dependencies: { "@cloudflare/containers": CONTAINERS_VERSION }, devDependencies: { wrangler: WRANGLER_VERSION } }, null, 2) + "\n", "application/json"));
  return {
    files,
    settings: [
      { name: "SEARXNG_SECRET", value: "Operator-generated high-entropy Worker secret, forwarded only to the private container." },
      { name: "RELMIO_SEARCH_TOKEN", value: "Independent operator-generated high-entropy Worker secret, held as the n8n HTTP Request credential." },
    ],
    steps: [
      "Create a new isolated Cloudflare Containers Worker project from these files. Review unique resource names/collisions and the package/Wrangler configuration before deploying; Cloudflare Containers requires a compatible account and locally available image builder. Do not apply this to an existing n8n project.",
      "Configure two independent high-entropy Worker secrets (SEARXNG_SECRET for SearXNG startup, RELMIO_SEARCH_TOKEN for caller authentication) via private dashboard/interactive secret input before deployment. Never put values in wrangler.jsonc or downloaded files. Container storage/cache is ephemeral; SearXNG needs outbound search-engine access.",
      "Deploy this Worker and container image only. Its authenticated workers.dev route is deliberately reachable from off-platform n8n; without that HTTPS route a self-hosted n8n cannot call a private Cloudflare binding. The container itself has no public service port.",
      "In an n8n workflow HTTP Request node (or AI Agent HTTP Request tool), GET the actual Worker HTTPS /search with q and format=json, using a stored Header Auth credential Authorization: Bearer <search token>. Confirm a JSON results array and actual engine outcome. A native instance Assistant SearXNG URL setting cannot send this custom header, so do not point it at this route as if that integration worked.",
    ],
    connection: { path: "/search", method: "GET", query: "q=<URL-encoded search text>&format=json", authentication: "Bearer RELMIO_SEARCH_TOKEN in n8n HTTP Request credential", usage: "Workflow or AI Agent HTTP Request tool; actual deployed Worker HTTPS hostname required" },
    requirements: ["Cloudflare Containers availability and image build support, SearXNG outbound egress, two independently supplied runtime secrets, and an n8n HTTP Request credential."],
    limitations: ["Search-only private container with authenticated public Worker route; no unauthenticated search, arbitrary proxy, privileged sandbox, persistent model cache, or native Assistant custom-header integration.", "Cloudflare container disk is ephemeral; search-engine blocks/rate limits and paid resource usage require live operator verification."],
  };
}

export const EDGE_HOSTING_PROFILES = [
  { providerId: "cloudflare-containers", component: "searxng", fields: [], render: cloudflareSearchPlan },
  { providerId: "n8n-cloud", component: "endpoint", fields: [], render: n8nCloudPlan },
  ...["vercel", "netlify", "cloudflare-workers"].map((providerId) => ({
    providerId, component: "endpoint", fields: endpointFields, render: endpointPlan,
  })),
];