import { getLocalDockerStatus, getManagedLocalEndpointStatus, } from "./local-installer.js"; import { getLocalN8nAssistantStatus } from "./local-n8n-assistant-installer.js"; import { getLocalN8nSidecarStatus } from "./local-n8n-sidecar-installer.js"; import { getLocalN8nSuperGrokStatus } from "./local-n8n-supergrok-installer.js"; import { getLocalN8nModelStatus } from "./local-n8n-model-installer.js"; import { getLocalN8nClaudeStatus } from "./local-n8n-claude-installer.js"; import { createSafeLocalN8nClaudeSnapshot } from "../domain/local-n8n-claude.js"; const SERVICE_DEFINITIONS = Object.freeze([ Object.freeze({ target: "codex-chatgpt", label: "Codex (ChatGPT plan)", kind: "endpoint", }), Object.freeze({ target: "codex-chat", label: "Codex Chat adapter", kind: "endpoint" }), Object.freeze({ target: "xai-grok-build", label: "SuperGrok", kind: "endpoint" }), Object.freeze({ target: "local-n8n-stack", label: "Local n8n stack", kind: "n8n-stack" }), Object.freeze({ target: "n8n-openai-oauth", label: "ChatGPT plan sidecar", kind: "n8n-oauth-bridge", }), Object.freeze({ target: "local-n8n-assistant", label: "AI Assistant tools", kind: "n8n-assistant", }), Object.freeze({ target: "n8n-supergrok-oauth", label: "SuperGrok for n8n", kind: "n8n-supergrok" }), Object.freeze({ target: "n8n-claude-oauth", label: "Claude for n8n", kind: "n8n-claude" }), Object.freeze({ target: "n8n-local-model", label: "Local model for n8n", kind: "n8n-local-model" }), ]); const STATES = new Set(["absent", "healthy", "stopped", "staged", "partial", "legacy", "unavailable"]); const ASSISTANT_MODES = new Set(["disabled", "sandbox", "sandbox-with-searxng"]); const PROVIDER_DEFINITIONS = Object.freeze([ Object.freeze({ target: "codex-chatgpt", label: "ChatGPT", authentication: "provider-oauth", }), Object.freeze({ target: "codex-chat", label: "ChatGPT", authentication: "provider-oauth", }), Object.freeze({ target: "xai-grok-build", label: "SuperGrok", authentication: "provider-oauth", }), Object.freeze({ target: "n8n-supergrok-oauth", label: "SuperGrok (n8n)", authentication: "provider-oauth" }), Object.freeze({ target: "n8n-claude-oauth", label: "Claude (n8n)", authentication: "provider-oauth" }), ]); function unavailableService(definition) { return { ...definition, managed: false, state: "unavailable", snapshot: null, actions: [], }; } function absentService(definition) { return { ...definition, managed: false, state: "absent", snapshot: null, actions: ["setup"], }; } function readExplicitLoopbackPort(value) { const match = /^(?:http|ws):\/\/127\.0\.0\.1:(\d{1,5})(?:\/|$)/u.exec(value); const port = Number(match?.[1]); if (!Number.isInteger(port) || port < 1 || port > 65_535) { throw new TypeError(); } return port; } function validateLoopbackEndpoint(value, { target }) { if (typeof value !== "string" || value.length > 128) throw new TypeError(); const parsed = new URL(value); if (parsed.hostname !== "127.0.0.1" || parsed.username || parsed.password) { throw new TypeError(); } const expectedProtocol = target === "codex-chatgpt" ? "ws:" : "http:"; const expectedPath = "/"; const port = readExplicitLoopbackPort(value); if ( parsed.protocol !== expectedProtocol || parsed.pathname !== expectedPath || parsed.search || parsed.hash || !Number.isInteger(port) || port < 1 || port > 65_535 ) { throw new TypeError(); } return value; } function validatePublicNgrokUrl(value) { if (typeof value !== "string" || value.length > 256) throw new TypeError(); const parsed = new URL(value); if ( parsed.protocol !== "https:" || parsed.port || parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.username || parsed.password || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(parsed.hostname) ) { throw new TypeError(); } return value; } function copyBooleanRecord(value, keys) { if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(); return Object.fromEntries(keys.map((key) => { if (typeof value[key] !== "boolean") throw new TypeError(); return [key, value[key]]; })); } const SIWC_SESSIONS = new Set(["signed-out", "connected", "reauthorize"]); const SIWC_OWNERSHIP = new Set(["owned", "handoff-pending", "transferred"]); export function copySiwcAccountView(account) { if (!account || typeof account !== "object" || Array.isArray(account) || typeof account.registrationId !== "string" || !/^[A-Za-z0-9_-]{8,128}$/u.test(account.registrationId) || typeof account.label !== "string" || account.label.length > 160 || /[\u0000-\u001f\u007f]/u.test(account.label) || (account.email !== undefined && (typeof account.email !== "string" || account.email.length > 254 || /[\u0000-\u001f\u007f]/u.test(account.email))) || account.identity !== "verified" || !SIWC_SESSIONS.has(account.session) || !["granted", "not-granted"].includes(account.planPermission) || typeof account.planEnabled !== "boolean" || !SIWC_OWNERSHIP.has(account.ownership) || typeof account.generation !== "string" || !/^[A-Za-z0-9_-]{8,128}$/u.test(account.generation) || typeof account.needsPlanWelcome !== "boolean" || typeof account.ownerHostId !== "string" || !/^urn:uuid:[a-f0-9-]{36}$/iu.test(account.ownerHostId) || typeof account.ownerRuntimeId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(account.ownerRuntimeId)) throw new TypeError(); return { registrationId: account.registrationId, label: account.label, ...(account.email !== undefined ? { email: account.email } : {}), identity: "verified", session: account.session, planPermission: account.planPermission, planEnabled: account.planEnabled, ownership: account.ownership, generation: account.generation, ownerHostId: account.ownerHostId, ownerRuntimeId: account.ownerRuntimeId, needsPlanWelcome: account.needsPlanWelcome, }; } export function copySiwcStaging(value) { if (!value || !/^[A-Za-z0-9_-]{8,128}$/u.test(value.installId ?? "") || !/^[A-Za-z0-9_-]{8,128}$/u.test(value.registrationId ?? "") || !/^[a-z][a-z0-9-]{0,63}$/u.test(value.stage ?? "")) throw new TypeError("The staged installation is invalid."); return { installId: value.installId, registrationId: value.registrationId, stage: value.stage }; } function copyInstalledSiwc(snapshot) { if (typeof snapshot.migrationRequired !== "boolean" || typeof snapshot.auth?.configured !== "boolean") throw new TypeError(); if (snapshot.migrationState !== undefined && (snapshot.migrationRequired !== true || !["incomplete", "prepared", "stopped"].includes(snapshot.migrationState) || snapshot.legacyResourcesPreserved !== true)) throw new TypeError(); const migration = snapshot.migrationState ? { migrationState: snapshot.migrationState, legacyResourcesPreserved: true } : {}; if (snapshot.migrationRequired) { if (snapshot.registrationId !== undefined || snapshot.auth.account !== undefined || snapshot.auth.configured) throw new TypeError(); return { migrationRequired: true, ...migration, auth: { configured: false } }; } if (!/^[A-Za-z0-9_-]{8,128}$/u.test(snapshot.registrationId ?? "")) throw new TypeError(); const account = snapshot.auth.account === undefined ? null : copySiwcAccountView(snapshot.auth.account); if (account && account.registrationId !== snapshot.registrationId) throw new TypeError(); const active = account?.ownership === "owned" && account.session === "connected" && account.planPermission === "granted" && account.planEnabled === true; if (snapshot.auth.configured !== active) throw new TypeError(); return { registrationId: snapshot.registrationId, migrationRequired: false, ...migration, auth: { configured: active, ...(account ? { account } : {}) }, }; } function copyEndpointSnapshot(target, snapshot) { if (!snapshot || snapshot.target !== target || snapshot.auth?.disclosure !== "rotate-only" || snapshot.canRotateCredential !== (snapshot.migrationRequired !== true)) throw new TypeError(); const chatgpt = target === "codex-chatgpt" || target === "codex-chat"; const siwc = chatgpt ? copyInstalledSiwc(snapshot) : null; if (!chatgpt && snapshot.auth.configured !== true) throw new TypeError(); return { target, endpoint: validateLoopbackEndpoint(snapshot.endpoint, { target }), auth: { configured: chatgpt ? siwc.auth.configured : true, disclosure: "rotate-only", ...(siwc?.auth.account ? { account: siwc.auth.account } : {}) }, ...(siwc ? { registrationId: siwc.registrationId, migrationRequired: siwc.migrationRequired, ...(siwc.migrationState ? { migrationState: siwc.migrationState, legacyResourcesPreserved: true } : {}) } : {}), canRotateCredential: snapshot.canRotateCredential, }; } function copyStackSnapshot(snapshot) { if ( snapshot?.target !== "local-n8n-stack" || !["none", "ngrok"].includes(snapshot.publicAccess) || !ASSISTANT_MODES.has(snapshot.assistantMode) || typeof snapshot.canResume !== "boolean" || snapshot.canRemove !== true || !/^relmio-local-n8n-[a-f0-9]{32}-n8n-1$/u.test(snapshot.n8nContainerName) || snapshot.networkName !== `${snapshot.n8nContainerName.slice(0, -6)}_edge` || !/^http:\/\/localhost:(?:[1-9][0-9]{0,4})$/u.test(snapshot.localUrl) || snapshot.endpoints?.n8nLocal !== snapshot.localUrl || snapshot.endpoints?.ngrokPublic !== snapshot.ngrokPublicUrl || snapshot.components?.ngrok !== (snapshot.publicAccess === "ngrok") || (snapshot.publicAccess === "none" && snapshot.ngrokPublicUrl !== null) ) throw new TypeError(); const publicUrl = snapshot.publicAccess === "ngrok" ? validatePublicNgrokUrl(snapshot.ngrokPublicUrl) : null; return { target: "local-n8n-stack", publicAccess: snapshot.publicAccess, localUrl: snapshot.localUrl, ngrokPublicUrl: publicUrl, n8nContainerName: snapshot.n8nContainerName, networkName: snapshot.networkName, assistantMode: snapshot.assistantMode, endpoints: { n8nLocal: snapshot.localUrl, ngrokPublic: publicUrl, }, components: copyBooleanRecord(snapshot.components, [ "n8n", "ngrok", "codeSandbox", "searxng", ]), canResume: snapshot.canResume, canRemove: true, }; } function copySidecarSnapshot(snapshot) { if (snapshot?.target !== "n8n-openai-oauth" || snapshot.endpoint !== "http://n8n-openai-oauth:10531/v1" || snapshot.auth?.disclosure !== "server-managed" || snapshot.canRefreshCredential !== false || snapshot.canRemove !== true) throw new TypeError(); const siwc = copyInstalledSiwc(snapshot); return { target: "n8n-openai-oauth", endpoint: snapshot.endpoint, auth: { configured: siwc.auth.configured, disclosure: "server-managed", ...(siwc.auth.account ? { account: siwc.auth.account } : {}) }, registrationId: siwc.registrationId, migrationRequired: siwc.migrationRequired, ...(siwc.migrationState ? { migrationState: siwc.migrationState, legacyResourcesPreserved: true } : {}), canRefreshCredential: false, canRemove: true, }; } function copySuperGrokSnapshot(snapshot) { if ( snapshot?.target !== "n8n-supergrok-oauth" || snapshot.endpoint !== "http://n8n-supergrok:14502/v1" || snapshot.auth?.configured !== true || snapshot.auth?.disclosure !== "one-time" || snapshot.canRemove !== true ) throw new TypeError(); return { target: "n8n-supergrok-oauth", endpoint: "http://n8n-supergrok:14502/v1", auth: { configured: true, disclosure: "one-time" }, canRemove: true, }; } const MODEL_STATES = new Set(["missing", "downloading", "ready", "failed", "partial"]); const MODEL_IDS = new Set(["qwen3:0.6b", "qwen3:1.7b", "qwen3.5:2b", "qwen3.5:4b", "qwen3.5:9b"]); function copyModelSnapshot(snapshot) { if ( snapshot?.target !== "n8n-local-model" || snapshot.endpoint !== "http://n8n-local-model:11434/v1" || !MODEL_STATES.has(snapshot.model?.state) || !MODEL_IDS.has(snapshot.model?.id) || typeof snapshot.canRetry !== "boolean" || snapshot.canRetry !== ["missing", "failed"].includes(snapshot.model.state) || snapshot.canRemove !== (snapshot.model.state !== "downloading") || (snapshot.model.digest !== null && !/^[a-f0-9]{64}$/u.test(snapshot.model.digest)) || (snapshot.model.state === "ready" && snapshot.model.digest === null) ) throw new TypeError(); return { target: "n8n-local-model", endpoint: snapshot.endpoint, model: { state: snapshot.model.state, id: snapshot.model.id, digest: snapshot.model.digest, }, canRetry: snapshot.canRetry, canRemove: snapshot.canRemove, }; } function copyAssistantSnapshot(snapshot) { if ( snapshot?.target !== "local-n8n-assistant" || snapshot.auth?.sandboxConfigured !== true || snapshot.auth?.disclosure !== "one-time" || snapshot.canRemove !== true ) { throw new TypeError(); } return { target: "local-n8n-assistant", components: copyBooleanRecord(snapshot.components, ["codeSandbox", "searxng"]), auth: { sandboxConfigured: true, disclosure: "one-time" }, canRemove: true, }; } function copySnapshot(definition, snapshot) { if (definition.kind === "endpoint") return copyEndpointSnapshot(definition.target, snapshot); if (definition.kind === "n8n-stack") return copyStackSnapshot(snapshot); if (definition.kind === "n8n-oauth-bridge") return copySidecarSnapshot(snapshot); if (definition.kind === "n8n-supergrok") return copySuperGrokSnapshot(snapshot); if (definition.kind === "n8n-claude") return createSafeLocalN8nClaudeSnapshot(snapshot); if (definition.kind === "n8n-local-model") return copyModelSnapshot(snapshot); return copyAssistantSnapshot(snapshot); } function actionsFor(definition, state, snapshot) { const actions = []; if (state === "legacy") return snapshot?.migrationRequired === true ? ["setup"] : []; if (state === "partial" && snapshot?.migrationState) return []; if (state === "stopped" && snapshot?.registrationId && (["codex-chatgpt", "codex-chat"].includes(definition.target) || definition.kind === "n8n-oauth-bridge")) actions.push("inspect-stopped-chatgpt"); if (state === "stopped" && snapshot?.canResume === true) { actions.push("resume"); } if ( state === "healthy" && definition.kind === "endpoint" && snapshot?.canRotateCredential === true ) { if (["codex-chatgpt", "codex-chat"].includes(definition.target)) { actions.push("setup"); if (snapshot.auth.account?.ownership === "owned") { actions.push("sign-out-chatgpt"); if (snapshot.auth.account.planEnabled) actions.push("disable-chatgpt-plan"); } } else if (definition.target === "xai-grok-build") { actions.push("sign-in-grok-build", "sign-out-grok-build"); } actions.push("rotate-local-capability"); } if (definition.kind === "n8n-oauth-bridge" && state === "healthy") { actions.push("setup"); if (snapshot.auth.account?.ownership === "owned") { actions.push("sign-out-chatgpt"); if (snapshot.auth.account.planEnabled) actions.push("disable-chatgpt-plan"); } } if (definition.kind === "n8n-local-model" && snapshot?.canRetry === true) { actions.push("retry-model"); } if (definition.kind === "n8n-claude") actions.push("manage-claude"); if (definition.kind === "n8n-supergrok") { if (state === "healthy") actions.push("sign-in-grok-build", "sign-out-grok-build"); if (snapshot?.canRemove === true) actions.push("remove-owned-supergrok"); } else if (snapshot?.canRemove === true) actions.push("remove"); return actions; } function providerRuntimeEntry(definition) { return { ...definition, readiness: "runtime-owned", }; } function getProviderReadiness() { return PROVIDER_DEFINITIONS.map(providerRuntimeEntry); } function sanitizeService(definition, result) { if (!result || !STATES.has(result.state)) throw new TypeError(); if (result.state === "absent") { return result.managed === false && result.snapshot == null ? absentService(definition) : unavailableService(definition); } if (result.state === "unavailable" || result.managed !== true) { return unavailableService(definition); } if (result.state === "staged") { if (!["codex-chatgpt", "codex-chat", "n8n-openai-oauth"].includes(definition.target)) throw new TypeError(); return { ...definition, managed: true, state: "staged", snapshot: null, staging: copySiwcStaging(result.staging), actions: ["setup"] }; } if (result.state === "partial" && result.snapshot == null) { return { ...definition, managed: true, state: "partial", snapshot: null, actions: [], }; } const snapshot = copySnapshot(definition, result.snapshot); return { ...definition, managed: true, state: result.state, snapshot, actions: actionsFor(definition, result.state, snapshot), }; } function sanitizeDocker(result) { if (result?.dockerAvailable !== true) { return { available: false, version: null, composeVersion: null }; } const version = /^[A-Za-z0-9.+-]{1,64}$/u.test(result.dockerVersion ?? "") ? result.dockerVersion : null; const composeVersion = /^[A-Za-z0-9.+-]{1,64}$/u.test(result.composeVersion ?? "") ? result.composeVersion : null; if (!version || !composeVersion) { return { available: false, version: null, composeVersion: null }; } return { available: true, version, composeVersion }; } function dashboardModelStatus(result) { if (result?.status === "absent") return { managed: false, state: "absent", snapshot: null }; if (result?.status === "unavailable") return { managed: false, state: "unavailable", snapshot: null }; if (result?.status === "partial") { if (result.managed !== true || !MODEL_IDS.has(result.modelId) || result.endpoint !== "http://n8n-local-model:11434/v1") throw new TypeError(); return { managed: true, state: "partial", snapshot: { target: "n8n-local-model", endpoint: result.endpoint, model: { state: "partial", id: result.modelId, digest: null }, canRetry: false, canRemove: true, }, }; } const modelStates = { "runtime-ready": "missing", downloading: "downloading", "model-ready": "ready", "model-error": "failed", }; const modelState = modelStates[result?.status]; if ( result?.managed !== true || !modelState || !MODEL_IDS.has(result.modelId) || result.endpoint !== "http://n8n-local-model:11434/v1" ) throw new TypeError(); return { managed: true, state: "healthy", snapshot: { target: "n8n-local-model", endpoint: result.endpoint, model: { state: modelState, id: result.modelId, digest: result.modelDigest ?? null }, canRetry: ["missing", "failed"].includes(modelState), canRemove: modelState !== "downloading", }, }; } async function inspectService(definition, inspectors) { try { let result; if (definition.kind === "endpoint") { result = await inspectors.inspectLocalEndpoint({ target: definition.target }); } else if (definition.kind === "n8n-stack") { result = await inspectors.inspectLocalN8nStack(); } else if (definition.kind === "n8n-oauth-bridge") { result = await inspectors.inspectLocalN8nSidecar(); } else if (definition.kind === "n8n-supergrok") { result = await inspectors.inspectLocalN8nSuperGrok(); } else if (definition.kind === "n8n-claude") { result = await inspectors.inspectLocalN8nClaude(); } else if (definition.kind === "n8n-local-model") { result = dashboardModelStatus(await inspectors.inspectLocalN8nModel()); } else { result = await inspectors.inspectLocalN8nAssistant(); } return sanitizeService(definition, result); } catch { return unavailableService(definition); } } const unavailableInspector = async () => ({ managed: false, state: "unavailable" }); export async function getLocalDashboardStatus({ now = () => new Date(), getDockerStatus = getLocalDockerStatus, inspectLocalEndpoint = getManagedLocalEndpointStatus, inspectLocalN8nStack = unavailableInspector, inspectLocalN8nSidecar = getLocalN8nSidecarStatus, inspectLocalN8nAssistant = getLocalN8nAssistantStatus, inspectLocalN8nSuperGrok = getLocalN8nSuperGrokStatus, inspectLocalN8nClaude = getLocalN8nClaudeStatus, inspectLocalN8nModel = getLocalN8nModelStatus, } = {}) { let generatedAt; try { generatedAt = now().toISOString(); } catch { generatedAt = new Date(0).toISOString(); } let docker; try { docker = sanitizeDocker(await getDockerStatus()); } catch { docker = { available: false, version: null, composeVersion: null }; } const inspectors = { inspectLocalEndpoint, inspectLocalN8nStack, inspectLocalN8nSidecar, inspectLocalN8nAssistant, inspectLocalN8nSuperGrok, inspectLocalN8nClaude, inspectLocalN8nModel, }; const [services, providers] = await Promise.all([ Promise.all( SERVICE_DEFINITIONS.map((definition) => inspectService(definition, inspectors)), ), getProviderReadiness(), ]); return { schemaVersion: 1, generatedAt, docker, auth: { secretsRevealable: false }, services, providers, }; }