Ctrl K
ComunidadGuías…

src/domain/local-n8n-stack.js

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

Archivo original ↗Preguntar a KiBot
import { validateLocalDockerHost } from "../infrastructure/local-process.js";
import { isIP } from "node:net";
import { validateHostname, validatePort } from "./validation.js";

export const LOCAL_N8N_STACK_TARGET = "local-n8n-stack";
export const LOCAL_N8N_STACK_PUBLIC_CONFIRMATION =
  "EXPOSE_LOCAL_N8N_VIA_NGROK";
export const LOCAL_N8N_STACK_REMOVE_CONFIRMATION =
  "REMOVE_LOCAL_N8N_STACK";
export const LOCAL_N8N_STACK_MANAGED_PATH = "~/.relmio/local/n8n-stack";

export const LOCAL_N8N_ASSISTANT_MODES = Object.freeze([
  "disabled",
  "sandbox",
  "sandbox-with-searxng",
]);

const RESERVED_PORT = 10_531;
const RESERVED_HOST_SUFFIXES = new Set([
  "example",
  "internal",
  "invalid",
  "lan",
  "local",
  "localhost",
  "test",
]);
const INSTALL_ID_PATTERN = /^[a-f0-9]{32}$/u;
const PROJECT_NAME_PATTERN = /^relmio-local-n8n-[a-f0-9]{32}$/u;

function hasExactKeys(value, keys) {
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
  const actual = Object.keys(value).sort();
  const expected = [...keys].sort();
  return actual.length === expected.length &&
    actual.every((key, index) => key === expected[index]);
}

export function validateNgrokHostname(value) {
  const hostname = validateHostname(value);
  if (
    hostname.includes(":") ||
    isIP(hostname) !== 0 ||
    hostname.split(".").length < 2 ||
    RESERVED_HOST_SUFFIXES.has(hostname.split(".").at(-1))
  ) {
    throw new TypeError("The reserved ngrok hostname is invalid.");
  }
  return hostname;
}

export function validateAssistantMode(value) {
  if (typeof value !== "string" || !LOCAL_N8N_ASSISTANT_MODES.includes(value)) {
    throw new TypeError(
      "Assistant mode must be disabled, sandbox, or sandbox-with-searxng.",
    );
  }
  return value;
}

export function validateTimezone(value) {
  if (typeof value !== "string" || value.length < 1 || value.length > 128) {
    throw new TypeError("Timezone is invalid.");
  }
  try {
    const timezone = new Intl.DateTimeFormat("en-US", { timeZone: value })
      .resolvedOptions().timeZone;
    if (timezone !== value) throw new Error("non-canonical");
  } catch {
    throw new TypeError("Timezone is invalid.");
  }
  return value;
}

export function createLocalN8nStackPlan({
  dockerHost,
  publicAccess,
  ngrokHostname,
  n8nPort,
  timezone,
  assistantMode,
}) {
  if (!["none", "ngrok"].includes(publicAccess)) {
    throw new TypeError("Select private access or ngrok public access.");
  }
  const localPort = validatePort(n8nPort);
  if (localPort === RESERVED_PORT) throw new TypeError("Local n8n cannot use port 10531.");
  if (publicAccess === "none" && ngrokHostname != null) {
    throw new TypeError("Private n8n cannot accept ngrok settings.");
  }
  const hostname = publicAccess === "ngrok" ? validateNgrokHostname(ngrokHostname) : null;
  const mode = validateAssistantMode(assistantMode);
  return Object.freeze({
    kind: "local-n8n-stack",
    target: LOCAL_N8N_STACK_TARGET,
    label: publicAccess === "ngrok" ? "Disposable self-hosted n8n + ngrok" : "Local n8n stack",
    dockerHost: validateLocalDockerHost(dockerHost),
    publicAccess,
    ngrokHostname: hostname,
    n8nPort: localPort,
    timezone: validateTimezone(timezone),
    assistantMode: mode,
    localUrl: `http://localhost:${localPort}`,
    ngrokPublicUrl: hostname === null ? null : `https://${hostname}`,
    n8nContainerName: null,
    networkName: null,
    hostPublication: "loopback-only",
    deploymentMode: "new-disposable-stack",
    managedPath: LOCAL_N8N_STACK_MANAGED_PATH,
  });
}

export function normalizeLocalN8nStackPlan(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new TypeError("The local n8n stack plan is invalid.");
  }
  const normalized = createLocalN8nStackPlan(value);
  if (!hasExactKeys(value, Object.keys(normalized))) {
    throw new TypeError("The local n8n stack plan is invalid.");
  }
  for (const [name, expected] of Object.entries(normalized)) {
    if (value[name] !== expected) {
      throw new TypeError("The local n8n stack plan is invalid.");
    }
  }
  return normalized;
}

