Canvas: Generate graph state out of dodo-app config

Restructure code, create shared config lib.

Change-Id: I2cf06d35c486d4557484daf8618a2c215316fa7e
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
new file mode 100644
index 0000000..53fd64c
--- /dev/null
+++ b/apps/canvas/config/src/config.ts
@@ -0,0 +1,598 @@
+import {
+	AppNode,
+	BoundEnvVar,
+	Env,
+	GatewayHttpsNode,
+	GatewayTCPNode,
+	MongoDBNode,
+	Network,
+	NetworkNode,
+	Port,
+	PostgreSQLNode,
+	ServiceNode,
+	VolumeNode,
+} from "./graph.js";
+import { Edge } from "@xyflow/react";
+import { v4 as uuidv4 } from "uuid";
+import { ConfigWithInput, Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain } from "./types.js";
+
+export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): ConfigWithInput | null {
+	try {
+		if (appId == null || env.managerAddr == null) {
+			return null;
+		}
+		const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
+		const ingressNodes = nodes
+			.filter((n) => n.type === "gateway-https")
+			.filter((n) => n.data.https !== undefined && !n.data.readonly);
+		const tcpNodes = nodes
+			.filter((n) => n.type === "gateway-tcp")
+			.filter((n) => n.data.exposed !== undefined && !n.data.readonly);
+		const findExpose = (n: AppNode): PortDomain[] => {
+			return n.data.ports
+				.map((p) => [n.id, p.id, p.name])
+				.flatMap((sp) => {
+					return tcpNodes.flatMap((i) =>
+						(i.data.exposed || [])
+							.filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
+							.map(() => ({
+								nodeId: i.id,
+								network: networkMap.get(i.data.network!)!,
+								subdomain: i.data.subdomain!,
+								port: { name: sp[2] },
+							})),
+					);
+				});
+		};
+		return {
+			input: {
+				appId: appId,
+				managerAddr: env.managerAddr,
+			},
+			service: nodes
+				.filter((n) => n.type === "app")
+				.map((n): Service => {
+					return {
+						nodeId: n.id,
+						type: n.data.type,
+						name: n.data.label,
+						source: {
+							repository: nodes
+								.filter((i) => i.type === "github")
+								.find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
+							branch:
+								n.data.repository != undefined && "branch" in n.data.repository
+									? n.data.repository.branch
+									: "main",
+							rootDir:
+								n.data.repository != undefined && "rootDir" in n.data.repository
+									? n.data.repository.rootDir
+									: "/",
+						},
+						ports: (n.data.ports || [])
+							.filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
+							.map((p) => ({
+								name: p.name.toLowerCase(),
+								value: p.value,
+								protocol: "TCP", // TODO(gio)
+							})),
+						env: (n.data.envVars || [])
+							.filter((e) => "name" in e)
+							.map((e) => ({
+								name: e.name,
+								alias: "alias" in e ? e.alias : undefined,
+							})),
+						ingress: ingressNodes
+							.filter((i) => i.data.https!.serviceId === n.id)
+							.map(
+								(i): Ingress => ({
+									nodeId: i.id,
+									network: networkMap.get(i.data.network!)!,
+									subdomain: i.data.subdomain!,
+									port: {
+										name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
+									},
+									auth:
+										i.data.auth?.enabled || false
+											? {
+													enabled: true,
+													groups: i.data.auth!.groups,
+													noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
+												}
+											: {
+													enabled: false,
+												},
+								}),
+							),
+						expose: findExpose(n),
+						preBuildCommands: n.data.preBuildCommands
+							? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
+							: [],
+						dev: {
+							enabled: n.data.dev ? n.data.dev.enabled : false,
+							username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
+							codeServer:
+								n.data.dev?.enabled && n.data.dev.expose != null
+									? {
+											network: networkMap.get(n.data.dev.expose.network)!,
+											subdomain: n.data.dev.expose.subdomain,
+										}
+									: undefined,
+							ssh:
+								n.data.dev?.enabled && n.data.dev.expose != null
+									? {
+											network: networkMap.get(n.data.dev.expose.network)!,
+											subdomain: n.data.dev.expose.subdomain,
+										}
+									: undefined,
+						},
+					};
+				}),
+			volume: nodes
+				.filter((n) => n.type === "volume")
+				.map(
+					(n): Volume => ({
+						nodeId: n.id,
+						name: n.data.label,
+						accessMode: n.data.type,
+						size: n.data.size,
+					}),
+				),
+			postgresql: nodes
+				.filter((n) => n.type === "postgresql")
+				.map(
+					(n): PostgreSQL => ({
+						nodeId: n.id,
+						name: n.data.label,
+						size: "1Gi", // TODO(gio)
+						expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
+					}),
+				),
+			mongodb: nodes
+				.filter((n) => n.type === "mongodb")
+				.map(
+					(n): MongoDB => ({
+						nodeId: n.id,
+						name: n.data.label,
+						size: "1Gi", // TODO(gio)
+						expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
+					}),
+				),
+		};
+	} catch (e) {
+		console.log(e);
+		return { input: { appId: "qweqwe", managerAddr: "" } };
+	}
+}
+
+export type Graph = {
+	nodes: AppNode[];
+	edges: Edge[];
+};
+
+export function configToGraph(config: Config, networks: Network[], current?: Graph): Graph {
+	if (current == null) {
+		current = { nodes: [], edges: [] };
+	}
+	const ret: Graph = {
+		nodes: [],
+		edges: [],
+	};
+	if (networks.length === 0) {
+		return ret;
+	}
+	const networkNodes = networks.map((n): NetworkNode => {
+		let existing: NetworkNode | undefined = undefined;
+		existing = current.nodes
+			.filter((i): i is NetworkNode => i.type === "network")
+			.find((i) => i.data.domain === n.domain);
+		return {
+			id: n.domain,
+			type: "network",
+			data: {
+				label: n.name,
+				domain: n.domain,
+				envVars: [],
+				ports: [],
+			},
+			position: existing != null ? existing.position : { x: 0, y: 0 },
+		};
+	});
+	const services = config.service?.map((s): ServiceNode => {
+		let existing: ServiceNode | null = null;
+		if (s.nodeId !== undefined) {
+			existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
+		}
+		return {
+			id: existing != null ? existing.id : uuidv4(),
+			type: "app",
+			data: {
+				label: s.name,
+				type: s.type,
+				env: [],
+				ports: (s.ports || []).map(
+					(p): Port => ({
+						id: uuidv4(),
+						name: p.name,
+						value: p.value,
+					}),
+				),
+				envVars: (s.env || []).map((e): BoundEnvVar => {
+					if (e.alias != null) {
+						return {
+							id: uuidv4(),
+							name: e.name,
+							source: null,
+							alias: e.alias,
+							isEditting: false,
+						};
+					} else {
+						return {
+							id: uuidv4(),
+							name: e.name,
+							source: null,
+							isEditting: false,
+						};
+					}
+				}),
+				volume: s.volume || [],
+				preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
+				// TODO(gio): dev
+				isChoosingPortToConnect: false,
+			},
+			// TODO(gio): generate position
+			position:
+				existing != null
+					? existing.position
+					: {
+							x: 0,
+							y: 0,
+						},
+		};
+	});
+	const serviceGateways = config.service?.flatMap((s, index): GatewayHttpsNode[] => {
+		return (s.ingress || []).map((i): GatewayHttpsNode => {
+			let existing: GatewayHttpsNode | null = null;
+			if (i.nodeId !== undefined) {
+				existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
+			}
+			console.log("!!!", i.network, networks);
+			return {
+				id: existing != null ? existing.id : uuidv4(),
+				type: "gateway-https",
+				data: {
+					label: i.subdomain,
+					envVars: [],
+					ports: [],
+					network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
+					subdomain: i.subdomain,
+					https: {
+						serviceId: services![index]!.id,
+						portId: services![index]!.data.ports.find((p) => {
+							const port = i.port;
+							if ("name" in port) {
+								return p.name === port.name;
+							} else {
+								return `${p.value}` === port.value;
+							}
+						})!.id,
+					},
+					auth: i.auth.enabled
+						? {
+								enabled: true,
+								groups: i.auth.groups || [],
+								noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
+							}
+						: {
+								enabled: false,
+								groups: [],
+								noAuthPathPatterns: [],
+							},
+				},
+				position: {
+					x: 0,
+					y: 0,
+				},
+			};
+		});
+	});
+	const exposures = new Map<string, GatewayTCPNode>();
+	config.service
+		?.flatMap((s, index): GatewayTCPNode[] => {
+			return (s.expose || []).map((e): GatewayTCPNode => {
+				let existing: GatewayTCPNode | null = null;
+				if (e.nodeId !== undefined) {
+					existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
+				}
+				return {
+					id: existing != null ? existing.id : uuidv4(),
+					type: "gateway-tcp",
+					data: {
+						label: e.subdomain,
+						envVars: [],
+						ports: [],
+						network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
+						subdomain: e.subdomain,
+						exposed: [
+							{
+								serviceId: services![index]!.id,
+								portId: services![index]!.data.ports.find((p) => {
+									const port = e.port;
+									if ("name" in port) {
+										return p.name === port.name;
+									} else {
+										return p.value === port.value;
+									}
+								})!.id,
+							},
+						],
+					},
+					position: existing != null ? existing.position : { x: 0, y: 0 },
+				};
+			});
+		})
+		.forEach((n) => {
+			const key = `${n.data.network}-${n.data.subdomain}`;
+			if (!exposures.has(key)) {
+				exposures.set(key, n);
+			} else {
+				exposures.get(key)!.data.exposed.push(...n.data.exposed);
+			}
+		});
+	const volumes = config.volume?.map((v): VolumeNode => {
+		let existing: VolumeNode | null = null;
+		if (v.nodeId !== undefined) {
+			existing = current.nodes.find((n) => n.id === v.nodeId) as VolumeNode;
+		}
+		return {
+			id: existing != null ? existing.id : uuidv4(),
+			type: "volume",
+			data: {
+				label: v.name,
+				type: v.accessMode,
+				size: v.size,
+				attachedTo: [],
+				envVars: [],
+				ports: [],
+			},
+			position:
+				existing != null
+					? existing.position
+					: {
+							x: 0,
+							y: 0,
+						},
+		};
+	});
+	const postgresql = config.postgresql?.map((p): PostgreSQLNode => {
+		let existing: PostgreSQLNode | null = null;
+		if (p.nodeId !== undefined) {
+			existing = current.nodes.find((n) => n.id === p.nodeId) as PostgreSQLNode;
+		}
+		return {
+			id: existing != null ? existing.id : uuidv4(),
+			type: "postgresql",
+			data: {
+				label: p.name,
+				volumeId: "", // TODO(gio): volume
+				envVars: [],
+				ports: [
+					{
+						id: "connection",
+						name: "connection",
+						value: 5432,
+					},
+				],
+			},
+			position:
+				existing != null
+					? existing.position
+					: {
+							x: 0,
+							y: 0,
+						},
+		};
+	});
+	config.postgresql
+		?.flatMap((p, index): GatewayTCPNode[] => {
+			return (p.expose || []).map((e): GatewayTCPNode => {
+				let existing: GatewayTCPNode | null = null;
+				if (e.nodeId !== undefined) {
+					existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
+				}
+				return {
+					id: existing != null ? existing.id : uuidv4(),
+					type: "gateway-tcp",
+					data: {
+						label: e.subdomain,
+						envVars: [],
+						ports: [],
+						network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
+						subdomain: e.subdomain,
+						exposed: [
+							{
+								serviceId: postgresql![index]!.id,
+								portId: "connection",
+							},
+						],
+					},
+					position: existing != null ? existing.position : { x: 0, y: 0 },
+				};
+			});
+		})
+		.forEach((n) => {
+			const key = `${n.data.network}-${n.data.subdomain}`;
+			if (!exposures.has(key)) {
+				exposures.set(key, n);
+			} else {
+				exposures.get(key)!.data.exposed.push(...n.data.exposed);
+			}
+		});
+	const mongodb = config.mongodb?.map((m): MongoDBNode => {
+		let existing: MongoDBNode | null = null;
+		if (m.nodeId !== undefined) {
+			existing = current.nodes.find((n) => n.id === m.nodeId) as MongoDBNode;
+		}
+		return {
+			id: existing != null ? existing.id : uuidv4(),
+			type: "mongodb",
+			data: {
+				label: m.name,
+				volumeId: "", // TODO(gio): volume
+				envVars: [],
+				ports: [
+					{
+						id: "connection",
+						name: "connection",
+						value: 27017,
+					},
+				],
+			},
+			position:
+				existing != null
+					? existing.position
+					: {
+							x: 0,
+							y: 0,
+						},
+		};
+	});
+	config.mongodb
+		?.flatMap((p, index): GatewayTCPNode[] => {
+			return (p.expose || []).map((e): GatewayTCPNode => {
+				let existing: GatewayTCPNode | null = null;
+				if (e.nodeId !== undefined) {
+					existing = current.nodes.find((n) => n.id === e.nodeId) as GatewayTCPNode;
+				}
+				return {
+					id: existing != null ? existing.id : uuidv4(),
+					type: "gateway-tcp",
+					data: {
+						label: e.subdomain,
+						envVars: [],
+						ports: [],
+						network: networks.find((n) => n.name.toLowerCase() === e.network.toLowerCase())!.domain,
+						subdomain: e.subdomain,
+						exposed: [
+							{
+								serviceId: mongodb![index]!.id,
+								portId: "connection",
+							},
+						],
+					},
+					position: existing != null ? existing.position : { x: 0, y: 0 },
+				};
+			});
+		})
+		.forEach((n) => {
+			const key = `${n.data.network}-${n.data.subdomain}`;
+			if (!exposures.has(key)) {
+				exposures.set(key, n);
+			} else {
+				exposures.get(key)!.data.exposed.push(...n.data.exposed);
+			}
+		});
+	ret.nodes = [
+		...networkNodes,
+		...ret.nodes,
+		...(services || []),
+		...(serviceGateways || []),
+		...(volumes || []),
+		...(postgresql || []),
+		...(mongodb || []),
+		...(exposures.values() || []),
+	];
+	services?.forEach((s) => {
+		s.data.envVars.forEach((e) => {
+			if (!("name" in e)) {
+				return;
+			}
+			if (!e.name.startsWith("DODO_")) {
+				return;
+			}
+			let r: {
+				type: string;
+				name: string;
+			} | null = null;
+			if (e.name.startsWith("DODO_PORT_")) {
+				return;
+			} else if (e.name.startsWith("DODO_POSTGRESQL_")) {
+				r = {
+					type: "postgresql",
+					name: e.name.replace("DODO_POSTGRESQL_", "").replace("_URL", "").toLowerCase(),
+				};
+			} else if (e.name.startsWith("DODO_MONGODB_")) {
+				r = {
+					type: "mongodb",
+					name: e.name.replace("DODO_MONGODB_", "").replace("_URL", "").toLowerCase(),
+				};
+			} else if (e.name.startsWith("DODO_VOLUME_")) {
+				r = {
+					type: "volume",
+					name: e.name.replace("DODO_VOLUME_", "").toLowerCase(),
+				};
+			}
+			if (r != null) {
+				e.source = ret.nodes.find((n) => n.type === r.type && n.data.label.toLowerCase() === r.name)!.id;
+			}
+		});
+	});
+	const envVarEdges = [...(services || [])].flatMap((n): Edge[] => {
+		return n.data.envVars.flatMap((e): Edge[] => {
+			if (e.source == null) {
+				return [];
+			}
+			const sn = ret.nodes.find((n) => n.id === e.source!)!;
+			const sourceHandle = sn.type === "app" ? "ports" : sn.type === "volume" ? "volume" : "env_var";
+			return [
+				{
+					id: uuidv4(),
+					source: e.source!,
+					sourceHandle: sourceHandle,
+					target: n.id,
+					targetHandle: "env_var",
+				},
+			];
+		});
+	});
+	const exposureEdges = [...exposures.values()].flatMap((n): Edge[] => {
+		return n.data.exposed.flatMap((e): Edge[] => {
+			return [
+				{
+					id: uuidv4(),
+					source: e.serviceId,
+					sourceHandle: ret.nodes.find((n) => n.id === e.serviceId)!.type === "app" ? "ports" : "env_var",
+					target: n.id,
+					targetHandle: "tcp",
+				},
+				{
+					id: uuidv4(),
+					source: n.id,
+					sourceHandle: "subdomain",
+					target: n.data.network!,
+					targetHandle: "subdomain",
+				},
+			];
+		});
+	});
+	const ingressEdges = [...(serviceGateways || [])].flatMap((n): Edge[] => {
+		return [
+			{
+				id: uuidv4(),
+				source: n.data.https!.serviceId,
+				sourceHandle: "ports",
+				target: n.id,
+				targetHandle: "https",
+			},
+			{
+				id: uuidv4(),
+				source: n.id,
+				sourceHandle: "subdomain",
+				target: n.data.network!,
+				targetHandle: "subdomain",
+			},
+		];
+	});
+	ret.edges = [...envVarEdges, ...exposureEdges, ...ingressEdges];
+	return ret;
+}
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
new file mode 100644
index 0000000..e8741f9
--- /dev/null
+++ b/apps/canvas/config/src/graph.ts
@@ -0,0 +1,320 @@
+import { z } from "zod";
+import { Node } from "@xyflow/react";
+import { Domain, ServiceType, VolumeType } from "./types.js";
+
+export const serviceAnalyzisSchema = z.object({
+	name: z.string(),
+	location: z.string(),
+	configVars: z.array(
+		z.object({
+			name: z.string(),
+			category: z.enum(["CommandLineFlag", "EnvironmentVariable"]),
+			type: z.optional(z.enum(["String", "Number", "Boolean"])),
+			semanticType: z.optional(
+				z.enum([
+					"EXPANDED_ENV_VAR",
+					"PORT",
+					"FILESYSTEM_PATH",
+					"DATABASE_URL",
+					"SQLITE_PATH",
+					"POSTGRES_URL",
+					"POSTGRES_PASSWORD",
+					"POSTGRES_USER",
+					"POSTGRES_DB",
+					"POSTGRES_PORT",
+					"POSTGRES_HOST",
+					"POSTGRES_SSL",
+					"MONGO_URL",
+					"MONGO_PASSWORD",
+					"MONGO_USER",
+					"MONGO_DB",
+					"MONGO_PORT",
+					"MONGO_HOST",
+					"MONGO_SSL",
+				]),
+			),
+		}),
+	),
+});
+
+export type BoundEnvVar =
+	| {
+			id: string;
+			source: string | null;
+	  }
+	| {
+			id: string;
+			source: string | null;
+			name: string;
+			isEditting: boolean;
+	  }
+	| {
+			id: string;
+			source: string | null;
+			name: string;
+			alias: string;
+			isEditting: boolean;
+	  }
+	| {
+			id: string;
+			source: string | null;
+			portId: string;
+			name: string;
+			alias: string;
+			isEditting: boolean;
+	  };
+
+export type EnvVar = {
+	name: string;
+	value: string;
+};
+
+export type InitData = {
+	label: string;
+	envVars: BoundEnvVar[];
+	ports: Port[];
+};
+
+export type NodeData = InitData & {
+	activeField?: string | undefined;
+	state?: string | null;
+};
+
+export type PortConnectedTo = {
+	serviceId: string;
+	portId: string;
+};
+
+export type NetworkData = NodeData & {
+	domain: string;
+};
+
+export type NetworkNode = Node<NetworkData> & {
+	type: "network";
+};
+
+export type GatewayHttpsData = NodeData & {
+	readonly?: boolean;
+	network?: string;
+	subdomain?: string;
+	https?: PortConnectedTo;
+	auth?: {
+		enabled: boolean;
+		groups: string[];
+		noAuthPathPatterns: string[];
+	};
+};
+
+export type GatewayHttpsNode = Node<GatewayHttpsData> & {
+	type: "gateway-https";
+};
+
+export type GatewayTCPData = NodeData & {
+	readonly?: boolean;
+	network?: string;
+	subdomain?: string;
+	exposed: PortConnectedTo[];
+	selected?: {
+		serviceId?: string;
+		portId?: string;
+	};
+};
+
+export type GatewayTCPNode = Node<GatewayTCPData> & {
+	type: "gateway-tcp";
+};
+
+export type Port = {
+	id: string;
+	name: string;
+	value: number;
+};
+
+export type ServiceData = NodeData & {
+	type: ServiceType;
+	repository?:
+		| {
+				id: number;
+				repoNodeId: string;
+		  }
+		| {
+				id: number;
+				repoNodeId: string;
+				branch: string;
+		  }
+		| {
+				id: number;
+				repoNodeId: string;
+				branch: string;
+				rootDir: string;
+		  };
+	env: string[];
+	volume: string[];
+	preBuildCommands: string;
+	isChoosingPortToConnect: boolean;
+	dev?:
+		| {
+				enabled: false;
+				expose?: Domain;
+		  }
+		| {
+				enabled: true;
+				expose?: Domain;
+				codeServerNodeId: string;
+				sshNodeId: string;
+		  };
+	info?: z.infer<typeof serviceAnalyzisSchema>;
+};
+
+export type ServiceNode = Node<ServiceData> & {
+	type: "app";
+};
+
+export type VolumeData = NodeData & {
+	type: VolumeType;
+	size: string;
+	attachedTo: string[];
+};
+
+export type VolumeNode = Node<VolumeData> & {
+	type: "volume";
+};
+
+export type PostgreSQLData = NodeData & {
+	volumeId: string;
+};
+
+export type PostgreSQLNode = Node<PostgreSQLData> & {
+	type: "postgresql";
+};
+
+export type MongoDBData = NodeData & {
+	volumeId: string;
+};
+
+export type MongoDBNode = Node<MongoDBData> & {
+	type: "mongodb";
+};
+
+export type GithubData = NodeData & {
+	repository?: {
+		id: number;
+		sshURL: string;
+		fullName: string;
+	};
+};
+
+export type GithubNode = Node<GithubData> & {
+	type: "github";
+};
+
+export type NANode = Node<NodeData> & {
+	type: undefined;
+};
+
+export type AppNode =
+	| NetworkNode
+	| GatewayHttpsNode
+	| GatewayTCPNode
+	| ServiceNode
+	| VolumeNode
+	| PostgreSQLNode
+	| MongoDBNode
+	| GithubNode
+	| NANode;
+
+export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
+
+export const networkSchema = z.object({
+	name: z.string().min(1),
+	domain: z.string().min(1),
+	hasAuth: z.boolean(),
+});
+
+export type Network = z.infer<typeof networkSchema>;
+
+export const accessSchema = z.discriminatedUnion("type", [
+	z.object({
+		type: z.literal("https"),
+		name: z.string(),
+		address: z.string(),
+	}),
+	z.object({
+		type: z.literal("ssh"),
+		name: z.string(),
+		host: z.string(),
+		port: z.number(),
+	}),
+	z.object({
+		type: z.literal("tcp"),
+		name: z.string(),
+		host: z.string(),
+		port: z.number(),
+	}),
+	z.object({
+		type: z.literal("udp"),
+		name: z.string(),
+		host: z.string(),
+		port: z.number(),
+	}),
+	z.object({
+		type: z.literal("postgresql"),
+		name: z.string(),
+		host: z.string(),
+		port: z.number(),
+		database: z.string(),
+		username: z.string(),
+		password: z.string(),
+	}),
+	z.object({
+		type: z.literal("mongodb"),
+		name: z.string(),
+		host: z.string(),
+		port: z.number(),
+		database: z.string(),
+		username: z.string(),
+		password: z.string(),
+	}),
+]);
+
+export const serviceInfoSchema = z.object({
+	name: z.string(),
+	workers: z.array(
+		z.object({
+			id: z.string(),
+			commit: z.optional(
+				z.object({
+					hash: z.string(),
+					message: z.string(),
+				}),
+			),
+			commands: z.optional(
+				z.array(
+					z.object({
+						command: z.string(),
+						state: z.string(),
+					}),
+				),
+			),
+		}),
+	),
+});
+
+export const envSchema = z.object({
+	managerAddr: z.optional(z.string().min(1)),
+	instanceId: z.optional(z.string().min(1)),
+	deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
+	networks: z.array(networkSchema).default([]),
+	integrations: z.object({
+		github: z.boolean(),
+	}),
+	services: z.array(serviceInfoSchema),
+	user: z.object({
+		id: z.string(),
+		username: z.string(),
+	}),
+	access: z.array(accessSchema),
+});
+
+export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
+export type Env = z.infer<typeof envSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
new file mode 100644
index 0000000..d064f1c
--- /dev/null
+++ b/apps/canvas/config/src/index.ts
@@ -0,0 +1,51 @@
+export {
+	Auth,
+	AuthDisabled,
+	AuthEnabled,
+	Config,
+	ConfigSchema,
+	ConfigWithInputSchema,
+	Domain,
+	Ingress,
+	MongoDB,
+	PortDomain,
+	PortValue,
+	PostgreSQL,
+	Service,
+	ServiceTypes,
+	Volume,
+	ConfigWithInput,
+	VolumeType,
+} from "./types.js";
+
+export {
+	AppNode,
+	NodeType,
+	Network,
+	ServiceNode,
+	BoundEnvVar,
+	GatewayTCPNode,
+	GatewayHttpsNode,
+	GithubNode,
+	serviceAnalyzisSchema,
+	ServiceData,
+	VolumeNode,
+	PostgreSQLNode,
+	MongoDBNode,
+	Port,
+	EnvVar,
+	NodeData,
+	InitData,
+	NetworkData,
+	GatewayHttpsData,
+	GatewayTCPData,
+	ServiceInfo,
+	Env,
+	VolumeData,
+	PostgreSQLData,
+	MongoDBData,
+	GithubData,
+	envSchema,
+} from "./graph.js";
+
+export { generateDodoConfig, configToGraph } from "./config.js";
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
new file mode 100644
index 0000000..fe417b8
--- /dev/null
+++ b/apps/canvas/config/src/types.ts
@@ -0,0 +1,155 @@
+import { z } from "zod";
+
+const AuthDisabledSchema = z.object({
+	enabled: z.literal(false),
+});
+
+const AuthEnabledSchema = z.object({
+	enabled: z.literal(true),
+	groups: z.array(z.string()),
+	noAuthPathPatterns: z.array(z.string()),
+});
+
+const AuthSchema = z.union([AuthDisabledSchema, AuthEnabledSchema]);
+
+const IngressSchema = z.object({
+	nodeId: z.string().optional(),
+	network: z.string(),
+	subdomain: z.string(),
+	port: z.union([z.object({ name: z.string() }), z.object({ value: z.string() })]),
+	auth: AuthSchema,
+});
+
+const DomainSchema = z.object({
+	nodeId: z.string().optional(),
+	network: z.string(),
+	subdomain: z.string(),
+});
+
+const PortValueSchema = z.union([
+	z.object({
+		name: z.string(),
+	}),
+	z.object({
+		value: z.number(),
+	}),
+]);
+
+const PortDomainSchema = DomainSchema.extend({
+	port: PortValueSchema,
+});
+
+export const ServiceTypes = [
+	"deno:2.2.0",
+	"golang:1.20.0",
+	"golang:1.22.0",
+	"golang:1.24.0",
+	"hugo:latest",
+	"php:8.2-apache",
+	"nextjs:deno-2.0.0",
+	"nodejs:23.1.0",
+	"nodejs:24.0.2",
+] as const;
+
+const ServiceTypeSchema = z.enum(ServiceTypes);
+
+const ServiceSchema = z.object({
+	nodeId: z.string().optional(),
+	type: ServiceTypeSchema,
+	name: z.string(),
+	source: z.object({
+		repository: z.string(),
+		branch: z.string(),
+		rootDir: z.string(),
+	}),
+	ports: z
+		.array(
+			z.object({
+				name: z.string(),
+				value: z.number(),
+				protocol: z.enum(["TCP", "UDP"]),
+			}),
+		)
+		.optional(),
+	env: z
+		.array(
+			z.object({
+				name: z.string(),
+				alias: z.string().optional(),
+			}),
+		)
+		.optional(),
+	ingress: z.array(IngressSchema).optional(),
+	expose: z.array(PortDomainSchema).optional(),
+	volume: z.array(z.string()).optional(),
+	preBuildCommands: z.array(z.object({ bin: z.string() })).optional(),
+	dev: z
+		.object({
+			enabled: z.boolean(),
+			username: z.string().optional(),
+			ssh: DomainSchema.optional(),
+			codeServer: DomainSchema.optional(),
+		})
+		.optional(),
+});
+
+const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
+
+const VolumeSchema = z.object({
+	nodeId: z.string().optional(),
+	name: z.string(),
+	size: z.string(),
+	accessMode: VolumeTypeSchema,
+});
+
+const PostgreSQLSchema = z.object({
+	nodeId: z.string().optional(),
+	name: z.string(),
+	size: z.string(),
+	expose: z.array(DomainSchema).optional(),
+});
+
+const MongoDBSchema = z.object({
+	nodeId: z.string().optional(),
+	name: z.string(),
+	size: z.string(),
+	expose: z.array(DomainSchema).optional(),
+});
+
+export const ConfigSchema = z.object({
+	service: z.array(ServiceSchema).optional(),
+	volume: z.array(VolumeSchema).optional(),
+	postgresql: z.array(PostgreSQLSchema).optional(),
+	mongodb: z.array(MongoDBSchema).optional(),
+});
+
+export const InputSchema = z.object({
+	appId: z.string(),
+	managerAddr: z.string(),
+	key: z
+		.object({
+			public: z.string(),
+			private: z.string(),
+		})
+		.optional(),
+});
+
+export const ConfigWithInputSchema = ConfigSchema.extend({
+	input: InputSchema,
+});
+
+export type AuthDisabled = z.infer<typeof AuthDisabledSchema>;
+export type AuthEnabled = z.infer<typeof AuthEnabledSchema>;
+export type Auth = z.infer<typeof AuthSchema>;
+export type Ingress = z.infer<typeof IngressSchema>;
+export type Domain = z.infer<typeof DomainSchema>;
+export type PortValue = z.infer<typeof PortValueSchema>;
+export type PortDomain = z.infer<typeof PortDomainSchema>;
+export type ServiceType = z.infer<typeof ServiceTypeSchema>;
+export type Service = z.infer<typeof ServiceSchema>;
+export type VolumeType = z.infer<typeof VolumeTypeSchema>;
+export type Volume = z.infer<typeof VolumeSchema>;
+export type PostgreSQL = z.infer<typeof PostgreSQLSchema>;
+export type MongoDB = z.infer<typeof MongoDBSchema>;
+export type Config = z.infer<typeof ConfigSchema>;
+export type ConfigWithInput = z.infer<typeof ConfigWithInputSchema>;