feat(FN-3506): add discovery contract and integrate remote node discovery i

Merges node discovery onboarding (FN-3506) with two steps: a pre-registration discovery contract wired into the AddNodeModal UI, and full remote discovery integration with documentation. Also adds a local embedded runtime manager to the desktop app (FN-3404) and wires permanent-agent approval contex

Fusion-Task-Id: FN-3506
This commit is contained in:
Fusion
2026-05-07 19:54:23 -07:00
committed by gsxdsm
parent 4770b1225c
commit 9f60a0da50
11 changed files with 576 additions and 13 deletions

View File

@@ -135,3 +135,4 @@ export async function persistNodeProjectPathMappings(
projectMappings.map(({ projectId, path }) => upsertProjectPathMapping(projectId, nodeId, path)),
);
}

View File

@@ -5548,6 +5548,18 @@ export interface NodeProjectMappingInput {
path: string;
}
export interface RemoteNodeDiscoveredProject {
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
}
export interface RemoteNodeProjectDiscoveryResult {
projects: RemoteNodeDiscoveredProject[];
}
/**
* Node onboarding payload used by dashboard UI.
*
@@ -5746,6 +5758,14 @@ export function registerNode(input: NodeCreateInput): Promise<NodeInfo> {
});
}
/** Discover projects from a remote node before registering it. */
export function discoverRemoteNodeProjects(input: { url: string; apiKey?: string }): Promise<RemoteNodeProjectDiscoveryResult> {
return api<RemoteNodeProjectDiscoveryResult>("/nodes/discover-projects", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch a single node by ID */
export function fetchNode(id: string): Promise<NodeInfo> {
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`);

View File

@@ -149,6 +149,37 @@
gap: var(--space-sm);
}
.add-node-modal__discovery-actions {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.add-node-modal__discovery-state {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.375);
color: var(--color-success);
}
.add-node-modal__discovered-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.add-node-modal__discovered-card {
padding: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.add-node-modal__discovered-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.add-node-modal__projects {
display: flex;
flex-direction: column;
@@ -203,4 +234,9 @@
.add-node-modal__project-card {
padding: var(--space-md);
}
.add-node-modal__discovered-row {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { NodeProjectMappingInput, ProjectInfo } from "../api";
import type { NodeProjectMappingInput, ProjectInfo, RemoteNodeDiscoveredProject, RemoteNodeProjectDiscoveryResult } from "../api";
import { validateProjectPath } from "../utils/projectDetection";
import type { ToastType } from "../hooks/useToast";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -32,6 +32,7 @@ interface AddNodeModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (input: AddNodeInput) => Promise<void>;
onDiscoverRemoteProjects: (input: { url: string; apiKey?: string }) => Promise<RemoteNodeProjectDiscoveryResult>;
addToast: (message: string, type?: ToastType) => void;
projects: ProjectInfo[];
}
@@ -71,7 +72,9 @@ function validateInput(input: AddNodeInput): FormErrors {
return errors;
}
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }: AddNodeModalProps) {
type DiscoveryState = "idle" | "loading" | "success" | "error";
export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjects, addToast, projects }: AddNodeModalProps) {
useMobileScrollLock(isOpen);
const [name, setName] = useState("");
const [type, setType] = useState<"local" | "remote">("local");
@@ -82,6 +85,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
const [selectedProjectPaths, setSelectedProjectPaths] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<FormErrors>({ projectMappings: {} });
const [isSubmitting, setIsSubmitting] = useState(false);
const [discoveryState, setDiscoveryState] = useState<DiscoveryState>("idle");
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
const [discoveredProjects, setDiscoveredProjects] = useState<RemoteNodeDiscoveredProject[]>([]);
const resetForm = useCallback(() => {
setName("");
@@ -93,8 +99,10 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
setSelectedProjectPaths({});
setErrors({ projectMappings: {} });
setIsSubmitting(false);
setDiscoveryState("idle");
setDiscoveryError(null);
setDiscoveredProjects([]);
}, []);
const closeModal = useCallback(() => {
if (isSubmitting) return;
resetForm();
@@ -130,6 +138,60 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
projectMappings: Object.entries(selectedProjectPaths).map(([projectId, path]) => ({ projectId, path: path.trim() })),
}), [apiKey, apiKeyMode, maxConcurrent, name, selectedProjectPaths, type, url]);
const handleDiscoverProjects = useCallback(async () => {
if (isSubmitting || discoveryState === "loading") return;
const trimmedUrl = url.trim();
if (!trimmedUrl) {
setErrors((current) => ({ ...current, url: "URL is required for remote nodes" }));
return;
}
setDiscoveryState("loading");
setDiscoveryError(null);
try {
const response = await onDiscoverRemoteProjects({
url: trimmedUrl,
apiKey: apiKeyMode === "provide" && apiKey.trim().length > 0 ? apiKey : undefined,
});
setDiscoveredProjects(response.projects);
setDiscoveryState("success");
setSelectedProjectPaths((current) => {
if (Object.keys(current).length === 0) {
return current;
}
const next = { ...current };
for (const project of projects) {
if (!(project.id in next)) continue;
const matches = response.projects.filter((remoteProject) => remoteProject.name === project.name);
if (matches.length === 1) {
next[project.id] = matches[0].path;
}
}
return next;
});
} catch (error) {
setDiscoveryState("error");
setDiscoveredProjects([]);
setDiscoveryError(error instanceof Error ? error.message : "Failed to discover remote projects");
}
}, [apiKey, apiKeyMode, discoveryState, isSubmitting, onDiscoverRemoteProjects, projects, url]);
useEffect(() => {
if (type !== "remote") {
setDiscoveryState("idle");
setDiscoveryError(null);
setDiscoveredProjects([]);
return;
}
setDiscoveryState("idle");
setDiscoveryError(null);
setDiscoveredProjects([]);
}, [apiKey, apiKeyMode, type, url]);
const handleSubmit = useCallback(async () => {
if (isSubmitting) return;
@@ -145,6 +207,11 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
return;
}
if (input.type === "remote" && discoveryState !== "success") {
setDiscoveryError("Discover remote projects before adding this node.");
return;
}
setIsSubmitting(true);
try {
@@ -157,7 +224,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
} finally {
setIsSubmitting(false);
}
}, [addToast, closeModal, input, isSubmitting, onSubmit]);
}, [addToast, closeModal, discoveryState, input, isSubmitting, onSubmit]);
const toggleProjectSelection = (project: ProjectInfo) => {
setSelectedProjectPaths((current) => {
@@ -165,6 +232,15 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
const { [project.id]: _removed, ...remaining } = current;
return remaining;
}
if (type === "remote" && discoveryState === "success") {
const matches = discoveredProjects.filter((remoteProject) => remoteProject.name === project.name);
if (matches.length === 1) {
return { ...current, [project.id]: matches[0].path };
}
return { ...current, [project.id]: "" };
}
return { ...current, [project.id]: project.path };
});
};
@@ -268,6 +344,42 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
/>
</label>
)}
<div className="add-node-modal__discovery-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => void handleDiscoverProjects()}
disabled={isSubmitting || discoveryState === "loading"}
>
{discoveryState === "loading" ? "Discovering..." : "Discover Remote Projects"}
</button>
{discoveryState === "success" && (
<span className="add-node-modal__discovery-state" data-state="success">
{discoveredProjects.length > 0 ? `Discovered ${discoveredProjects.length} remote project${discoveredProjects.length === 1 ? "" : "s"}.` : "No projects discovered on remote node."}
</span>
)}
{discoveryState === "error" && discoveryError && (
<span className="form-error add-node-modal__error">{discoveryError}</span>
)}
{discoveryState === "idle" && (
<span className="add-node-modal__hint">Discover remote projects before adding this node.</span>
)}
</div>
{discoveryState === "success" && discoveredProjects.length > 0 && (
<div className="add-node-modal__discovered-list" aria-label="Discovered remote projects">
{discoveredProjects.map((project) => (
<div key={`${project.id}-${project.path}`} className="card add-node-modal__discovered-card">
<div className="add-node-modal__discovered-row">
<strong>{project.name}</strong>
<span className="card-status-badge card-status-badge--in-review">{project.status}</span>
</div>
<div className="add-node-modal__hint">{project.path}</div>
</div>
))}
</div>
)}
</div>
)}
@@ -311,6 +423,20 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }:
{selected && (
<label className="add-node-modal__field">
<span>Path on this node</span>
{type === "remote" && discoveryState === "success" && (
<span className="add-node-modal__hint">
{(() => {
const matches = discoveredProjects.filter((remoteProject) => remoteProject.name === project.name);
if (matches.length === 1) {
return `Remote-authoritative path discovered: ${matches[0].path}`;
}
if (matches.length > 1) {
return "Multiple remote projects matched this name. Enter the correct path manually.";
}
return "No exact remote name match. Enter this path manually.";
})()}
</span>
)}
<input
className="input"
type="text"

View File

@@ -31,6 +31,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
healthCheck,
patchDockerConfig,
fetchDockerDiff,
discoverRemoteProjects,
} = useNodes();
const { projects, refresh: refreshProjects } = useProjects();
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
@@ -249,6 +250,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
isOpen={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleRegister}
onDiscoverRemoteProjects={discoverRemoteProjects}
addToast={addToast}
projects={projects}
/>

View File

@@ -7,6 +7,7 @@ describe("AddNodeModal", () => {
isOpen: true,
onClose: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
onDiscoverRemoteProjects: vi.fn().mockResolvedValue({ projects: [] }),
addToast: vi.fn(),
projects: [
{
@@ -236,18 +237,22 @@ describe("AddNodeModal", () => {
).toBeInTheDocument();
});
it("submits remote node with URL and API key", async () => {
render(<AddNodeModal {...defaultProps} />);
it("submits remote node with URL and API key after discovery", async () => {
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
projects: [{
id: "remote-1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
}],
});
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
// Fill name
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
// Switch to remote
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
// Fill URL and API key
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
@@ -258,6 +263,15 @@ describe("AddNodeModal", () => {
target: { value: "secret-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
await waitFor(() => {
expect(onDiscoverRemoteProjects).toHaveBeenCalledWith({
url: "https://node.example.com",
apiKey: "secret-key",
});
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
@@ -272,6 +286,22 @@ describe("AddNodeModal", () => {
});
});
it("blocks remote submit until discovery succeeds", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Discover remote projects before adding this node.")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
it("validates selected project path is required", async () => {
render(<AddNodeModal {...defaultProps} />);
@@ -306,6 +336,96 @@ describe("AddNodeModal", () => {
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
it("prefills path from exact-name discovery match", async () => {
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
projects: [{
id: "remote-1",
name: "Project One",
path: "/remote/project-one",
status: "active",
isolationMode: "in-process",
}],
});
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
await waitFor(() => {
expect(onDiscoverRemoteProjects).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
expect(screen.getByDisplayValue("/remote/project-one")).toBeInTheDocument();
});
it("leaves ambiguous name matches unresolved", async () => {
const onDiscoverRemoteProjects = vi.fn().mockResolvedValue({
projects: [
{
id: "remote-1",
name: "Project One",
path: "/remote/project-one-a",
status: "active",
isolationMode: "in-process",
},
{
id: "remote-2",
name: "Project One",
path: "/remote/project-one-b",
status: "paused",
isolationMode: "child-process",
},
],
});
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Remote Node" },
});
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
await waitFor(() => {
expect(onDiscoverRemoteProjects).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Path is required")).toBeInTheDocument();
});
it("shows discovery error inline", async () => {
const onDiscoverRemoteProjects = vi.fn().mockRejectedValue(new Error("upstream unavailable"));
render(<AddNodeModal {...defaultProps} onDiscoverRemoteProjects={onDiscoverRemoteProjects} />);
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
expect(await screen.findByText("upstream unavailable")).toBeInTheDocument();
});
it("shows explicit zero-project discovery state", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: "Remote" }));
fireEvent.change(screen.getByPlaceholderText("https://node.example.com"), {
target: { value: "https://node.example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Discover Remote Projects" }));
expect(await screen.findByText("No projects discovered on remote node.")).toBeInTheDocument();
});
it("submits selected project mappings", async () => {
render(<AddNodeModal {...defaultProps} />);

View File

@@ -85,6 +85,10 @@ function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}
update: vi.fn().mockResolvedValue(makeNode()),
unregister: vi.fn().mockResolvedValue(undefined),
healthCheck: vi.fn().mockResolvedValue(undefined),
fetchDockerConfig: vi.fn().mockResolvedValue(null),
patchDockerConfig: vi.fn().mockResolvedValue({}),
fetchDockerDiff: vi.fn().mockResolvedValue({ persistedVersion: 0, deployedVersion: null, needsRecreate: false }),
discoverRemoteProjects: vi.fn().mockResolvedValue({ projects: [] }),
...overrides,
};
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeOnboardingInput, NodeUpdateInput } from "../api";
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeOnboardingInput, NodeUpdateInput, RemoteNodeProjectDiscoveryResult } from "../api";
import {
fetchDockerConfigDiff,
fetchDockerNodeConfig,
@@ -9,6 +9,7 @@ import {
updateNode,
unregisterNode,
checkNodeHealth,
discoverRemoteNodeProjects,
} from "../api";
import { persistNodeProjectPathMappings } from "../api-node";
@@ -24,6 +25,7 @@ export interface UseNodesResult {
fetchDockerConfig: (nodeId: string) => Promise<DockerNodeConfigInfo | null>;
patchDockerConfig: (nodeId: string, config: Partial<DockerNodeConfigInfo>) => Promise<DockerNodeConfigInfo>;
fetchDockerDiff: (nodeId: string) => Promise<{ persistedVersion: number; deployedVersion: number | null; needsRecreate: boolean }>;
discoverRemoteProjects: (input: { url: string; apiKey?: string }) => Promise<RemoteNodeProjectDiscoveryResult>;
}
const POLL_INTERVAL_MS = 10000; // 10 seconds
@@ -188,6 +190,10 @@ export function useNodes(): UseNodesResult {
return { persistedVersion: 0, deployedVersion: null, needsRecreate: false };
}, []);
const discoverRemoteProjects = useCallback(async (input: { url: string; apiKey?: string }) => {
return discoverRemoteNodeProjects(input);
}, []);
return {
nodes,
loading,
@@ -200,5 +206,6 @@ export function useNodes(): UseNodesResult {
fetchDockerConfig,
patchDockerConfig,
fetchDockerDiff,
discoverRemoteProjects,
};
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import { request } from "../test-request.js";
@@ -100,6 +100,10 @@ function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
describe("Node routes", () => {
const app = createServer(new MockStore() as any);
afterEach(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
mockListNodes.mockResolvedValue([]);
@@ -163,6 +167,130 @@ describe("Node routes", () => {
mockListProjectNodePathMappingsForNode.mockResolvedValue([]);
});
describe("POST /api/nodes/discover-projects", () => {
it("returns normalized remote project discovery payload on success", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ([
{
id: "proj_1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
},
]),
});
vi.stubGlobal("fetch", fetchMock);
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com", apiKey: "secret" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
projects: [
{
id: "proj_1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
},
],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://node.example.com/api/projects",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer secret" },
signal: expect.any(AbortSignal),
}),
);
});
it("returns upstream HTTP failure status and message", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 503,
statusText: "Service Unavailable",
json: async () => ({ error: "upstream down" }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "upstream down" });
});
it("returns 401 when upstream rejects auth", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
json: async () => ({ error: "Invalid API key" }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com", apiKey: "wrong" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Invalid API key" });
});
it("rejects malformed upstream payload", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ projects: [] }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(502);
expect(res.body).toEqual({ error: "Remote node returned malformed project discovery payload" });
});
it("returns 504 on timeout/unreachable abort", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(Object.assign(new Error("aborted"), { name: "AbortError" })));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(504);
expect(res.body).toEqual({ error: "Remote node discovery request timed out" });
});
});
it("GET /api/nodes/:id/path-mappings returns node mappings", async () => {
mockListProjectNodePathMappingsForNode.mockResolvedValue([
{

View File

@@ -1,6 +1,48 @@
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
const REMOTE_DISCOVERY_TIMEOUT_MS = 5000;
type DiscoveredRemoteProject = {
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
};
function normalizeNodeUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw badRequest("url is required and must be a non-empty string");
}
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw badRequest("url must be a valid HTTP(S) URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw badRequest("url must use http or https");
}
return parsed.toString().replace(/\/$/, "");
}
function isDiscoveredRemoteProject(value: unknown): value is DiscoveredRemoteProject {
if (!value || typeof value !== "object") return false;
const project = value as Record<string, unknown>;
return typeof project.id === "string"
&& typeof project.name === "string"
&& typeof project.path === "string"
&& ["active", "paused", "errored", "initializing"].includes(String(project.status))
&& ["in-process", "child-process"].includes(String(project.isolationMode));
}
export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx;
@@ -93,6 +135,70 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/**
* POST /api/nodes/discover-projects
* Discover projects from a remote node before registration.
* Body: { url: string; apiKey?: string }
*/
router.post("/nodes/discover-projects", async (req, res) => {
try {
const { url, apiKey } = req.body as { url?: unknown; apiKey?: unknown };
if (typeof url !== "string") {
throw badRequest("url is required and must be a non-empty string");
}
if (apiKey !== undefined && typeof apiKey !== "string") {
throw badRequest("apiKey must be a string when provided");
}
const normalizedUrl = normalizeNodeUrl(url);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REMOTE_DISCOVERY_TIMEOUT_MS);
try {
const response = await fetch(`${normalizedUrl}/api/projects`, {
method: "GET",
headers: apiKey && apiKey.trim().length > 0 ? { Authorization: `Bearer ${apiKey}` } : undefined,
signal: controller.signal,
});
if (!response.ok) {
let upstreamMessage = `Remote node returned ${response.status} ${response.statusText}`;
try {
const payload = await response.json() as { error?: unknown };
if (typeof payload?.error === "string" && payload.error.trim()) {
upstreamMessage = payload.error.trim();
}
} catch {
// keep generic upstream message
}
throw new ApiError(response.status, upstreamMessage);
}
const rawBody = await response.json() as unknown;
if (!Array.isArray(rawBody) || !rawBody.every(isDiscoveredRemoteProject)) {
throw new ApiError(502, "Remote node returned malformed project discovery payload");
}
res.json({ projects: rawBody });
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError(504, "Remote node discovery request timed out");
}
throw new ApiError(502, `Unable to reach remote node: ${error instanceof Error ? error.message : String(error)}`);
} finally {
clearTimeout(timeoutId);
}
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/nodes/:id/path-mappings
* List all project path mappings for a node.