src/domain/local-n8n-claude.js
Rama main · réplica de lectura del repositorio original.
import { validateLocalDockerHost } from "../infrastructure/local-process.js";
import { validateDockerName } from "./validation.js";
import { validateDockerObjectId } from "./local-n8n-sidecar.js";
import { validModels, MODEL_RULES } from "../claude/launcher.js";
import { reviewClaudeCompanion } from "../claude/companion.js";
export const LOCAL_N8N_CLAUDE_TARGET = "n8n-claude-oauth";
export const LOCAL_N8N_CLAUDE_ENDPOINT = "http://relmio-claude:14502/v1";
export const DEFAULT_LOCAL_CLAUDE_MODELS = Object.freeze(["claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5"]);
export const CLAUDE_REVIEW_FIELDS = Object.freeze([
"officialBinaryReviewed", "effectivePolicyReviewed", "nativeAuthAndBillingReviewed", "policyChangesControlled",
]);
export function validateClaudeReview(review) {
if (!review || typeof review !== "object" || Array.isArray(review) ||
Object.keys(review).length !== CLAUDE_REVIEW_FIELDS.length ||
CLAUDE_REVIEW_FIELDS.some((key) => review[key] !== true)) {
throw new TypeError("Confirm all four Claude review questions.");
}
return Object.fromEntries(CLAUDE_REVIEW_FIELDS.map((key) => [key, true]));
}
export function createLocalN8nClaudePlan({ dockerHost, n8nContainerId, n8nContainerName, dockerNetworkId, networkName, models = [...DEFAULT_LOCAL_CLAUDE_MODELS], arch }) {
if (!validModels(models)) throw new TypeError(MODEL_RULES);
if (!["amd64", "arm64"].includes(arch)) throw new TypeError("The local Claude architecture is unsupported.");
const { version, executableSha256 } = reviewClaudeCompanion({ network: networkName, arch, models });
return {
kind: "n8n-claude", target: LOCAL_N8N_CLAUDE_TARGET, label: "Claude for n8n",
endpoint: LOCAL_N8N_CLAUDE_ENDPOINT, baseUrl: LOCAL_N8N_CLAUDE_ENDPOINT,
protocol: "openai-chat-completions", upstreamAuth: "provider-owned-oauth",
dockerHost: validateLocalDockerHost(dockerHost),
n8nContainerId: validateDockerObjectId(n8nContainerId, "n8n container"),
n8nContainerName: validateDockerName(n8nContainerName),
dockerNetworkId: validateDockerObjectId(dockerNetworkId, "Docker network"),
networkName: validateDockerName(networkName), models: [...models], arch,
version, executableSha256,
managedPath: "~/.relmio/local/n8n-claude-oauth", hostPublication: "none", experimental: true,
};
}
export function normalizeLocalN8nClaudePlan(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("The private Claude plan is invalid.");
const normalized = createLocalN8nClaudePlan(value);
if (Object.keys(value).some((key) => !Object.hasOwn(normalized, key) && key !== "disposableHarnessWarning") ||
(Object.hasOwn(value, "disposableHarnessWarning") && typeof value.disposableHarnessWarning !== "boolean") ||
Object.entries(normalized).some(([key, expected]) => key === "models"
? JSON.stringify(value.models) !== JSON.stringify(expected) : value[key] !== expected)) {
throw new TypeError("The private Claude plan is invalid.");
}
return normalized;
}
export function createSafeLocalN8nClaudePlan(plan) {
const normalized = normalizeLocalN8nClaudePlan(plan);
const { dockerHost: _dockerHost, ...safe } = normalized;
return { ...safe, disposableHarnessWarning: plan.disposableHarnessWarning === true };
}
function validExpiry(value) {
return typeof value === "string" && Number.isFinite(Date.parse(value)) &&
new Date(Date.parse(value)).toISOString() === value;
}
export function createSafeLocalN8nClaudeInstallResult(result, plan) {
if (result?.target !== LOCAL_N8N_CLAUDE_TARGET || result.endpoint !== LOCAL_N8N_CLAUDE_ENDPOINT ||
result.baseUrl !== result.endpoint || result.protocol !== "openai-chat-completions" ||
result.networkName !== plan.networkName || result.n8nContainerName !== plan.n8nContainerName ||
result.hostPublication !== "none" || !/^[A-Za-z0-9_-]{32,256}$/u.test(result.clientKey ?? "") ||
result.credentialShownOnce !== true || result.deploymentMode !== "installed" ||
!validExpiry(result.expiresAt) || JSON.stringify(result.models) !== JSON.stringify(plan.models)) {
throw new TypeError("The local Claude install result is invalid.");
}
return {
target: LOCAL_N8N_CLAUDE_TARGET, endpoint: LOCAL_N8N_CLAUDE_ENDPOINT,
baseUrl: LOCAL_N8N_CLAUDE_ENDPOINT, protocol: "openai-chat-completions",
networkName: validateDockerName(result.networkName), n8nContainerName: validateDockerName(result.n8nContainerName),
hostPublication: "none", clientCredential: result.clientKey, credentialShownOnce: true,
deploymentMode: "installed", models: [...result.models], expiresAt: result.expiresAt,
};
}
// What Docker reports for the companion container. Only "running" (started, not restarting, health check passing) counts as healthy.
const RUNTIMES = Object.freeze(["running", "starting", "restarting", "unhealthy", "stopped"]);
export function createSafeLocalN8nClaudeSnapshot(snapshot) {
if (snapshot?.target !== LOCAL_N8N_CLAUDE_TARGET || snapshot.endpoint !== LOCAL_N8N_CLAUDE_ENDPOINT ||
snapshot.auth?.configured !== true || snapshot.auth.disclosure !== "one-time" ||
snapshot.canRemove !== true || !validModels(snapshot.models) || !validExpiry(snapshot.expiresAt) ||
typeof snapshot.approvalExpired !== "boolean" ||
!RUNTIMES.includes(snapshot.runtime)) throw new TypeError("The local Claude snapshot is invalid.");
return {
target: LOCAL_N8N_CLAUDE_TARGET, endpoint: LOCAL_N8N_CLAUDE_ENDPOINT,
auth: { configured: true, disclosure: "one-time" }, canRemove: true,
models: [...snapshot.models], expiresAt: snapshot.expiresAt, approvalExpired: snapshot.approvalExpired,
runtime: snapshot.runtime,
};
}
export function createSafeLocalN8nClaudeStatus(result) {
if (result?.target !== LOCAL_N8N_CLAUDE_TARGET || typeof result.managed !== "boolean" ||
!["absent", "healthy", "partial", "unavailable"].includes(result.state) ||
result.managed !== ["healthy", "partial"].includes(result.state)) throw new TypeError("The local Claude status is invalid.");
const snapshot = result.managed ? createSafeLocalN8nClaudeSnapshot(result.snapshot) : null;
if (result.state === "healthy" && snapshot.runtime !== "running") throw new TypeError("The local Claude status is invalid.");
return { target: LOCAL_N8N_CLAUDE_TARGET, managed: result.managed, state: result.state, snapshot };
}
export function createSafeLocalN8nClaudeChangeResult(result, action) {
if (result?.target !== LOCAL_N8N_CLAUDE_TARGET ||
(action === "remove" ? result.removed !== true || typeof result.loginKept !== "boolean" : result.changed !== true) ||
(action === "renew" && !validExpiry(result.expiresAt))) throw new TypeError("The local Claude action result is invalid.");
return { target: LOCAL_N8N_CLAUDE_TARGET,
...(action === "remove" ? { removed: true, loginKept: result.loginKept } : { changed: true }),
...(action === "renew" ? { expiresAt: result.expiresAt } : {}) };
}