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 , 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 . 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=&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 . 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=&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, })), ];