function createInstallId(randomBytes) {
  const value = randomBytes(16);
  if (!Buffer.isBuffer(value) || value.length !== 16) {
    throw new TypeError("A cryptographic local n8n installation generator is required.");
  }
  return value.toString("hex");
}

export function validateLocalN8nStackMarker(value) {
  const version = value?.schemaVersion;
  const keys = [
    "schemaVersion", "kind", "target", "installId", "projectName",
    "dockerHost", "ngrokHostname", "n8nPort", "timezone", "assistantMode",
    ...([2, 3].includes(version) || (version === 1 && Object.hasOwn(value, "publicAccess")) ? ["publicAccess"] : []),
    // Relmio 0.24.0 and earlier stored the retired ngrok inspector port. It is accepted and ignored.
    ...([1, 2].includes(version) && Object.hasOwn(value, "ngrokInspectorPort") ? ["ngrokInspectorPort"] : []),
  ];
  if (
    !hasExactKeys(value, keys) ||
    ![1, 2, 3].includes(version) ||
    ((version !== 1 && !["none", "ngrok"].includes(value.publicAccess)) || (version === 1 && Object.hasOwn(value, "publicAccess") && value.publicAccess !== "ngrok")) ||
    value.kind !== "relmio-local-n8n-stack" ||
    value.target !== LOCAL_N8N_STACK_TARGET ||
    typeof value.installId !== "string" ||
    !INSTALL_ID_PATTERN.test(value.installId) ||
    typeof value.projectName !== "string" ||
    !PROJECT_NAME_PATTERN.test(value.projectName) ||
    value.projectName.slice(-32) !== value.installId
  ) {
    throw new TypeError("The local n8n stack ownership marker is invalid.");
  }
  const plan = createLocalN8nStackPlan({
    dockerHost: value.dockerHost,
    publicAccess: version === 1 ? "ngrok" : value.publicAccess,
    ngrokHostname: value.ngrokHostname,
    n8nPort: value.n8nPort,
    timezone: value.timezone,
    assistantMode: value.assistantMode,
  });
  return Object.freeze({
    schemaVersion: version,
    kind: "relmio-local-n8n-stack",
    target: LOCAL_N8N_STACK_TARGET,
    installId: value.installId,
    projectName: value.projectName,
    dockerHost: plan.dockerHost,
    publicAccess: plan.publicAccess,
    ngrokHostname: plan.ngrokHostname,
    n8nPort: plan.n8nPort,
    timezone: plan.timezone,
    assistantMode: plan.assistantMode,
  });
}

export function createLocalN8nStackInstallation({ plan, randomBytes }) {
  const safePlan = normalizeLocalN8nStackPlan(plan);
  if (typeof randomBytes !== "function") {
    throw new TypeError("A cryptographic local n8n installation generator is required.");
  }
  const installId = createInstallId(randomBytes);
  const marker = validateLocalN8nStackMarker({
    schemaVersion: 3,
    kind: "relmio-local-n8n-stack",
    target: LOCAL_N8N_STACK_TARGET,
    installId,
    projectName: `relmio-local-n8n-${installId}`,
    dockerHost: safePlan.dockerHost,
    publicAccess: safePlan.publicAccess,
    ngrokHostname: safePlan.ngrokHostname,
    n8nPort: safePlan.n8nPort,
    timezone: safePlan.timezone,
    assistantMode: safePlan.assistantMode,
  });
  return Object.freeze({ ...marker, marker });
}

export function getLocalN8nStackLabels(installation) {
  const marker = validateLocalN8nStackMarker(installation?.marker ?? installation);
  return Object.freeze({
    "io.relmio.managed": "true",
    "io.relmio.target": LOCAL_N8N_STACK_TARGET,
    "io.relmio.install": marker.installId,
    "io.relmio.project": marker.projectName,
  });
}

export function getLocalN8nStackServiceNames(installation) {
  const marker = validateLocalN8nStackMarker(installation?.marker ?? installation);
  return Object.freeze([
    "n8n",
    ...(marker.publicAccess === "ngrok" ? ["ngrok"] : []),
    ...(marker.assistantMode === "disabled"
      ? []
      : ["relmio-sandbox-certs", "relmio-sandbox-api", "relmio-sandbox-runner-1"]),
    ...(marker.assistantMode === "sandbox-with-searxng" ? ["relmio-searxng"] : []),
  ]);
}