+
+ Payments (Stripe)
+
+ Products, donations, subscriptions, ticketed events
+
+
setFeatureFlags((f) => ({ ...f, enablePayments: v }))}
+ disabled={isRegistered}
+ />
+
+
Newsletter (Listmonk)
@@ -876,6 +891,36 @@ export default function InstanceDetailPage() {
/>
+
+
+ People CRM
+
+ Unified people management, contact linking
+
+
setFeatureFlags((f) => ({ ...f, enablePeople: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ Analytics & GeoIP
+
+ Visitor geography tracking, user drill-down, unified dashboard
+
+
setFeatureFlags((f) => ({ ...f, enableAnalytics: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+
+
Chat (Rocket.Chat)
@@ -888,6 +933,45 @@ export default function InstanceDetailPage() {
disabled={isRegistered}
/>
+
+
+
+ Video Conferencing (Jitsi Meet)
+
+ Self-hosted video calls (4 containers)
+
+
setFeatureFlags((f) => ({ ...f, enableMeet: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ SMS Campaigns
+
+ Termux Android bridge, bulk SMS outreach
+
+
setFeatureFlags((f) => ({ ...f, enableSms: v }))}
+ disabled={isRegistered}
+ />
+
+
+
+
+ Social Connections
+
+ Volunteer friendships, challenges, spotlights, referrals
+
+
setFeatureFlags((f) => ({ ...f, enableSocial: v }))}
+ disabled={isRegistered}
+ />
+
@@ -897,7 +981,7 @@ export default function InstanceDetailPage() {
Monitoring
- Prometheus, Grafana, Alertmanager
+ Prometheus, Grafana, Alertmanager, cAdvisor
-
-
-
- Payments
-
- Vaultwarden (secrets vault, future)
-
-
setFeatureFlags((f) => ({ ...f, enablePayments: v }))}
- disabled={isRegistered}
- />
-
-
-
-
- Video Conferencing (Jitsi Meet)
-
- Self-hosted video calls, Rocket.Chat integration (4 containers)
-
-
setFeatureFlags((f) => ({ ...f, enableMeet: v }))}
- disabled={isRegistered}
- />
-
-
-
-
- SMS Campaigns
-
- Termux-based SMS outreach (no additional containers)
-
-
setFeatureFlags((f) => ({ ...f, enableSms: v }))}
- disabled={isRegistered}
- />
-
-
-
-
- Social Connections
-
- Volunteer social features, friend connections
-
-
setFeatureFlags((f) => ({ ...f, enableSocial: v }))}
- disabled={isRegistered}
- />
-
-
-
-
- People CRM
-
- Unified people management, contact linking
-
-
setFeatureFlags((f) => ({ ...f, enablePeople: v }))}
- disabled={isRegistered}
- />
-
-
-
-
- Analytics & GeoIP
-
- Unified analytics, visitor geography, user drill-down
-
-
setFeatureFlags((f) => ({ ...f, enableAnalytics: v }))}
- disabled={isRegistered}
- />
-
@@ -1015,6 +1021,7 @@ export default function InstanceDetailPage() {
enableSms: instance.enableSms,
enableSocial: instance.enableSocial,
enablePeople: instance.enablePeople,
+ enableAnalytics: instance.enableAnalytics,
});
}}
disabled={!hasFeatureChanges}
diff --git a/changemaker-control-panel/admin/src/pages/InstanceListPage.tsx b/changemaker-control-panel/admin/src/pages/InstanceListPage.tsx
index 49765865..aeb55400 100644
--- a/changemaker-control-panel/admin/src/pages/InstanceListPage.tsx
+++ b/changemaker-control-panel/admin/src/pages/InstanceListPage.tsx
@@ -112,6 +112,7 @@ export default function InstanceListPage() {
navigate(`/app/instances/${record.id}`)}>{name}
{record.isRegistered && External}
+ {record.isRemote && Remote}
),
},
diff --git a/changemaker-control-panel/admin/src/pages/InviteCodesPage.tsx b/changemaker-control-panel/admin/src/pages/InviteCodesPage.tsx
new file mode 100644
index 00000000..c03ec8dd
--- /dev/null
+++ b/changemaker-control-panel/admin/src/pages/InviteCodesPage.tsx
@@ -0,0 +1,138 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Typography, Button, Table, Tag, Space, message, Popconfirm, Card, Alert } from 'antd';
+import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons';
+import { api } from '@/lib/api';
+import type { AgentInviteCode } from '@/types/api';
+
+const { Title, Text } = Typography;
+
+export default function InviteCodesPage() {
+ const [codes, setCodes] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
+
+ const fetchCodes = useCallback(async () => {
+ try {
+ setLoading(true);
+ const { data } = await api.get('/api/invite-codes');
+ setCodes(data.data || []);
+ } catch {
+ message.error('Failed to load invite codes');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { fetchCodes(); }, [fetchCodes]);
+
+ const handleCreate = async () => {
+ try {
+ setCreating(true);
+ const { data } = await api.post('/api/invite-codes');
+ message.success(`Invite code created: ${data.code}`);
+ fetchCodes();
+ } catch {
+ message.error('Failed to create invite code');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleRevoke = async (id: string) => {
+ try {
+ await api.delete(`/api/invite-codes/${id}`);
+ message.success('Invite code revoked');
+ fetchCodes();
+ } catch {
+ message.error('Failed to revoke invite code');
+ }
+ };
+
+ const copyCode = (code: string) => {
+ navigator.clipboard.writeText(code);
+ message.success('Code copied to clipboard');
+ };
+
+ const columns = [
+ {
+ title: 'Code',
+ dataIndex: 'code',
+ key: 'code',
+ render: (code: string) => (
+
+ {code}
+ } onClick={() => copyCode(code)} />
+
+ ),
+ },
+ {
+ title: 'Status',
+ key: 'status',
+ render: (_: unknown, record: AgentInviteCode) => {
+ if (record.usedAt) return Used;
+ if (new Date(record.expiresAt) < new Date()) return Expired;
+ return Active;
+ },
+ },
+ {
+ title: 'Created',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ render: (date: string) => new Date(date).toLocaleString(),
+ },
+ {
+ title: 'Expires',
+ dataIndex: 'expiresAt',
+ key: 'expiresAt',
+ render: (date: string) => new Date(date).toLocaleString(),
+ },
+ {
+ title: 'Created By',
+ dataIndex: 'createdBy',
+ key: 'createdBy',
+ render: (user: AgentInviteCode['createdBy']) => user?.name || user?.email || '-',
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ render: (_: unknown, record: AgentInviteCode) => {
+ if (record.usedAt) return null;
+ return (
+ handleRevoke(record.id)}>
+ } size="small">Revoke
+
+ );
+ },
+ },
+ ];
+
+ return (
+
+
+
+
Agent Invite Codes
+ } onClick={handleCreate} loading={creating}>
+ Generate Code
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/changemaker-control-panel/admin/src/types/api.ts b/changemaker-control-panel/admin/src/types/api.ts
index 11e480e7..1839629e 100644
--- a/changemaker-control-panel/admin/src/types/api.ts
+++ b/changemaker-control-panel/admin/src/types/api.ts
@@ -21,8 +21,14 @@ export interface Instance {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
+ enableAnalytics: boolean;
jvbAdvertiseIp?: string;
isRegistered: boolean;
+ isRemote: boolean;
+ agentUrl?: string;
+ agentFingerprint?: string;
+ agentVersion?: string;
+ agentLastSeen?: string;
adminEmail: string;
pangolinEndpoint?: string;
pangolinSiteId?: string;
@@ -95,6 +101,7 @@ export interface DiscoveredInstance {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
+ enableAnalytics: boolean;
emailTestMode: boolean;
source: 'parent' | 'docker';
isRunning: boolean;
@@ -202,3 +209,41 @@ export interface AuditLogEntry {
user?: { id: string; email: string; name: string } | null;
instance?: { id: string; name: string; slug: string } | null;
}
+
+// ─── Remote Agent Types ─────────────────────────────────────────────
+
+export interface AgentRegistration {
+ id: string;
+ inviteCodeId: string;
+ slug: string;
+ name: string;
+ domain: string;
+ agentUrl: string;
+ basePath: string;
+ composeProject: string;
+ metadata?: Record;
+ status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXPIRED';
+ instanceId?: string;
+ approvedById?: string;
+ approvedAt?: string;
+ rejectedAt?: string;
+ createdAt: string;
+}
+
+export interface AgentInviteCode {
+ id: string;
+ code: string;
+ createdById: string;
+ usedById?: string;
+ expiresAt: string;
+ usedAt?: string;
+ createdAt: string;
+ createdBy?: { id: string; name: string; email: string };
+}
+
+export interface AgentStatus {
+ reachable: boolean;
+ version?: string;
+ uptime?: number;
+ error?: string;
+}
diff --git a/changemaker-control-panel/agent/Dockerfile b/changemaker-control-panel/agent/Dockerfile
new file mode 100644
index 00000000..06cf98af
--- /dev/null
+++ b/changemaker-control-panel/agent/Dockerfile
@@ -0,0 +1,17 @@
+FROM node:20-alpine AS builder
+RUN apk add --no-cache git
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY tsconfig.json ./
+COPY src/ ./src/
+RUN npx tsc
+
+FROM node:20-alpine
+RUN apk add --no-cache docker-cli docker-cli-compose git rsync
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci --production
+COPY --from=builder /app/dist/ ./dist/
+EXPOSE 7443
+CMD ["node", "dist/server.js"]
diff --git a/changemaker-control-panel/agent/package-lock.json b/changemaker-control-panel/agent/package-lock.json
new file mode 100644
index 00000000..da110332
--- /dev/null
+++ b/changemaker-control-panel/agent/package-lock.json
@@ -0,0 +1,1642 @@
+{
+ "name": "ccp-agent",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ccp-agent",
+ "version": "1.0.0",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-async-errors": "^3.1.1",
+ "winston": "^3.17.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.0.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
+ "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/@dabh/diagnostics": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
+ "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
+ "dependencies": {
+ "@so-ric/colorspace": "^1.1.6",
+ "enabled": "2.0.x",
+ "kuler": "^2.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@so-ric/colorspace": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
+ "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
+ "dependencies": {
+ "color": "^5.0.2",
+ "text-hex": "1.0.x"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.17",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
+ "dev": true
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/triple-beam": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
+ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/color": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
+ "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
+ "dependencies": {
+ "color-convert": "^3.1.3",
+ "color-string": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
+ "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.6"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+ "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+ "engines": {
+ "node": ">=12.20"
+ }
+ },
+ "node_modules/color-string": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
+ "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+ },
+ "node_modules/enabled": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
+ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-async-errors": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
+ "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
+ "peerDependencies": {
+ "express": "^4.16.2"
+ }
+ },
+ "node_modules/fecha": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
+ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fn.name": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
+ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
+ "dev": true,
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/kuler": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
+ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
+ },
+ "node_modules/logform": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
+ "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
+ "dependencies": {
+ "@colors/colors": "1.6.0",
+ "@types/triple-beam": "^1.3.2",
+ "fecha": "^4.2.0",
+ "ms": "^2.1.1",
+ "safe-stable-stringify": "^2.3.1",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/logform/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/one-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
+ "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
+ "dependencies": {
+ "fn.name": "1.x.x"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/stack-trace": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
+ "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/text-hex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
+ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/triple-beam": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
+ "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/winston": {
+ "version": "3.19.0",
+ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
+ "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
+ "dependencies": {
+ "@colors/colors": "^1.6.0",
+ "@dabh/diagnostics": "^2.0.8",
+ "async": "^3.2.3",
+ "is-stream": "^2.0.0",
+ "logform": "^2.7.0",
+ "one-time": "^1.0.0",
+ "readable-stream": "^3.4.0",
+ "safe-stable-stringify": "^2.3.1",
+ "stack-trace": "0.0.x",
+ "triple-beam": "^1.3.0",
+ "winston-transport": "^4.9.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/winston-transport": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
+ "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
+ "dependencies": {
+ "logform": "^2.7.0",
+ "readable-stream": "^3.6.2",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/changemaker-control-panel/agent/package.json b/changemaker-control-panel/agent/package.json
new file mode 100644
index 00000000..09164a55
--- /dev/null
+++ b/changemaker-control-panel/agent/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "ccp-agent",
+ "version": "1.0.0",
+ "description": "Changemaker Control Panel — Remote Agent",
+ "main": "dist/server.js",
+ "scripts": {
+ "dev": "tsx watch src/server.ts",
+ "build": "tsc",
+ "start": "node dist/server.js",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-async-errors": "^3.1.1",
+ "winston": "^3.17.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.0.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/changemaker-control-panel/agent/src/config/env.ts b/changemaker-control-panel/agent/src/config/env.ts
new file mode 100644
index 00000000..a69ad6e5
--- /dev/null
+++ b/changemaker-control-panel/agent/src/config/env.ts
@@ -0,0 +1,43 @@
+import 'dotenv/config';
+import { z } from 'zod';
+
+const envSchema = z.object({
+ // Agent server
+ AGENT_PORT: z.coerce.number().default(7443),
+ AGENT_LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
+
+ // TLS certificates (required once approved)
+ AGENT_CERT_PATH: z.string().default('/etc/ccp-agent/agent.pem'),
+ AGENT_KEY_PATH: z.string().default('/etc/ccp-agent/agent.key'),
+ AGENT_CA_CERT_PATH: z.string().default('/etc/ccp-agent/ca.pem'),
+
+ // Allowed CCP fingerprints (comma-separated SHA-256 hex)
+ ALLOWED_CCP_FINGERPRINTS: z.string().default(''),
+
+ // Data directory (registry.json lives here)
+ AGENT_DATA_DIR: z.string().default('/var/lib/ccp-agent'),
+
+ // Phone-home registration (set during initial setup, cleared after approval)
+ CCP_URL: z.string().default(''),
+ CCP_INVITE_CODE: z.string().default(''),
+ CCP_AGENT_URL: z.string().default(''), // How CCP can reach this agent
+
+ // Instance info (for phone-home registration)
+ INSTANCE_SLUG: z.string().default(''),
+ INSTANCE_DOMAIN: z.string().default(''),
+ INSTANCE_BASE_PATH: z.string().default(''),
+});
+
+function validateEnv() {
+ const result = envSchema.safeParse(process.env);
+ if (!result.success) {
+ console.error('Invalid environment variables:');
+ for (const [key, errors] of Object.entries(result.error.flatten().fieldErrors)) {
+ console.error(` ${key}: ${errors?.join(', ')}`);
+ }
+ process.exit(1);
+ }
+ return result.data;
+}
+
+export const env = validateEnv();
diff --git a/changemaker-control-panel/agent/src/middleware/error-handler.ts b/changemaker-control-panel/agent/src/middleware/error-handler.ts
new file mode 100644
index 00000000..afd89b69
--- /dev/null
+++ b/changemaker-control-panel/agent/src/middleware/error-handler.ts
@@ -0,0 +1,25 @@
+import { Request, Response, NextFunction } from 'express';
+import { logger } from '../utils/logger';
+
+export class AgentError extends Error {
+ constructor(public statusCode: number, message: string, public code?: string) {
+ super(message);
+ this.name = 'AgentError';
+ }
+}
+
+export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
+ if (err instanceof AgentError) {
+ res.status(err.statusCode).json({
+ error: err.code || 'AGENT_ERROR',
+ message: err.message,
+ });
+ return;
+ }
+
+ logger.error(`Unhandled error: ${err.message}`);
+ res.status(500).json({
+ error: 'INTERNAL_ERROR',
+ message: 'An internal error occurred',
+ });
+}
diff --git a/changemaker-control-panel/agent/src/middleware/mtls-auth.ts b/changemaker-control-panel/agent/src/middleware/mtls-auth.ts
new file mode 100644
index 00000000..c1f3975b
--- /dev/null
+++ b/changemaker-control-panel/agent/src/middleware/mtls-auth.ts
@@ -0,0 +1,71 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import { Request, Response, NextFunction } from 'express';
+import { env } from '../config/env';
+import { logger } from '../utils/logger';
+import type { TLSSocket } from 'tls';
+
+/**
+ * Load allowed fingerprints from env var or from the auto-generated config file.
+ * The config file is written during phone-home cert installation.
+ */
+function loadAllowedFingerprints(): string[] {
+ // First check env var
+ if (env.ALLOWED_CCP_FINGERPRINTS) {
+ return env.ALLOWED_CCP_FINGERPRINTS.split(',').map((f) => f.trim().toLowerCase());
+ }
+
+ // Fall back to the auto-generated fingerprint file from phone-home registration
+ try {
+ const configPath = path.join(env.AGENT_DATA_DIR, 'ccp-fingerprint');
+ const fingerprint = fs.readFileSync(configPath, 'utf-8').trim().toLowerCase();
+ if (fingerprint) return [fingerprint];
+ } catch {
+ // No fingerprint file — fingerprint pinning not available
+ }
+
+ return [];
+}
+
+// Cache the fingerprints at startup — reload requires restart
+const allowedFingerprints = loadAllowedFingerprints();
+
+/**
+ * mTLS authentication middleware.
+ * Verifies that the connecting client presented a valid certificate
+ * signed by the trusted CA, and checks against allowed fingerprints.
+ */
+export function mtlsAuth(req: Request, res: Response, next: NextFunction) {
+ const socket = req.socket as TLSSocket;
+
+ // Check that the client presented a certificate and it was authorized by the TLS layer
+ if (!socket.authorized) {
+ const authError = socket.authorizationError;
+ logger.warn(`[mtls] Client certificate rejected: ${authError}`);
+ res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid client certificate' });
+ return;
+ }
+
+ const peerCert = socket.getPeerCertificate();
+ if (!peerCert || !peerCert.raw) {
+ logger.warn('[mtls] No peer certificate presented');
+ res.status(401).json({ error: 'UNAUTHORIZED', message: 'No client certificate' });
+ return;
+ }
+
+ // SECURITY: Check fingerprint against allowed list (env var or auto-generated file)
+ if (allowedFingerprints.length > 0) {
+ const fingerprint = crypto.createHash('sha256').update(peerCert.raw).digest('hex');
+ if (!allowedFingerprints.includes(fingerprint)) {
+ logger.warn(`[mtls] Client fingerprint ${fingerprint.substring(0, 16)}... not in allowed list`);
+ res.status(403).json({ error: 'FORBIDDEN', message: 'Client certificate not authorized' });
+ return;
+ }
+ } else {
+ // No fingerprint pinning configured — log a warning but allow (CA validation is still enforced)
+ logger.warn('[mtls] No fingerprint pinning configured — relying on CA chain validation only');
+ }
+
+ next();
+}
diff --git a/changemaker-control-panel/agent/src/routes/backup.routes.ts b/changemaker-control-panel/agent/src/routes/backup.routes.ts
new file mode 100644
index 00000000..7494fabb
--- /dev/null
+++ b/changemaker-control-panel/agent/src/routes/backup.routes.ts
@@ -0,0 +1,105 @@
+import { Router, Request, Response } from 'express';
+import { param } from '../utils/params';
+import fs from 'fs/promises';
+import path from 'path';
+import { exec as execCb } from 'child_process';
+import { promisify } from 'util';
+import * as docker from '../services/docker.service';
+import { getSlugEntry } from '../services/registry.service';
+import { env } from '../config/env';
+import { logger } from '../utils/logger';
+
+const exec = promisify(execCb);
+const router = Router();
+
+// POST /instance/:slug/backup — Run pg_dump + tar uploads → return backup info
+router.post('/instance/:slug/backup', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const backupDir = path.join(env.AGENT_DATA_DIR, 'backups', param(req, 'slug'), timestamp);
+ await fs.mkdir(backupDir, { recursive: true });
+
+ const { pgPassword } = req.body;
+
+ try {
+ // 1. pg_dump
+ const dumpFile = path.join(backupDir, 'database.sql');
+ const dump = await docker.composeExec(
+ entry.basePath, entry.composeProject,
+ 'v2-postgres',
+ 'pg_dump -U changemaker -d changemaker',
+ 300_000,
+ pgPassword ? { PGPASSWORD: pgPassword } : undefined
+ );
+ await fs.writeFile(dumpFile, dump, 'utf-8');
+
+ // Gzip the dump
+ await exec(`gzip '${dumpFile}'`, { timeout: 120_000 });
+
+ // 2. Tar uploads if exists
+ const uploadsDir = path.join(entry.basePath, 'uploads');
+ let hasUploads = false;
+ try {
+ await fs.access(uploadsDir);
+ hasUploads = true;
+ } catch { /* no uploads dir */ }
+
+ if (hasUploads) {
+ await exec(
+ `tar -czf '${path.join(backupDir, 'uploads.tar.gz')}' -C '${entry.basePath}' uploads`,
+ { timeout: 300_000 }
+ );
+ }
+
+ // 3. Create final archive
+ const archiveName = `backup-${param(req, 'slug')}-${timestamp}.tar.gz`;
+ const archivePath = path.join(env.AGENT_DATA_DIR, 'backups', archiveName);
+ await exec(
+ `tar -czf '${archivePath}' -C '${path.dirname(backupDir)}' '${timestamp}'`,
+ { timeout: 300_000 }
+ );
+
+ // Clean up temp dir
+ await fs.rm(backupDir, { recursive: true, force: true });
+
+ const stats = await fs.stat(archivePath);
+ const backupId = timestamp;
+
+ logger.info(`[backup] Created backup for ${param(req, 'slug')}: ${archivePath} (${stats.size} bytes)`);
+
+ res.json({
+ backupId,
+ archivePath,
+ sizeBytes: stats.size,
+ timestamp,
+ });
+ } catch (err) {
+ // Clean up on failure
+ try { await fs.rm(backupDir, { recursive: true, force: true }); } catch { /* ignore */ }
+ throw err;
+ }
+});
+
+// GET /instance/:slug/backup/:id/download — Stream backup archive
+router.get('/instance/:slug/backup/:id/download', async (req: Request, res: Response) => {
+ const archiveName = `backup-${param(req, 'slug')}-${param(req, 'id')}.tar.gz`;
+ const archivePath = path.join(env.AGENT_DATA_DIR, 'backups', archiveName);
+
+ try {
+ await fs.access(archivePath);
+ } catch {
+ res.status(404).json({ error: 'NOT_FOUND', message: 'Backup archive not found' });
+ return;
+ }
+
+ const stats = await fs.stat(archivePath);
+ res.setHeader('Content-Type', 'application/gzip');
+ res.setHeader('Content-Length', stats.size);
+ res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
+
+ const { createReadStream } = await import('fs');
+ const stream = createReadStream(archivePath);
+ stream.pipe(res);
+});
+
+export default router;
diff --git a/changemaker-control-panel/agent/src/routes/compose.routes.ts b/changemaker-control-panel/agent/src/routes/compose.routes.ts
new file mode 100644
index 00000000..42ab9248
--- /dev/null
+++ b/changemaker-control-panel/agent/src/routes/compose.routes.ts
@@ -0,0 +1,103 @@
+import { Router, Request, Response } from 'express';
+import * as docker from '../services/docker.service';
+import { getSlugEntry } from '../services/registry.service';
+import { param } from '../utils/params';
+
+const router = Router();
+
+// GET /instance/:slug/ps
+router.get('/instance/:slug/ps', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const containers = await docker.composePs(entry.basePath, entry.composeProject);
+ res.json(containers);
+});
+
+// GET /instance/:slug/logs
+router.get('/instance/:slug/logs', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const service = req.query.service as string | undefined;
+ const tail = req.query.tail ? Number(req.query.tail) : 200;
+ const since = req.query.since as string | undefined;
+ const logs = await docker.composeLogs(entry.basePath, entry.composeProject, service, tail, since);
+ res.json(logs);
+});
+
+// POST /instance/:slug/up
+router.post('/instance/:slug/up', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const services = req.body?.services as string[] | undefined;
+ const result = await docker.composeUp(entry.basePath, entry.composeProject, services);
+ res.json(result);
+});
+
+// POST /instance/:slug/stop
+router.post('/instance/:slug/stop', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const result = await docker.composeStop(entry.basePath, entry.composeProject);
+ res.json(result);
+});
+
+// POST /instance/:slug/restart
+router.post('/instance/:slug/restart', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const service = req.body?.service as string | undefined;
+ const result = await docker.composeRestart(entry.basePath, entry.composeProject, service);
+ res.json(result);
+});
+
+// POST /instance/:slug/down
+router.post('/instance/:slug/down', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const removeVolumes = req.body?.removeVolumes === true;
+ const result = await docker.composeDown(entry.basePath, entry.composeProject, removeVolumes);
+ res.json(result);
+});
+
+// POST /instance/:slug/pull
+router.post('/instance/:slug/pull', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const result = await docker.composePull(entry.basePath, entry.composeProject);
+ res.json(result);
+});
+
+// POST /instance/:slug/build
+router.post('/instance/:slug/build', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const result = await docker.composeBuild(entry.basePath, entry.composeProject);
+ res.json(result);
+});
+
+// POST /instance/:slug/exec
+router.post('/instance/:slug/exec', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const { service, command, envVars } = req.body;
+ if (!service || !command) {
+ res.status(400).json({ error: 'VALIDATION', message: 'service and command are required' });
+ return;
+ }
+
+ // SECURITY: Reject shell metacharacters entirely — prevents `;`, `&&`, `|`, `$()`, backticks
+ const SHELL_META = /[;&|`$(){}!><\n\r]/;
+ if (SHELL_META.test(command)) {
+ res.status(403).json({ error: 'FORBIDDEN', message: 'Command contains disallowed characters' });
+ return;
+ }
+
+ // Command allowlist — only allow known safe command prefixes
+ const allowedPatterns = [
+ /^pg_dump\b/,
+ /^npx\s+prisma\b/,
+ /^cat\s/,
+ /^ls\b/,
+ /^echo\b/,
+ ];
+ if (!allowedPatterns.some((p) => p.test(command))) {
+ res.status(403).json({ error: 'FORBIDDEN', message: 'Command not in allowlist' });
+ return;
+ }
+
+ const result = await docker.composeExec(entry.basePath, entry.composeProject, service, command, undefined, envVars);
+ res.json(result);
+});
+
+export default router;
diff --git a/changemaker-control-panel/agent/src/routes/files.routes.ts b/changemaker-control-panel/agent/src/routes/files.routes.ts
new file mode 100644
index 00000000..8406bd37
--- /dev/null
+++ b/changemaker-control-panel/agent/src/routes/files.routes.ts
@@ -0,0 +1,51 @@
+import { Router, Request, Response } from 'express';
+import { getSlugEntry } from '../services/registry.service';
+import { param } from '../utils/params';
+import * as fileService from '../services/file.service';
+
+const router = Router();
+
+// GET /instance/:slug/env — Read .env as key/value map
+router.get('/instance/:slug/env', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const envVars = await fileService.readEnvFile(entry.basePath);
+ res.json(envVars);
+});
+
+// POST /instance/:slug/files — Write rendered template files
+router.post('/instance/:slug/files', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const { files } = req.body;
+ if (!Array.isArray(files)) {
+ res.status(400).json({ error: 'VALIDATION', message: 'files array required' });
+ return;
+ }
+ await fileService.writeFiles(entry.basePath, files);
+ res.json({ written: files.length });
+});
+
+// POST /instance/:slug/mkdir — Create directory
+router.post('/instance/:slug/mkdir', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const { path: dirPath } = req.body;
+ if (!dirPath) {
+ res.status(400).json({ error: 'VALIDATION', message: 'path required' });
+ return;
+ }
+ await fileService.mkdirp(entry.basePath, dirPath);
+ res.json({ created: dirPath });
+});
+
+// POST /instance/:slug/clone-source — Git clone CML source
+router.post('/instance/:slug/clone-source', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const { gitRepo, gitBranch, excludes } = req.body;
+ if (!gitRepo || !gitBranch) {
+ res.status(400).json({ error: 'VALIDATION', message: 'gitRepo and gitBranch required' });
+ return;
+ }
+ await fileService.cloneSource(entry.basePath, gitRepo, gitBranch, excludes);
+ res.json({ cloned: true });
+});
+
+export default router;
diff --git a/changemaker-control-panel/agent/src/routes/health.routes.ts b/changemaker-control-panel/agent/src/routes/health.routes.ts
new file mode 100644
index 00000000..c009ff0d
--- /dev/null
+++ b/changemaker-control-panel/agent/src/routes/health.routes.ts
@@ -0,0 +1,15 @@
+import { Router } from 'express';
+
+const router = Router();
+const startedAt = Date.now();
+const VERSION = '1.0.0';
+
+router.get('/health', (_req, res) => {
+ res.json({
+ status: 'ok',
+ version: VERSION,
+ uptime: Math.floor((Date.now() - startedAt) / 1000),
+ });
+});
+
+export default router;
diff --git a/changemaker-control-panel/agent/src/routes/registry.routes.ts b/changemaker-control-panel/agent/src/routes/registry.routes.ts
new file mode 100644
index 00000000..157921ab
--- /dev/null
+++ b/changemaker-control-panel/agent/src/routes/registry.routes.ts
@@ -0,0 +1,30 @@
+import { Router, Request, Response } from 'express';
+import { param } from '../utils/params';
+import { registerSlug, unregisterSlug, listSlugs } from '../services/registry.service';
+
+const router = Router();
+
+// POST /instances/register — Register a slug→basePath mapping
+router.post('/instances/register', async (req: Request, res: Response) => {
+ const { slug, basePath, composeProject } = req.body;
+ if (!slug || !basePath || !composeProject) {
+ res.status(400).json({ error: 'VALIDATION', message: 'slug, basePath, and composeProject required' });
+ return;
+ }
+ await registerSlug(slug, basePath, composeProject);
+ res.json({ registered: slug });
+});
+
+// DELETE /instances/:slug — Unregister slug
+router.delete('/instances/:slug', async (req: Request, res: Response) => {
+ await unregisterSlug(param(req, 'slug'));
+ res.json({ unregistered: param(req, 'slug') });
+});
+
+// GET /instances — List all managed slugs
+router.get('/instances', async (_req: Request, res: Response) => {
+ const slugs = await listSlugs();
+ res.json(slugs);
+});
+
+export default router;
diff --git a/changemaker-control-panel/agent/src/routes/upgrade.routes.ts b/changemaker-control-panel/agent/src/routes/upgrade.routes.ts
new file mode 100644
index 00000000..90bc1cdf
--- /dev/null
+++ b/changemaker-control-panel/agent/src/routes/upgrade.routes.ts
@@ -0,0 +1,79 @@
+import { Router, Request, Response } from 'express';
+import { param } from '../utils/params';
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import fs from 'fs/promises';
+import path from 'path';
+import { getSlugEntry } from '../services/registry.service';
+import { logger } from '../utils/logger';
+
+const execFileAsync = promisify(execFile);
+const router = Router();
+
+/** Validate a git branch name — prevent shell injection. */
+const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
+
+// POST /instance/:slug/upgrade/start — Run upgrade.sh
+router.post('/instance/:slug/upgrade/start', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const { skipBackup, useRegistry, branch } = req.body || {};
+
+ // SECURITY: Validate branch name to prevent injection
+ if (branch && !SAFE_BRANCH.test(branch)) {
+ res.status(400).json({ error: 'VALIDATION', message: 'Invalid branch name' });
+ return;
+ }
+
+ const scriptPath = path.join(entry.basePath, 'scripts', 'upgrade.sh');
+ try {
+ await fs.access(scriptPath);
+ } catch {
+ res.status(400).json({ error: 'NOT_FOUND', message: 'upgrade.sh not found' });
+ return;
+ }
+
+ // SECURITY: Use execFile with args array — no shell interpolation
+ const args = ['--api-mode', '--force'];
+ if (skipBackup) args.push('--skip-backup');
+ if (useRegistry) args.push('--use-registry');
+ if (branch) args.push('--branch', branch);
+
+ // Fire-and-forget — CCP polls progress
+ execFileAsync('bash', [scriptPath, ...args], {
+ cwd: entry.basePath,
+ timeout: 600_000,
+ maxBuffer: 10 * 1024 * 1024,
+ }).catch((err) => {
+ logger.error(`[upgrade] ${param(req, 'slug')} failed: ${(err as Error).message}`);
+ });
+
+ res.json({ started: true });
+});
+
+// GET /instance/:slug/upgrade/progress — Read progress.json
+router.get('/instance/:slug/upgrade/progress', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const progressPath = path.join(entry.basePath, 'data', 'upgrade', 'progress.json');
+
+ try {
+ const content = await fs.readFile(progressPath, 'utf-8');
+ res.json(JSON.parse(content));
+ } catch {
+ res.json({ phase: 0, percentage: 0, message: 'Waiting for upgrade to start...' });
+ }
+});
+
+// GET /instance/:slug/upgrade/result — Read result.json
+router.get('/instance/:slug/upgrade/result', async (req: Request, res: Response) => {
+ const entry = await getSlugEntry(param(req, 'slug'));
+ const resultPath = path.join(entry.basePath, 'data', 'upgrade', 'result.json');
+
+ try {
+ const content = await fs.readFile(resultPath, 'utf-8');
+ res.json(JSON.parse(content));
+ } catch {
+ res.status(404).json({ error: 'NOT_FOUND', message: 'No upgrade result available' });
+ }
+});
+
+export default router;
diff --git a/changemaker-control-panel/agent/src/server.ts b/changemaker-control-panel/agent/src/server.ts
new file mode 100644
index 00000000..c6b3c811
--- /dev/null
+++ b/changemaker-control-panel/agent/src/server.ts
@@ -0,0 +1,159 @@
+import 'express-async-errors';
+import express from 'express';
+import https from 'https';
+import http from 'http';
+import fs from 'fs';
+import { env } from './config/env';
+import { logger } from './utils/logger';
+import { mtlsAuth } from './middleware/mtls-auth';
+import { errorHandler } from './middleware/error-handler';
+import healthRoutes from './routes/health.routes';
+import composeRoutes from './routes/compose.routes';
+import filesRoutes from './routes/files.routes';
+import registryRoutes from './routes/registry.routes';
+import backupRoutes from './routes/backup.routes';
+import upgradeRoutes from './routes/upgrade.routes';
+
+const app = express();
+
+// Parse JSON bodies (up to 50MB for template file uploads)
+app.use(express.json({ limit: '50mb' }));
+
+// Health endpoint is always accessible (no mTLS required)
+app.use(healthRoutes);
+
+// All other routes require mTLS authentication
+function hasCerts(): boolean {
+ try {
+ fs.accessSync(env.AGENT_CERT_PATH);
+ fs.accessSync(env.AGENT_KEY_PATH);
+ fs.accessSync(env.AGENT_CA_CERT_PATH);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+if (hasCerts()) {
+ // mTLS mode — certificates are installed
+ const tlsOptions: https.ServerOptions = {
+ key: fs.readFileSync(env.AGENT_KEY_PATH),
+ cert: fs.readFileSync(env.AGENT_CERT_PATH),
+ ca: fs.readFileSync(env.AGENT_CA_CERT_PATH),
+ requestCert: true,
+ rejectUnauthorized: true,
+ };
+
+ app.use(mtlsAuth);
+ app.use(composeRoutes);
+ app.use(filesRoutes);
+ app.use(registryRoutes);
+ app.use(backupRoutes);
+ app.use(upgradeRoutes);
+ app.use(errorHandler);
+
+ const server = https.createServer(tlsOptions, app);
+ server.listen(env.AGENT_PORT, () => {
+ logger.info(`CCP Agent (mTLS) listening on port ${env.AGENT_PORT}`);
+ });
+} else {
+ // Pre-approval mode — start HTTP, only health + phone-home polling
+ logger.info('No certificates found — starting in phone-home registration mode');
+
+ app.use(errorHandler);
+
+ const server = http.createServer(app);
+ server.listen(env.AGENT_PORT, () => {
+ logger.info(`CCP Agent (registration mode) listening on port ${env.AGENT_PORT}`);
+ });
+
+ // Start phone-home polling if CCP_URL and CCP_INVITE_CODE are set
+ if (env.CCP_URL && env.CCP_INVITE_CODE) {
+ startPhoneHome();
+ }
+}
+
+/**
+ * Phone-home registration flow:
+ * 1. POST to CCP with invite code + instance metadata
+ * 2. Poll CCP every 30s until approved
+ * 3. On approval, save certs and restart with mTLS
+ */
+async function startPhoneHome() {
+ logger.info(`[phone-home] Registering with CCP at ${env.CCP_URL}...`);
+
+ // Step 1: Send registration request
+ try {
+ const response = await fetch(`${env.CCP_URL}/api/agents/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ inviteCode: env.CCP_INVITE_CODE,
+ slug: env.INSTANCE_SLUG,
+ name: env.INSTANCE_SLUG,
+ domain: env.INSTANCE_DOMAIN,
+ agentUrl: env.CCP_AGENT_URL,
+ basePath: env.INSTANCE_BASE_PATH,
+ }),
+ });
+
+ if (!response.ok) {
+ const err = await response.text();
+ logger.error(`[phone-home] Registration failed: ${response.status} ${err}`);
+ return;
+ }
+
+ const result = await response.json() as { registrationId: string };
+ logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
+
+ // Step 2: Poll for approval
+ const pollInterval = setInterval(async () => {
+ try {
+ const pollResp = await fetch(
+ `${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
+ );
+
+ if (!pollResp.ok) return;
+
+ const pollData = await pollResp.json() as {
+ status: string;
+ certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
+ };
+
+ if (pollData.status === 'APPROVED' && pollData.certBundle) {
+ clearInterval(pollInterval);
+ logger.info('[phone-home] Approved! Saving certificates...');
+
+ // Save certs
+ const fsp = await import('fs/promises');
+ const pathMod = await import('path');
+ await fsp.mkdir(pathMod.dirname(env.AGENT_CERT_PATH), { recursive: true });
+ await fsp.writeFile(env.AGENT_CERT_PATH, pollData.certBundle.agentCertPem);
+ await fsp.writeFile(env.AGENT_KEY_PATH, pollData.certBundle.agentKeyPem);
+ await fsp.writeFile(env.AGENT_CA_CERT_PATH, pollData.certBundle.caCertPem);
+
+ // SECURITY: Write the CCP fingerprint to a config file so the agent
+ // can verify the CCP's identity on subsequent connections.
+ if (pollData.certBundle.ccpFingerprint) {
+ const configPath = pathMod.join(env.AGENT_DATA_DIR, 'ccp-fingerprint');
+ await fsp.mkdir(env.AGENT_DATA_DIR, { recursive: true });
+ await fsp.writeFile(configPath, pollData.certBundle.ccpFingerprint);
+ logger.info(`[phone-home] CCP fingerprint saved: ${pollData.certBundle.ccpFingerprint.substring(0, 16)}...`);
+ }
+
+ logger.info('[phone-home] Certificates saved. Restarting with mTLS...');
+
+ // Exit so Docker restart policy brings us back with certs
+ process.exit(0);
+ } else if (pollData.status === 'REJECTED') {
+ clearInterval(pollInterval);
+ logger.error('[phone-home] Registration was rejected by CCP admin');
+ }
+ } catch (err) {
+ logger.warn(`[phone-home] Poll failed: ${(err as Error).message}`);
+ }
+ }, 30_000);
+ } catch (err) {
+ logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
+ }
+}
diff --git a/changemaker-control-panel/agent/src/services/docker.service.ts b/changemaker-control-panel/agent/src/services/docker.service.ts
new file mode 100644
index 00000000..1da7cc18
--- /dev/null
+++ b/changemaker-control-panel/agent/src/services/docker.service.ts
@@ -0,0 +1,134 @@
+import { exec as execCb } from 'child_process';
+import { promisify } from 'util';
+import { logger } from '../utils/logger';
+
+const exec = promisify(execCb);
+
+const EXEC_TIMEOUT = 120_000;
+
+function validateName(name: string, label: string): string {
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
+ throw new Error(`Invalid ${label}: ${name}`);
+ }
+ return name;
+}
+
+function validateDuration(value: string): string {
+ if (!/^\d+[smhd]$/.test(value)) throw new Error(`Invalid duration: ${value}`);
+ return value;
+}
+
+function validateTail(value: number): number {
+ return Math.floor(Math.max(1, Math.min(value, 5000)));
+}
+
+export interface ContainerInfo {
+ name: string;
+ service: string;
+ status: string;
+ state: string;
+ health: string;
+ ports: string;
+ createdAt: string;
+ exitCode: number;
+}
+
+async function execCmd(command: string, cwd: string, timeoutMs = EXEC_TIMEOUT) {
+ logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`);
+ try {
+ return await exec(command, {
+ cwd,
+ timeout: timeoutMs,
+ maxBuffer: 10 * 1024 * 1024,
+ env: { ...process.env, COMPOSE_ANSI: 'never' },
+ });
+ } catch (err: unknown) {
+ const error = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
+ if (error.killed) throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`);
+ throw new Error(`Command failed: ${command}\n${error.stderr || error.message}`);
+ }
+}
+
+function composeCmd(project: string): string {
+ return `docker compose -p ${validateName(project, 'project')}`;
+}
+
+export async function composeUp(projectDir: string, project: string, services?: string[]) {
+ const svc = services?.length ? ` ${services.map((s) => validateName(s, 'service')).join(' ')}` : '';
+ const orphanFlag = services?.length ? '' : ' --remove-orphans';
+ const { stdout, stderr } = await execCmd(`${composeCmd(project)} up -d${orphanFlag}${svc}`, projectDir);
+ return stdout || stderr;
+}
+
+export async function composeDown(projectDir: string, project: string, removeVolumes = false) {
+ const flags = removeVolumes ? ' -v' : '';
+ const { stdout, stderr } = await execCmd(`${composeCmd(project)} down${flags}`, projectDir);
+ return stdout || stderr;
+}
+
+export async function composeStop(projectDir: string, project: string) {
+ const { stdout, stderr } = await execCmd(`${composeCmd(project)} stop`, projectDir);
+ return stdout || stderr;
+}
+
+export async function composeRestart(projectDir: string, project: string, service?: string) {
+ const svc = service ? ` ${validateName(service, 'service')}` : '';
+ const { stdout, stderr } = await execCmd(`${composeCmd(project)} restart${svc}`, projectDir);
+ return stdout || stderr;
+}
+
+export async function composePull(projectDir: string, project: string) {
+ const { stdout, stderr } = await execCmd(`${composeCmd(project)} pull`, projectDir, 300_000);
+ return stdout || stderr;
+}
+
+export async function composeBuild(projectDir: string, project: string) {
+ const { stdout, stderr } = await execCmd(`${composeCmd(project)} build`, projectDir, 600_000);
+ return stdout || stderr;
+}
+
+export async function composePs(projectDir: string, project: string): Promise {
+ const { stdout } = await execCmd(`${composeCmd(project)} ps --format json`, projectDir);
+ if (!stdout.trim()) return [];
+ const containers: ContainerInfo[] = [];
+ for (const line of stdout.trim().split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const raw = JSON.parse(line);
+ containers.push({
+ name: raw.Name || raw.name || '',
+ service: raw.Service || raw.service || '',
+ status: raw.Status || raw.status || '',
+ state: raw.State || raw.state || '',
+ health: raw.Health || raw.health || '',
+ ports: raw.Ports || raw.ports || '',
+ createdAt: raw.CreatedAt || raw.created_at || '',
+ exitCode: raw.ExitCode ?? raw.exit_code ?? 0,
+ });
+ } catch { /* skip */ }
+ }
+ return containers;
+}
+
+export async function composeLogs(projectDir: string, project: string, service?: string, tail = 200, since?: string) {
+ const parts = [composeCmd(project), 'logs', '--no-color'];
+ if (tail > 0) parts.push(`--tail=${validateTail(tail)}`);
+ if (since) parts.push(`--since=${validateDuration(since)}`);
+ if (service) parts.push(validateName(service, 'service'));
+ const { stdout, stderr } = await execCmd(parts.join(' '), projectDir);
+ return stdout || stderr;
+}
+
+export async function composeExec(
+ projectDir: string, project: string, service: string,
+ command: string, timeoutMs = EXEC_TIMEOUT, envVars?: Record
+) {
+ const envFlags = envVars
+ ? Object.entries(envVars).map(([k, v]) => `-e ${k}='${v.replace(/'/g, "'\\''")}'`).join(' ') + ' '
+ : '';
+ const { stdout, stderr } = await execCmd(
+ `${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`,
+ projectDir, timeoutMs
+ );
+ return stdout || stderr;
+}
diff --git a/changemaker-control-panel/agent/src/services/file.service.ts b/changemaker-control-panel/agent/src/services/file.service.ts
new file mode 100644
index 00000000..9cfe9d75
--- /dev/null
+++ b/changemaker-control-panel/agent/src/services/file.service.ts
@@ -0,0 +1,104 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { parse as parseDotenv } from 'dotenv';
+import { AgentError } from '../middleware/error-handler';
+import { logger } from '../utils/logger';
+
+/**
+ * Validate that a resolved path is within the allowed basePath.
+ * Prevents path traversal attacks.
+ */
+function assertWithin(filePath: string, basePath: string): void {
+ const resolvedFile = path.resolve(filePath);
+ const resolvedBase = path.resolve(basePath);
+ if (!resolvedFile.startsWith(resolvedBase + '/') && resolvedFile !== resolvedBase) {
+ throw new AgentError(403, `Path ${filePath} is outside allowed directory`, 'PATH_TRAVERSAL');
+ }
+}
+
+export async function readEnvFile(basePath: string): Promise> {
+ const envPath = path.join(basePath, '.env');
+ const content = await fs.readFile(envPath, 'utf-8');
+ return parseDotenv(Buffer.from(content));
+}
+
+export async function writeFiles(
+ basePath: string,
+ files: Array<{ relativePath: string; content: string }>
+): Promise {
+ for (const file of files) {
+ const filePath = path.join(basePath, file.relativePath);
+ assertWithin(filePath, basePath);
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, file.content, 'utf-8');
+ logger.debug(`[files] Wrote ${filePath}`);
+ }
+}
+
+export async function mkdirp(basePath: string, relativePath: string): Promise {
+ const dirPath = path.join(basePath, relativePath);
+ assertWithin(dirPath, basePath);
+ await fs.mkdir(dirPath, { recursive: true });
+}
+
+/** Validate git repo URL and branch name to prevent shell injection. */
+const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
+const SAFE_REPO = /^[a-zA-Z0-9@:._\/-]+$/;
+const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;
+
+export async function cloneSource(
+ basePath: string,
+ gitRepo: string,
+ gitBranch: string,
+ excludes?: string[]
+): Promise {
+ // SECURITY: Validate inputs before any shell execution
+ if (!SAFE_REPO.test(gitRepo)) {
+ throw new AgentError(400, 'Invalid git repository URL', 'VALIDATION');
+ }
+ if (!SAFE_BRANCH.test(gitBranch)) {
+ throw new AgentError(400, 'Invalid git branch name', 'VALIDATION');
+ }
+
+ const { execFile } = await import('child_process');
+ const { promisify } = await import('util');
+ const execFileAsync = promisify(execFile);
+
+ // Ensure base directory exists
+ await fs.mkdir(basePath, { recursive: true });
+
+ // Clone into a temp directory first, then move contents
+ const tmpDir = `${basePath}.tmp-${Date.now()}`;
+ try {
+ // SECURITY: Use execFile with args array — no shell interpolation
+ await execFileAsync('git', ['clone', '--branch', gitBranch, '--depth', '1', gitRepo, tmpDir], {
+ timeout: 300_000,
+ });
+
+ // Remove git metadata and excluded directories
+ const defaultExcludes = excludes || [
+ '.git', 'node_modules', 'changemaker-control-panel', '.claude',
+ 'api/dist', 'admin/dist',
+ ];
+ for (const exclude of defaultExcludes) {
+ // SECURITY: Validate each exclude entry
+ if (!SAFE_EXCLUDE.test(exclude)) continue;
+ const excludePath = path.join(tmpDir, exclude);
+ // SECURITY: Verify exclude path is within tmpDir
+ if (!path.resolve(excludePath).startsWith(path.resolve(tmpDir) + '/')) continue;
+ try {
+ await fs.rm(excludePath, { recursive: true, force: true });
+ } catch { /* ignore if doesn't exist */ }
+ }
+
+ // Move contents to basePath using execFile (no shell)
+ await execFileAsync('rsync', ['-a', `${tmpDir}/`, `${basePath}/`], { timeout: 120_000 });
+ await fs.rm(tmpDir, { recursive: true, force: true });
+
+ logger.info(`[files] Cloned ${gitRepo}@${gitBranch} → ${basePath}`);
+ } catch (err) {
+ // Clean up temp dir on failure
+ try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
+ throw err;
+ }
+}
diff --git a/changemaker-control-panel/agent/src/services/registry.service.ts b/changemaker-control-panel/agent/src/services/registry.service.ts
new file mode 100644
index 00000000..2aaa883c
--- /dev/null
+++ b/changemaker-control-panel/agent/src/services/registry.service.ts
@@ -0,0 +1,69 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { env } from '../config/env';
+import { logger } from '../utils/logger';
+import { AgentError } from '../middleware/error-handler';
+
+interface SlugEntry {
+ basePath: string;
+ composeProject: string;
+ registeredAt: string;
+}
+
+type Registry = Record;
+
+const registryPath = () => path.join(env.AGENT_DATA_DIR, 'registry.json');
+
+let cache: Registry | null = null;
+
+async function loadRegistry(): Promise {
+ if (cache) return cache;
+ try {
+ const data = await fs.readFile(registryPath(), 'utf-8');
+ cache = JSON.parse(data) as Registry;
+ return cache;
+ } catch {
+ cache = {};
+ return cache;
+ }
+}
+
+async function saveRegistry(registry: Registry): Promise {
+ await fs.mkdir(env.AGENT_DATA_DIR, { recursive: true });
+ await fs.writeFile(registryPath(), JSON.stringify(registry, null, 2), 'utf-8');
+ cache = registry;
+}
+
+export async function registerSlug(slug: string, basePath: string, composeProject: string): Promise {
+ const registry = await loadRegistry();
+ registry[slug] = {
+ basePath,
+ composeProject,
+ registeredAt: new Date().toISOString(),
+ };
+ await saveRegistry(registry);
+ logger.info(`[registry] Registered slug ${slug} → ${basePath} (project: ${composeProject})`);
+}
+
+export async function unregisterSlug(slug: string): Promise {
+ const registry = await loadRegistry();
+ if (!registry[slug]) {
+ throw new AgentError(404, `Slug ${slug} not registered`);
+ }
+ delete registry[slug];
+ await saveRegistry(registry);
+ logger.info(`[registry] Unregistered slug ${slug}`);
+}
+
+export async function getSlugEntry(slug: string): Promise {
+ const registry = await loadRegistry();
+ const entry = registry[slug];
+ if (!entry) {
+ throw new AgentError(404, `Slug ${slug} not registered`, 'SLUG_NOT_FOUND');
+ }
+ return entry;
+}
+
+export async function listSlugs(): Promise> {
+ return loadRegistry();
+}
diff --git a/changemaker-control-panel/agent/src/utils/logger.ts b/changemaker-control-panel/agent/src/utils/logger.ts
new file mode 100644
index 00000000..7108dd78
--- /dev/null
+++ b/changemaker-control-panel/agent/src/utils/logger.ts
@@ -0,0 +1,12 @@
+import winston from 'winston';
+import { env } from '../config/env';
+
+export const logger = winston.createLogger({
+ level: env.AGENT_LOG_LEVEL,
+ format: winston.format.combine(
+ winston.format.timestamp(),
+ winston.format.colorize(),
+ winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`)
+ ),
+ transports: [new winston.transports.Console()],
+});
diff --git a/changemaker-control-panel/agent/src/utils/params.ts b/changemaker-control-panel/agent/src/utils/params.ts
new file mode 100644
index 00000000..930ec38f
--- /dev/null
+++ b/changemaker-control-panel/agent/src/utils/params.ts
@@ -0,0 +1,11 @@
+import { Request } from 'express';
+
+/**
+ * Extract a route parameter as a string.
+ * Express 5 types params as string | string[]; this helper narrows it.
+ */
+export function param(req: Request, name: string): string {
+ const val = req.params[name];
+ if (Array.isArray(val)) return val[0];
+ return val;
+}
diff --git a/changemaker-control-panel/agent/tsconfig.json b/changemaker-control-panel/agent/tsconfig.json
new file mode 100644
index 00000000..0a6e9e4f
--- /dev/null
+++ b/changemaker-control-panel/agent/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "commonjs",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/changemaker-control-panel/api/prisma/migrations/20260407193653_add_enable_analytics/migration.sql b/changemaker-control-panel/api/prisma/migrations/20260407193653_add_enable_analytics/migration.sql
new file mode 100644
index 00000000..ba9cd0b3
--- /dev/null
+++ b/changemaker-control-panel/api/prisma/migrations/20260407193653_add_enable_analytics/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "instances" ADD COLUMN "enable_analytics" BOOLEAN NOT NULL DEFAULT false;
diff --git a/changemaker-control-panel/api/prisma/migrations/20260407203022_add_remote_agent_support/migration.sql b/changemaker-control-panel/api/prisma/migrations/20260407203022_add_remote_agent_support/migration.sql
new file mode 100644
index 00000000..fe28a73c
--- /dev/null
+++ b/changemaker-control-panel/api/prisma/migrations/20260407203022_add_remote_agent_support/migration.sql
@@ -0,0 +1,111 @@
+-- CreateEnum
+CREATE TYPE "AgentRegistrationStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'EXPIRED');
+
+-- AlterEnum
+-- This migration adds more than one value to an enum.
+-- With PostgreSQL versions 11 and earlier, this is not possible
+-- in a single migration. This can be worked around by creating
+-- multiple migrations, each migration adding only one value to
+-- the enum.
+
+
+ALTER TYPE "AuditAction" ADD VALUE 'AGENT_CONNECT';
+ALTER TYPE "AuditAction" ADD VALUE 'AGENT_REGISTER';
+ALTER TYPE "AuditAction" ADD VALUE 'AGENT_APPROVE';
+ALTER TYPE "AuditAction" ADD VALUE 'AGENT_REJECT';
+ALTER TYPE "AuditAction" ADD VALUE 'INVITE_CREATE';
+ALTER TYPE "AuditAction" ADD VALUE 'INVITE_REVOKE';
+ALTER TYPE "AuditAction" ADD VALUE 'CERT_ISSUE';
+ALTER TYPE "AuditAction" ADD VALUE 'CERT_REVOKE';
+
+-- AlterTable
+ALTER TABLE "instances" ADD COLUMN "agent_fingerprint" TEXT,
+ADD COLUMN "agent_last_seen" TIMESTAMP(3),
+ADD COLUMN "agent_url" TEXT,
+ADD COLUMN "agent_version" TEXT,
+ADD COLUMN "is_remote" BOOLEAN NOT NULL DEFAULT false;
+
+-- CreateTable
+CREATE TABLE "ccp_certificate_authority" (
+ "id" TEXT NOT NULL,
+ "common_name" TEXT NOT NULL,
+ "encrypted_key" TEXT NOT NULL,
+ "cert_pem" TEXT NOT NULL,
+ "fingerprint" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ccp_certificate_authority_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "issued_agent_certs" (
+ "id" TEXT NOT NULL,
+ "ca_id" TEXT NOT NULL,
+ "instance_id" TEXT NOT NULL,
+ "common_name" TEXT NOT NULL,
+ "encrypted_key" TEXT NOT NULL,
+ "cert_pem" TEXT NOT NULL,
+ "fingerprint" TEXT NOT NULL,
+ "issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "revoked_at" TIMESTAMP(3),
+
+ CONSTRAINT "issued_agent_certs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "agent_invite_codes" (
+ "id" TEXT NOT NULL,
+ "code" TEXT NOT NULL,
+ "created_by_id" TEXT NOT NULL,
+ "used_by_id" TEXT,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "used_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "agent_invite_codes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "agent_registrations" (
+ "id" TEXT NOT NULL,
+ "invite_code_id" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "domain" TEXT NOT NULL,
+ "agent_url" TEXT NOT NULL,
+ "base_path" TEXT NOT NULL,
+ "compose_project" TEXT NOT NULL,
+ "metadata" JSONB,
+ "status" "AgentRegistrationStatus" NOT NULL DEFAULT 'PENDING',
+ "instance_id" TEXT,
+ "approved_by_id" TEXT,
+ "approved_at" TIMESTAMP(3),
+ "rejected_at" TIMESTAMP(3),
+ "cert_bundle" JSONB,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "agent_registrations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "issued_agent_certs_instance_id_key" ON "issued_agent_certs"("instance_id");
+
+-- CreateIndex
+CREATE INDEX "issued_agent_certs_instance_id_idx" ON "issued_agent_certs"("instance_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "agent_invite_codes_code_key" ON "agent_invite_codes"("code");
+
+-- CreateIndex
+CREATE INDEX "agent_registrations_status_idx" ON "agent_registrations"("status");
+
+-- AddForeignKey
+ALTER TABLE "issued_agent_certs" ADD CONSTRAINT "issued_agent_certs_ca_id_fkey" FOREIGN KEY ("ca_id") REFERENCES "ccp_certificate_authority"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "issued_agent_certs" ADD CONSTRAINT "issued_agent_certs_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "agent_invite_codes" ADD CONSTRAINT "agent_invite_codes_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "ccp_users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/changemaker-control-panel/api/prisma/schema.prisma b/changemaker-control-panel/api/prisma/schema.prisma
index 9972f42b..8a81d33b 100644
--- a/changemaker-control-panel/api/prisma/schema.prisma
+++ b/changemaker-control-panel/api/prisma/schema.prisma
@@ -28,6 +28,7 @@ model CcpUser {
auditLogs AuditLog[]
triggeredUpgrades InstanceUpgrade[]
acknowledgedEvents InstanceEvent[]
+ agentInviteCodes AgentInviteCode[]
@@map("ccp_users")
}
@@ -78,6 +79,13 @@ model Instance {
// True if this instance was registered externally (not provisioned by CCP)
isRegistered Boolean @default(false) @map("is_registered")
+ // Remote agent management
+ isRemote Boolean @default(false) @map("is_remote")
+ agentUrl String? @map("agent_url")
+ agentFingerprint String? @map("agent_fingerprint")
+ agentVersion String? @map("agent_version")
+ agentLastSeen DateTime? @map("agent_last_seen")
+
// Feature flags
enableMedia Boolean @default(false) @map("enable_media")
enableChat Boolean @default(false) @map("enable_chat")
@@ -120,6 +128,7 @@ model Instance {
auditLogs AuditLog[]
upgrades InstanceUpgrade[]
events InstanceEvent[]
+ agentCert IssuedAgentCert?
@@map("instances")
}
@@ -208,6 +217,14 @@ enum AuditAction {
BACKUP_DELETE
PANGOLIN_SETUP
PANGOLIN_SYNC
+ AGENT_CONNECT
+ AGENT_REGISTER
+ AGENT_APPROVE
+ AGENT_REJECT
+ INVITE_CREATE
+ INVITE_REVOKE
+ CERT_ISSUE
+ CERT_REVOKE
USER_LOGIN
USER_CREATE
USER_UPDATE
@@ -313,3 +330,81 @@ model CcpSetting {
@@map("ccp_settings")
}
+
+// ─── Remote Agent Management ──────────────────────────────
+
+model CcpCertificateAuthority {
+ id String @id @default(uuid())
+ commonName String @map("common_name")
+ encryptedKey String @map("encrypted_key")
+ certPem String @map("cert_pem")
+ fingerprint String
+ createdAt DateTime @default(now()) @map("created_at")
+ expiresAt DateTime @map("expires_at")
+
+ issuedCerts IssuedAgentCert[]
+
+ @@map("ccp_certificate_authority")
+}
+
+model IssuedAgentCert {
+ id String @id @default(uuid())
+ caId String @map("ca_id")
+ instanceId String @unique @map("instance_id")
+ commonName String @map("common_name")
+ encryptedKey String @map("encrypted_key")
+ certPem String @map("cert_pem")
+ fingerprint String
+ issuedAt DateTime @default(now()) @map("issued_at")
+ expiresAt DateTime @map("expires_at")
+ revokedAt DateTime? @map("revoked_at")
+
+ ca CcpCertificateAuthority @relation(fields: [caId], references: [id])
+ instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
+
+ @@index([instanceId])
+ @@map("issued_agent_certs")
+}
+
+model AgentInviteCode {
+ id String @id @default(uuid())
+ code String @unique
+ createdById String @map("created_by_id")
+ usedById String? @map("used_by_id")
+ expiresAt DateTime @map("expires_at")
+ usedAt DateTime? @map("used_at")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ createdBy CcpUser @relation(fields: [createdById], references: [id])
+
+ @@map("agent_invite_codes")
+}
+
+enum AgentRegistrationStatus {
+ PENDING
+ APPROVED
+ REJECTED
+ EXPIRED
+}
+
+model AgentRegistration {
+ id String @id @default(uuid())
+ inviteCodeId String @map("invite_code_id")
+ slug String
+ name String
+ domain String
+ agentUrl String @map("agent_url")
+ basePath String @map("base_path")
+ composeProject String @map("compose_project")
+ metadata Json?
+ status AgentRegistrationStatus @default(PENDING)
+ instanceId String? @map("instance_id")
+ approvedById String? @map("approved_by_id")
+ approvedAt DateTime? @map("approved_at")
+ rejectedAt DateTime? @map("rejected_at")
+ certBundle Json? @map("cert_bundle")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@index([status])
+ @@map("agent_registrations")
+}
diff --git a/changemaker-control-panel/api/src/config/env.ts b/changemaker-control-panel/api/src/config/env.ts
index e35a3a11..3841d130 100644
--- a/changemaker-control-panel/api/src/config/env.ts
+++ b/changemaker-control-panel/api/src/config/env.ts
@@ -62,6 +62,12 @@ const envSchema = z.object({
// Health checks
HEALTH_CHECK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 min (0 to disable)
+ // Remote agent defaults
+ AGENT_CONNECT_TIMEOUT_MS: z.coerce.number().default(10_000),
+ AGENT_REQUEST_TIMEOUT_MS: z.coerce.number().default(30_000),
+ AGENT_LONG_OP_TIMEOUT_MS: z.coerce.number().default(600_000), // 10 min for backups/builds
+ AGENT_HEALTH_FAILURE_THRESHOLD: z.coerce.number().default(3),
+
// Backups
BACKUP_STORAGE_PATH: z.string().default(
path.resolve(process.cwd(), '..', 'backups')
diff --git a/changemaker-control-panel/api/src/modules/agents/agents.routes.ts b/changemaker-control-panel/api/src/modules/agents/agents.routes.ts
new file mode 100644
index 00000000..4c9fc0d3
--- /dev/null
+++ b/changemaker-control-panel/api/src/modules/agents/agents.routes.ts
@@ -0,0 +1,248 @@
+import { Router, Request, Response } from 'express';
+import rateLimit from 'express-rate-limit';
+import { prisma } from '../../lib/prisma';
+import { Prisma, AuditAction, InstanceStatus, AgentRegistrationStatus } from '@prisma/client';
+import { validateInviteCode, markCodeUsed } from '../../services/invite-code.service';
+import { issueAgentCert } from '../../services/certificate.service';
+import { authenticate, requireRole } from '../../middleware/auth';
+import { AppError } from '../../middleware/error-handler';
+import { logger } from '../../utils/logger';
+
+const router = Router();
+
+// SECURITY: Strict rate limiter for unauthenticated agent endpoints
+const agentRegistrationLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 10, // 10 attempts per window per IP
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: { error: 'RATE_LIMITED', message: 'Too many registration attempts, try again later' },
+});
+
+// ─── Public Endpoints (used by remote agents during phone-home) ──────
+
+/**
+ * POST /api/agents/register
+ * Agent phones home with invite code + instance metadata.
+ * Creates a PENDING registration for admin approval.
+ */
+router.post('/register', agentRegistrationLimiter, async (req: Request, res: Response) => {
+ const { inviteCode, slug, name, domain, agentUrl, basePath, composeProject, metadata } = req.body;
+
+ if (!inviteCode || !slug || !agentUrl) {
+ throw new AppError(400, 'inviteCode, slug, and agentUrl are required');
+ }
+
+ // Validate invite code
+ const invite = await validateInviteCode(inviteCode);
+
+ // Check for duplicate pending registrations
+ const existing = await prisma.agentRegistration.findFirst({
+ where: { slug, status: AgentRegistrationStatus.PENDING },
+ });
+ if (existing) {
+ res.json({ registrationId: existing.id, status: 'PENDING' });
+ return;
+ }
+
+ // Create pending registration
+ const registration = await prisma.agentRegistration.create({
+ data: {
+ inviteCodeId: invite.id,
+ slug: slug || '',
+ name: name || slug || '',
+ domain: domain || '',
+ agentUrl,
+ basePath: basePath || '',
+ composeProject: composeProject || slug || '',
+ metadata: metadata || null,
+ },
+ });
+
+ logger.info(`[agents] New registration request: ${slug} from ${agentUrl} (invite: ${invite.code})`);
+
+ res.status(201).json({
+ registrationId: registration.id,
+ status: 'PENDING',
+ message: 'Registration submitted — waiting for admin approval',
+ });
+});
+
+/**
+ * GET /api/agents/poll
+ * Agent polls to check if registration was approved.
+ * Returns cert bundle on approval.
+ */
+router.get('/poll', agentRegistrationLimiter, async (req: Request, res: Response) => {
+ const { registrationId, slug } = req.query;
+
+ if (!registrationId && !slug) {
+ throw new AppError(400, 'registrationId or slug required');
+ }
+
+ const registration = await prisma.agentRegistration.findFirst({
+ where: registrationId
+ ? { id: registrationId as string }
+ : { slug: slug as string, status: { in: [AgentRegistrationStatus.PENDING, AgentRegistrationStatus.APPROVED] } },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ if (!registration) {
+ throw new AppError(404, 'Registration not found');
+ }
+
+ if (registration.status === AgentRegistrationStatus.APPROVED && registration.certBundle) {
+ // Return cert bundle — agent will save certs and restart with mTLS
+ const bundle = registration.certBundle;
+
+ // SECURITY: Wipe the cert bundle (contains private key) after first delivery.
+ // The agent gets one chance to retrieve it; after that it's gone from the DB.
+ await prisma.agentRegistration.update({
+ where: { id: registration.id },
+ data: { certBundle: Prisma.DbNull },
+ });
+ logger.info(`[agents] Cert bundle delivered and wiped for ${registration.slug}`);
+
+ res.json({
+ status: 'APPROVED',
+ certBundle: bundle,
+ });
+ return;
+ }
+
+ if (registration.status === AgentRegistrationStatus.APPROVED && !registration.certBundle) {
+ // Cert bundle was already delivered and wiped — agent must re-issue if it missed it
+ res.json({ status: 'APPROVED', certBundle: null, message: 'Certificate bundle already delivered. Contact admin to re-issue.' });
+ return;
+ }
+
+ if (registration.status === AgentRegistrationStatus.REJECTED) {
+ res.json({ status: 'REJECTED' });
+ return;
+ }
+
+ res.json({ status: 'PENDING' });
+});
+
+// ─── Authenticated Endpoints (CCP admin) ─────────────────────────────
+
+/**
+ * GET /api/agents/registrations
+ * List all agent registrations (pending, approved, rejected).
+ */
+router.get('/registrations', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => {
+ const registrations = await prisma.agentRegistration.findMany({
+ orderBy: { createdAt: 'desc' },
+ take: 100,
+ });
+ res.json(registrations);
+});
+
+/**
+ * POST /api/agents/registrations/:id/approve
+ * Approve a pending registration: issue certs, create Instance, mark approved.
+ */
+router.post('/registrations/:id/approve', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
+ if (!registration) throw new AppError(404, 'Registration not found');
+ if (registration.status !== AgentRegistrationStatus.PENDING) {
+ throw new AppError(400, `Registration is ${registration.status}, not PENDING`);
+ }
+
+ // Create the Instance record
+ const instance = await prisma.instance.create({
+ data: {
+ slug: registration.slug,
+ name: registration.name,
+ domain: registration.domain,
+ status: InstanceStatus.STOPPED,
+ statusMessage: 'Remote instance registered — agent connecting',
+ basePath: registration.basePath,
+ composeProject: registration.composeProject,
+ portConfig: (registration.metadata as Record)?.portConfig || { api: 4000, admin: 3000, postgres: 5432, nginx: 80 },
+ isRegistered: true,
+ isRemote: true,
+ agentUrl: registration.agentUrl,
+ adminEmail: (registration.metadata as Record)?.adminEmail as string || 'admin@example.com',
+ },
+ });
+
+ // Issue mTLS certificates
+ const certMaterials = await issueAgentCert(instance.id, registration.slug);
+
+ // Mark invite code as used
+ const invite = await prisma.agentInviteCode.findUnique({ where: { id: registration.inviteCodeId } });
+ if (invite && !invite.usedAt) {
+ await markCodeUsed(invite.code, instance.id);
+ }
+
+ // Update registration with approval + cert bundle
+ await prisma.agentRegistration.update({
+ where: { id: id as string },
+ data: {
+ status: AgentRegistrationStatus.APPROVED,
+ instanceId: instance.id,
+ approvedById: (req as unknown as { user: { id: string } }).user.id,
+ approvedAt: new Date(),
+ certBundle: {
+ caCertPem: certMaterials.caCertPem,
+ agentCertPem: certMaterials.agentCertPem,
+ agentKeyPem: certMaterials.agentKeyPem,
+ ccpFingerprint: certMaterials.caFingerprint,
+ },
+ },
+ });
+
+ // Audit log
+ await prisma.auditLog.create({
+ data: {
+ userId: (req as unknown as { user: { id: string } }).user.id,
+ instanceId: instance.id,
+ action: AuditAction.AGENT_APPROVE,
+ details: { slug: registration.slug, agentUrl: registration.agentUrl },
+ ipAddress: req.ip || null,
+ },
+ });
+
+ logger.info(`[agents] Registration approved: ${registration.slug} → instance ${instance.id}`);
+
+ res.json({
+ message: 'Registration approved — agent will receive certificates on next poll',
+ instanceId: instance.id,
+ });
+});
+
+/**
+ * POST /api/agents/registrations/:id/reject
+ * Reject a pending registration.
+ */
+router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
+ const { id } = req.params;
+ const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
+ if (!registration) throw new AppError(404, 'Registration not found');
+ if (registration.status !== AgentRegistrationStatus.PENDING) {
+ throw new AppError(400, `Registration is ${registration.status}, not PENDING`);
+ }
+
+ await prisma.agentRegistration.update({
+ where: { id: id as string },
+ data: {
+ status: AgentRegistrationStatus.REJECTED,
+ rejectedAt: new Date(),
+ },
+ });
+
+ await prisma.auditLog.create({
+ data: {
+ userId: (req as unknown as { user: { id: string } }).user.id,
+ action: AuditAction.AGENT_REJECT,
+ details: { slug: registration.slug, agentUrl: registration.agentUrl },
+ ipAddress: req.ip || null,
+ },
+ });
+
+ res.json({ message: 'Registration rejected' });
+});
+
+export default router;
diff --git a/changemaker-control-panel/api/src/modules/certificates/certificates.routes.ts b/changemaker-control-panel/api/src/modules/certificates/certificates.routes.ts
new file mode 100644
index 00000000..baa2dec9
--- /dev/null
+++ b/changemaker-control-panel/api/src/modules/certificates/certificates.routes.ts
@@ -0,0 +1,16 @@
+import { Router, Request, Response } from 'express';
+import { authenticate, requireRole } from '../../middleware/auth';
+import { getCACert } from '../../services/certificate.service';
+
+const router = Router();
+
+/**
+ * GET /api/certificates/ca
+ * Get the CCP CA public certificate (for manual agent setup).
+ */
+router.get('/ca', authenticate, requireRole('SUPER_ADMIN'), async (_req: Request, res: Response) => {
+ const ca = await getCACert();
+ res.json(ca);
+});
+
+export default router;
diff --git a/changemaker-control-panel/api/src/modules/instances/instances.schemas.ts b/changemaker-control-panel/api/src/modules/instances/instances.schemas.ts
index bc1b7117..1348ad07 100644
--- a/changemaker-control-panel/api/src/modules/instances/instances.schemas.ts
+++ b/changemaker-control-panel/api/src/modules/instances/instances.schemas.ts
@@ -16,6 +16,7 @@ export const createInstanceSchema = z.object({
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
+ enableAnalytics: z.boolean().default(false),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
@@ -42,6 +43,7 @@ export const updateInstanceSchema = z.object({
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
+ enableAnalytics: z.boolean().optional(),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).nullable().optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
@@ -76,6 +78,7 @@ export const registerInstanceSchema = z.object({
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
+ enableAnalytics: z.boolean().default(false),
emailTestMode: z.boolean().default(true),
notes: z.string().optional(),
});
@@ -92,6 +95,7 @@ export const reconfigureInstanceSchema = z.object({
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
+ enableAnalytics: z.boolean().optional(),
});
export const configureTunnelSchema = z.object({
diff --git a/changemaker-control-panel/api/src/modules/instances/instances.service.ts b/changemaker-control-panel/api/src/modules/instances/instances.service.ts
index bf1b79f7..6ef2fab5 100644
--- a/changemaker-control-panel/api/src/modules/instances/instances.service.ts
+++ b/changemaker-control-panel/api/src/modules/instances/instances.service.ts
@@ -8,6 +8,7 @@ import { encryptJson, decryptJson } from '../../utils/encryption';
import { generateSecrets } from '../../services/secret-generator';
import { allocatePorts, releasePorts } from '../../services/port-allocator';
import * as docker from '../../services/docker.service';
+import { getDriverForInstance, AgentUnreachableError } from '../../services/execution-driver';
import { provision } from './provisioner';
import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput, ConfigureTunnelInput } from './instances.schemas';
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
@@ -86,6 +87,7 @@ export async function createInstance(input: CreateInstanceInput, userId: string,
enableSms: input.enableSms,
enableSocial: input.enableSocial,
enablePeople: input.enablePeople,
+ enableAnalytics: input.enableAnalytics,
jvbAdvertiseIp: input.jvbAdvertiseIp,
adminEmail: input.adminEmail,
pangolinEndpoint: input.enablePangolin ? input.pangolinEndpoint : null,
@@ -184,6 +186,7 @@ export async function registerInstance(input: RegisterInstanceInput, userId: str
enableSms: input.enableSms,
enableSocial: input.enableSocial,
enablePeople: input.enablePeople,
+ enableAnalytics: input.enableAnalytics,
adminEmail: input.adminEmail,
notes: input.notes,
},
@@ -282,7 +285,8 @@ export async function deleteInstance(id: string, userId: string, ipAddress?: str
// Stop containers and remove volumes
try {
- await docker.composeDown(instance.basePath, instance.composeProject, true);
+ const driver = await getDriverForInstance(instance);
+ await driver.composeDown(instance.basePath, instance.composeProject, true);
logger.info(`[instances] ${instance.slug}: Containers stopped and volumes removed`);
} catch (err) {
logger.warn(`[instances] ${instance.slug}: Docker cleanup warning: ${(err as Error).message}`);
@@ -413,7 +417,8 @@ export async function startInstance(id: string, userId: string, ipAddress?: stri
}
try {
- await docker.composeUp(instance.basePath, instance.composeProject);
+ const driver = await getDriverForInstance(instance);
+ await driver.composeUp(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
@@ -432,6 +437,13 @@ export async function startInstance(id: string, userId: string, ipAddress?: stri
return { message: 'Instance started' };
} catch (err) {
+ if (err instanceof AgentUnreachableError) {
+ await prisma.instance.update({
+ where: { id },
+ data: { status: InstanceStatus.ERROR, statusMessage: `Agent unreachable: ${err.agentUrl}` },
+ });
+ throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
+ }
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
@@ -452,7 +464,8 @@ export async function stopInstance(id: string, userId: string, ipAddress?: strin
}
try {
- await docker.composeStop(instance.basePath, instance.composeProject);
+ const driver = await getDriverForInstance(instance);
+ await driver.composeStop(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
@@ -471,6 +484,9 @@ export async function stopInstance(id: string, userId: string, ipAddress?: strin
return { message: 'Instance stopped' };
} catch (err) {
+ if (err instanceof AgentUnreachableError) {
+ throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
+ }
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to stop instance: ${errorMsg}`, 'DOCKER_ERROR');
}
@@ -483,7 +499,8 @@ export async function restartInstance(id: string, userId: string, ipAddress?: st
}
try {
- await docker.composeRestart(instance.basePath, instance.composeProject, service);
+ const driver = await getDriverForInstance(instance);
+ await driver.composeRestart(instance.basePath, instance.composeProject, service);
await prisma.auditLog.create({
data: {
@@ -497,6 +514,9 @@ export async function restartInstance(id: string, userId: string, ipAddress?: st
return { message: `${service || 'All services'} restarted` };
} catch (err) {
+ if (err instanceof AgentUnreachableError) {
+ throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
+ }
const errorMsg = (err as Error).message;
throw new AppError(500, `Failed to restart: ${errorMsg}`, 'DOCKER_ERROR');
}
@@ -509,9 +529,10 @@ export async function getInstanceServices(id: string) {
}
try {
- return await docker.composePs(instance.basePath, instance.composeProject);
+ const driver = await getDriverForInstance(instance);
+ return await driver.composePs(instance.basePath, instance.composeProject);
} catch {
- // If compose ps fails (e.g. no containers), return empty array
+ // If compose ps fails (e.g. no containers or agent unreachable), return empty array
return [];
}
}
@@ -528,7 +549,8 @@ export async function getInstanceLogs(
}
try {
- return await docker.composeLogs(
+ const driver = await getDriverForInstance(instance);
+ return await driver.composeLogs(
instance.basePath,
instance.composeProject,
service,
@@ -536,6 +558,9 @@ export async function getInstanceLogs(
since
);
} catch (err) {
+ if (err instanceof AgentUnreachableError) {
+ throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
+ }
throw new AppError(500, `Failed to get logs: ${(err as Error).message}`, 'DOCKER_ERROR');
}
}
@@ -577,12 +602,21 @@ export async function reconfigureInstance(
// Re-render templates with updated flags
const secrets = decryptJson>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
- await renderAllTemplates(context, instance.basePath);
+
+ const driver = await getDriverForInstance(instance);
+ if (instance.isRemote) {
+ // Remote: render in memory, send files to agent
+ const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
+ const files = await renderAllTemplatesInMemory(context);
+ await driver.writeFiles(instance.basePath, files);
+ } else {
+ await renderAllTemplates(context, instance.basePath);
+ }
// If instance is running, apply changes via docker compose up
if (instance.status === 'RUNNING') {
try {
- await docker.composeUp(instance.basePath, instance.composeProject);
+ await driver.composeUp(instance.basePath, instance.composeProject);
// --remove-orphans (from composeUp) will clean up disabled services
await prisma.instance.update({
@@ -590,6 +624,13 @@ export async function reconfigureInstance(
data: { statusMessage: 'Reconfiguration complete' },
});
} catch (err) {
+ if (err instanceof AgentUnreachableError) {
+ await prisma.instance.update({
+ where: { id },
+ data: { statusMessage: `Agent unreachable: ${(err as AgentUnreachableError).agentUrl}` },
+ });
+ throw new AppError(503, err.message, 'AGENT_UNREACHABLE');
+ }
const errorMsg = (err as Error).message;
await prisma.instance.update({
where: { id },
@@ -661,12 +702,20 @@ export async function configureTunnel(
clearTemplateCache();
const secrets = decryptJson>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
- await renderAllTemplates(context, instance.basePath);
+
+ const driver = await getDriverForInstance(instance);
+ if (instance.isRemote) {
+ const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
+ const files = await renderAllTemplatesInMemory(context);
+ await driver.writeFiles(instance.basePath, files);
+ } else {
+ await renderAllTemplates(context, instance.basePath);
+ }
// If running, bring up the newt container
if (instance.status === 'RUNNING') {
try {
- await docker.composeUp(instance.basePath, instance.composeProject, ['newt']);
+ await driver.composeUp(instance.basePath, instance.composeProject, ['newt']);
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Tunnel configured and Newt started' },
@@ -738,12 +787,20 @@ export async function removeTunnel(
clearTemplateCache();
const secrets = decryptJson>(instance.encryptedSecrets);
const context = buildTemplateContext(updated, secrets);
- await renderAllTemplates(context, instance.basePath);
+
+ const driver = await getDriverForInstance(instance);
+ if (instance.isRemote) {
+ const { renderAllTemplatesInMemory } = await import('../../services/template-engine');
+ const files = await renderAllTemplatesInMemory(context);
+ await driver.writeFiles(instance.basePath, files);
+ } else {
+ await renderAllTemplates(context, instance.basePath);
+ }
// If running, full compose up with --remove-orphans removes the orphaned newt container
if (instance.status === 'RUNNING') {
try {
- await docker.composeUp(instance.basePath, instance.composeProject);
+ await driver.composeUp(instance.basePath, instance.composeProject);
await prisma.instance.update({
where: { id },
data: { statusMessage: 'Tunnel removed' },
diff --git a/changemaker-control-panel/api/src/modules/invite-codes/invite-codes.routes.ts b/changemaker-control-panel/api/src/modules/invite-codes/invite-codes.routes.ts
new file mode 100644
index 00000000..a0b3aceb
--- /dev/null
+++ b/changemaker-control-panel/api/src/modules/invite-codes/invite-codes.routes.ts
@@ -0,0 +1,62 @@
+import { Router, Request, Response } from 'express';
+import { authenticate, requireRole } from '../../middleware/auth';
+import { AuditAction } from '@prisma/client';
+import { prisma } from '../../lib/prisma';
+import { createInviteCode, listInviteCodes, revokeInviteCode } from '../../services/invite-code.service';
+
+const router = Router();
+
+/**
+ * POST /api/invite-codes
+ * Generate a new invite code for agent registration.
+ */
+router.post('/', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
+ const userId = (req as unknown as { user: { id: string } }).user.id;
+ const { expiryHours } = req.body || {};
+
+ const invite = await createInviteCode(userId, expiryHours);
+
+ await prisma.auditLog.create({
+ data: {
+ userId,
+ action: AuditAction.INVITE_CREATE,
+ details: { code: invite.code, expiresAt: invite.expiresAt.toISOString() },
+ ipAddress: req.ip || null,
+ },
+ });
+
+ res.status(201).json(invite);
+});
+
+/**
+ * GET /api/invite-codes
+ * List all invite codes.
+ */
+router.get('/', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
+ const page = Number(req.query.page) || 1;
+ const limit = Number(req.query.limit) || 50;
+ const result = await listInviteCodes(page, limit);
+ res.json(result);
+});
+
+/**
+ * DELETE /api/invite-codes/:id
+ * Revoke an unused invite code.
+ */
+router.delete('/:id', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
+ const userId = (req as unknown as { user: { id: string } }).user.id;
+ await revokeInviteCode(req.params.id as string);
+
+ await prisma.auditLog.create({
+ data: {
+ userId,
+ action: AuditAction.INVITE_REVOKE,
+ details: { inviteCodeId: req.params.id },
+ ipAddress: req.ip || null,
+ },
+ });
+
+ res.json({ message: 'Invite code revoked' });
+});
+
+export default router;
diff --git a/changemaker-control-panel/api/src/server.ts b/changemaker-control-panel/api/src/server.ts
index c29b11b1..90f92e9b 100644
--- a/changemaker-control-panel/api/src/server.ts
+++ b/changemaker-control-panel/api/src/server.ts
@@ -16,6 +16,9 @@ import healthRoutes from './modules/health/health.routes';
import auditRoutes from './modules/audit/audit.routes';
import backupRoutes from './modules/backups/backup.routes';
import eventsRoutes, { instanceEventsRouter } from './modules/events/events.routes';
+import agentRoutes from './modules/agents/agents.routes';
+import certificateRoutes from './modules/certificates/certificates.routes';
+import inviteCodeRoutes from './modules/invite-codes/invite-codes.routes';
import { startHealthScheduler } from './services/health.service';
import { autoDiscoverOnStartup } from './services/discovery.service';
@@ -60,6 +63,9 @@ app.use('/api/audit', auditRoutes);
app.use('/api/backups', backupRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/instances/:id/events', instanceEventsRouter);
+app.use('/api/agents', agentRoutes);
+app.use('/api/certificates', certificateRoutes);
+app.use('/api/invite-codes', inviteCodeRoutes);
// Error handler (must be last)
app.use(errorHandler);
diff --git a/changemaker-control-panel/api/src/services/certificate.service.ts b/changemaker-control-panel/api/src/services/certificate.service.ts
new file mode 100644
index 00000000..acc7b21e
--- /dev/null
+++ b/changemaker-control-panel/api/src/services/certificate.service.ts
@@ -0,0 +1,236 @@
+import crypto from 'crypto';
+import { exec as execCb } from 'child_process';
+import { promisify } from 'util';
+import fs from 'fs/promises';
+import os from 'os';
+import path from 'path';
+import { prisma } from '../lib/prisma';
+import { encrypt, decrypt } from '../utils/encryption';
+import { logger } from '../utils/logger';
+
+const exec = promisify(execCb);
+
+const CA_VALIDITY_DAYS = 3650; // ~10 years
+const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
+
+function computeFingerprint(certPem: string): string {
+ const der = Buffer.from(
+ certPem
+ .replace(/-----BEGIN CERTIFICATE-----/g, '')
+ .replace(/-----END CERTIFICATE-----/g, '')
+ .replace(/\s/g, ''),
+ 'base64'
+ );
+ return crypto.createHash('sha256').update(der).digest('hex');
+}
+
+/**
+ * Run openssl commands in a temp directory, then clean up.
+ */
+async function withTempDir(fn: (dir: string) => Promise): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ccp-cert-'));
+ try {
+ return await fn(dir);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+}
+
+/**
+ * Ensure a Certificate Authority exists. Creates one if none exists.
+ */
+export async function ensureCA() {
+ const existing = await prisma.ccpCertificateAuthority.findFirst({
+ orderBy: { createdAt: 'desc' },
+ });
+
+ if (existing && existing.expiresAt > new Date()) {
+ return existing;
+ }
+
+ logger.info('Generating new CCP Certificate Authority...');
+
+ const { keyPem, certPem } = await withTempDir(async (dir) => {
+ const keyFile = path.join(dir, 'ca.key');
+ const certFile = path.join(dir, 'ca.crt');
+
+ // Generate CA key + self-signed cert
+ await exec(
+ `openssl req -x509 -newkey rsa:4096 -keyout "${keyFile}" -out "${certFile}" ` +
+ `-days ${CA_VALIDITY_DAYS} -nodes ` +
+ `-subj "/CN=CCP Certificate Authority/O=Changemaker Control Panel"`,
+ { timeout: 30_000 }
+ );
+
+ return {
+ keyPem: await fs.readFile(keyFile, 'utf-8'),
+ certPem: await fs.readFile(certFile, 'utf-8'),
+ };
+ });
+
+ const fingerprint = computeFingerprint(certPem);
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + CA_VALIDITY_DAYS);
+
+ const ca = await prisma.ccpCertificateAuthority.create({
+ data: {
+ commonName: 'CCP Certificate Authority',
+ encryptedKey: encrypt(keyPem),
+ certPem,
+ fingerprint,
+ expiresAt,
+ },
+ });
+
+ logger.info(`CA created: fingerprint=${fingerprint.substring(0, 16)}...`);
+ return ca;
+}
+
+/**
+ * Issue a certificate for a remote agent, signed by the CA.
+ * Returns the certificate materials (plaintext) for one-time display.
+ */
+export async function issueAgentCert(instanceId: string, slug: string) {
+ const ca = await ensureCA();
+ const caKeyPem = decrypt(ca.encryptedKey);
+
+ const commonName = `ccp-agent-${slug}`;
+
+ const { agentKeyPem, agentCertPem } = await withTempDir(async (dir) => {
+ const caKeyFile = path.join(dir, 'ca.key');
+ const caCertFile = path.join(dir, 'ca.crt');
+ const agentKeyFile = path.join(dir, 'agent.key');
+ const agentCsrFile = path.join(dir, 'agent.csr');
+ const agentCertFile = path.join(dir, 'agent.crt');
+ const serialFile = path.join(dir, 'serial');
+ const extFile = path.join(dir, 'ext.cnf');
+
+ // Write CA materials
+ await fs.writeFile(caKeyFile, caKeyPem);
+ await fs.writeFile(caCertFile, ca.certPem);
+ await fs.writeFile(serialFile, crypto.randomBytes(16).toString('hex'));
+
+ // Extensions for server+client auth
+ await fs.writeFile(extFile, [
+ 'basicConstraints=CA:FALSE',
+ 'keyUsage=digitalSignature,keyEncipherment',
+ 'extendedKeyUsage=serverAuth,clientAuth',
+ ].join('\n'));
+
+ // Generate agent key
+ await exec(
+ `openssl genrsa -out "${agentKeyFile}" 2048`,
+ { timeout: 15_000 }
+ );
+
+ // Generate CSR
+ await exec(
+ `openssl req -new -key "${agentKeyFile}" -out "${agentCsrFile}" ` +
+ `-subj "/CN=${commonName}/O=Changemaker Lite Agent"`,
+ { timeout: 15_000 }
+ );
+
+ // Sign CSR with CA
+ await exec(
+ `openssl x509 -req -in "${agentCsrFile}" ` +
+ `-CA "${caCertFile}" -CAkey "${caKeyFile}" ` +
+ `-CAserial "${serialFile}" ` +
+ `-out "${agentCertFile}" -days ${AGENT_CERT_VALIDITY_DAYS} ` +
+ `-extfile "${extFile}"`,
+ { timeout: 15_000 }
+ );
+
+ return {
+ agentKeyPem: await fs.readFile(agentKeyFile, 'utf-8'),
+ agentCertPem: await fs.readFile(agentCertFile, 'utf-8'),
+ };
+ });
+
+ const fingerprint = computeFingerprint(agentCertPem);
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + AGENT_CERT_VALIDITY_DAYS);
+
+ // Revoke any existing cert for this instance
+ await prisma.issuedAgentCert.deleteMany({ where: { instanceId } });
+
+ // Store the issued cert
+ await prisma.issuedAgentCert.create({
+ data: {
+ caId: ca.id,
+ instanceId,
+ commonName,
+ encryptedKey: encrypt(agentKeyPem),
+ certPem: agentCertPem,
+ fingerprint,
+ expiresAt,
+ },
+ });
+
+ // Update instance with the agent fingerprint
+ await prisma.instance.update({
+ where: { id: instanceId },
+ data: { agentFingerprint: fingerprint },
+ });
+
+ logger.info(`Agent cert issued for ${slug}: fingerprint=${fingerprint.substring(0, 16)}...`);
+
+ return {
+ caCertPem: ca.certPem,
+ agentCertPem,
+ agentKeyPem, // Plaintext — display once, never retrievable again
+ fingerprint,
+ caFingerprint: ca.fingerprint,
+ };
+}
+
+/**
+ * Revoke an agent's certificate.
+ */
+export async function revokeAgentCert(instanceId: string) {
+ const cert = await prisma.issuedAgentCert.findUnique({ where: { instanceId } });
+ if (!cert) return;
+
+ await prisma.issuedAgentCert.update({
+ where: { id: cert.id },
+ data: { revokedAt: new Date() },
+ });
+
+ await prisma.instance.update({
+ where: { id: instanceId },
+ data: { agentFingerprint: null },
+ });
+
+ logger.info(`Agent cert revoked for instance ${instanceId}`);
+}
+
+/**
+ * Get the mTLS materials CCP needs to present when calling a remote agent.
+ */
+export async function getAgentClientMaterials(instanceId: string) {
+ const cert = await prisma.issuedAgentCert.findUnique({
+ where: { instanceId },
+ include: { ca: true },
+ });
+
+ if (!cert || cert.revokedAt) return null;
+
+ return {
+ agentCertPem: cert.certPem,
+ agentKeyPem: decrypt(cert.encryptedKey),
+ caCertPem: cert.ca.certPem,
+ fingerprint: cert.fingerprint,
+ expiresAt: cert.expiresAt,
+ };
+}
+
+/**
+ * Get the CA public certificate (for manual agent setup).
+ */
+export async function getCACert() {
+ const ca = await ensureCA();
+ return {
+ certPem: ca.certPem,
+ fingerprint: ca.fingerprint,
+ expiresAt: ca.expiresAt,
+ };
+}
diff --git a/changemaker-control-panel/api/src/services/discovery.service.ts b/changemaker-control-panel/api/src/services/discovery.service.ts
index 9cdd1349..85524a7c 100644
--- a/changemaker-control-panel/api/src/services/discovery.service.ts
+++ b/changemaker-control-panel/api/src/services/discovery.service.ts
@@ -32,6 +32,7 @@ export interface DiscoveredInstance {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
+ enableAnalytics: boolean;
emailTestMode: boolean;
// Discovery metadata (UI-only, not persisted)
source: 'parent' | 'docker';
@@ -388,6 +389,7 @@ export async function autoDiscoverOnStartup(): Promise {
enableSms: inst.enableSms,
enableSocial: inst.enableSocial,
enablePeople: inst.enablePeople,
+ enableAnalytics: inst.enableAnalytics,
emailTestMode: inst.emailTestMode,
},
userId,
diff --git a/changemaker-control-panel/api/src/services/execution-driver.ts b/changemaker-control-panel/api/src/services/execution-driver.ts
new file mode 100644
index 00000000..cbaa0462
--- /dev/null
+++ b/changemaker-control-panel/api/src/services/execution-driver.ts
@@ -0,0 +1,82 @@
+import type { ContainerInfo } from './docker.service';
+
+/**
+ * Abstraction layer for instance operations.
+ * LocalDriver wraps docker.service.ts + filesystem.
+ * RemoteDriver makes HTTPS calls to the remote agent.
+ */
+export interface ExecutionDriver {
+ // ─── Docker Compose Operations ──────────────────────────────
+ composeUp(projectDir: string, project: string, services?: string[]): Promise;
+ composeDown(projectDir: string, project: string, removeVolumes?: boolean): Promise;
+ composeStop(projectDir: string, project: string): Promise;
+ composeRestart(projectDir: string, project: string, service?: string): Promise;
+ composePull(projectDir: string, project: string): Promise;
+ composeBuild(projectDir: string, project: string): Promise;
+ composePs(projectDir: string, project: string): Promise;
+ composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string): Promise;
+ composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record): Promise;
+
+ // ─── Container Health ───────────────────────────────────────
+ waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number): Promise;
+ waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number): Promise;
+
+ // ─── Filesystem Operations ──────────────────────────────────
+ readEnvFile(basePath: string): Promise | null>;
+ writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>): Promise;
+ mkdir(basePath: string, relativePath: string): Promise;
+ fileExists(basePath: string, relativePath: string): Promise;
+ deleteDirectory(dirPath: string): Promise;
+ cloneSource(basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise;
+}
+
+/**
+ * Error thrown when a remote agent is unreachable.
+ */
+export class AgentUnreachableError extends Error {
+ constructor(public agentUrl: string, cause?: Error) {
+ super(`Remote agent at ${agentUrl} is not reachable`);
+ this.name = 'AgentUnreachableError';
+ if (cause) this.cause = cause;
+ }
+}
+
+/**
+ * Minimal instance shape needed to resolve a driver.
+ */
+export interface DriverInstance {
+ id: string;
+ slug: string;
+ isRemote: boolean;
+ agentUrl: string | null;
+}
+
+/**
+ * Resolve the correct execution driver for an instance.
+ * Returns LocalDriver for local instances, RemoteDriver for remote ones.
+ */
+export async function getDriverForInstance(instance: DriverInstance): Promise {
+ if (!instance.isRemote) {
+ const { getLocalDriver } = await import('./local-driver');
+ return getLocalDriver();
+ }
+
+ if (!instance.agentUrl) {
+ throw new Error(`Remote instance ${instance.slug} has no agent URL configured`);
+ }
+
+ const { getAgentClientMaterials } = await import('./certificate.service');
+ const materials = await getAgentClientMaterials(instance.id);
+ if (!materials) {
+ throw new Error(`No valid certificate found for remote instance ${instance.slug}`);
+ }
+
+ const { RemoteDriver } = await import('./remote-driver');
+ return new RemoteDriver(
+ instance.agentUrl,
+ instance.slug,
+ Buffer.from(materials.agentCertPem),
+ Buffer.from(materials.agentKeyPem),
+ Buffer.from(materials.caCertPem)
+ );
+}
diff --git a/changemaker-control-panel/api/src/services/health.service.ts b/changemaker-control-panel/api/src/services/health.service.ts
index 4d2fa3f4..201bc74e 100644
--- a/changemaker-control-panel/api/src/services/health.service.ts
+++ b/changemaker-control-panel/api/src/services/health.service.ts
@@ -1,6 +1,10 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { parse as parseDotenv } from 'dotenv';
import { InstanceStatus, HealthStatus } from '@prisma/client';
import { prisma } from '../lib/prisma';
import * as docker from './docker.service';
+import { getDriverForInstance, AgentUnreachableError } from './execution-driver';
import { logger } from '../utils/logger';
import { createEvent } from './event.service';
import type { ContainerInfo } from './docker.service';
@@ -52,8 +56,43 @@ function determineHealth(containers: ContainerInfo[]): {
return { status, serviceStatus, totalServices: total, healthyServices: healthyCount };
}
+/**
+ * Parse an instance's .env file and return all variables.
+ * Returns null if the file doesn't exist or can't be read.
+ */
+async function readEnvFile(basePath: string): Promise | null> {
+ try {
+ const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8');
+ return parseDotenv(Buffer.from(content));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Extract feature flags from parsed .env variables.
+ */
+function extractFeatureFlags(envVars: Record): Record {
+ const isTrue = (val?: string) => val?.toLowerCase() === 'true';
+ return {
+ enableMedia: isTrue(envVars.ENABLE_MEDIA_FEATURES),
+ enableChat: isTrue(envVars.ENABLE_CHAT),
+ enableGancio: isTrue(envVars.GANCIO_SYNC_ENABLED),
+ enableListmonk: isTrue(envVars.LISTMONK_SYNC_ENABLED),
+ enablePayments: isTrue(envVars.ENABLE_PAYMENTS),
+ enableMeet: isTrue(envVars.ENABLE_MEET),
+ enableSms: isTrue(envVars.ENABLE_SMS),
+ enableSocial: isTrue(envVars.ENABLE_SOCIAL),
+ enablePeople: isTrue(envVars.ENABLE_PEOPLE),
+ enableAnalytics: isTrue(envVars.ENABLE_ANALYTICS),
+ };
+}
+
/**
* Check the health of a single instance. Returns the created HealthCheck record.
+ * Also auto-corrects instance.status based on actual container state:
+ * - RUNNING instance with 0 containers → STOPPED
+ * - STOPPED instance with running containers → RUNNING
*/
export async function checkInstanceHealth(instanceId: string) {
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
@@ -61,17 +100,29 @@ export async function checkInstanceHealth(instanceId: string) {
throw new Error(`Instance ${instanceId} not found`);
}
- if (instance.status !== InstanceStatus.RUNNING) {
- throw new Error(`Instance ${instance.slug} is not running (status: ${instance.status})`);
+ // Only check RUNNING or STOPPED instances (skip PROVISIONING, ERROR, DESTROYING)
+ if (instance.status !== InstanceStatus.RUNNING && instance.status !== InstanceStatus.STOPPED) {
+ throw new Error(`Instance ${instance.slug} is not checkable (status: ${instance.status})`);
}
const startTime = Date.now();
let containers: ContainerInfo[];
+ const driver = await getDriverForInstance(instance);
+
try {
- containers = await docker.composePs(instance.basePath, instance.composeProject);
+ containers = await driver.composePs(instance.basePath, instance.composeProject);
} catch (err) {
// If compose ps fails, record UNKNOWN status
+ const updateData: { lastHealthCheck: Date; status?: InstanceStatus } = {
+ lastHealthCheck: new Date(),
+ };
+ // If we thought it was RUNNING but can't even reach compose, mark as STOPPED
+ if (instance.status === InstanceStatus.RUNNING) {
+ updateData.status = InstanceStatus.STOPPED;
+ logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (compose ps failed)`);
+ }
+
const healthCheck = await prisma.healthCheck.create({
data: {
instanceId,
@@ -85,7 +136,7 @@ export async function checkInstanceHealth(instanceId: string) {
await prisma.instance.update({
where: { id: instanceId },
- data: { lastHealthCheck: new Date() },
+ data: updateData,
});
logger.warn(`[health] ${instance.slug}: compose ps failed: ${(err as Error).message}`);
@@ -95,6 +146,62 @@ export async function checkInstanceHealth(instanceId: string) {
const responseTimeMs = Date.now() - startTime;
const { status, serviceStatus, totalServices, healthyServices } = determineHealth(containers);
+ // Auto-correct instance status based on actual container state
+ const hasRunningContainers = containers.some((c) => c.state === 'running');
+
+ if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
+ await prisma.instance.update({
+ where: { id: instanceId },
+ data: { status: InstanceStatus.STOPPED },
+ });
+ logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
+ } else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
+ await prisma.instance.update({
+ where: { id: instanceId },
+ data: { status: InstanceStatus.RUNNING },
+ });
+ logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${containers.filter((c) => c.state === 'running').length} running containers detected)`);
+ }
+
+ // Sync domain and feature flags from .env if they have drifted
+ const envVars = instance.isRemote
+ ? await driver.readEnvFile(instance.basePath)
+ : await readEnvFile(instance.basePath);
+ if (envVars) {
+ const driftUpdates: Record = {};
+
+ // Domain sync
+ const envDomain = envVars.DOMAIN;
+ if (envDomain && envDomain !== instance.domain) {
+ driftUpdates.domain = envDomain;
+ logger.info(`[health] ${instance.slug}: synced domain ${instance.domain} → ${envDomain}`);
+ }
+
+ // Feature flag sync (only for registered/external instances)
+ if (instance.isRegistered) {
+ const envFlags = extractFeatureFlags(envVars);
+ const flagKeys = Object.keys(envFlags) as Array;
+ for (const key of flagKeys) {
+ if ((instance as Record)[key] !== envFlags[key]) {
+ driftUpdates[key] = envFlags[key];
+ }
+ }
+ if (Object.keys(driftUpdates).length > (envDomain && envDomain !== instance.domain ? 1 : 0)) {
+ const changedFlags = flagKeys.filter(k => (instance as Record)[k] !== envFlags[k]);
+ if (changedFlags.length > 0) {
+ logger.info(`[health] ${instance.slug}: synced feature flags: ${changedFlags.join(', ')}`);
+ }
+ }
+ }
+
+ if (Object.keys(driftUpdates).length > 0) {
+ await prisma.instance.update({
+ where: { id: instanceId },
+ data: driftUpdates,
+ });
+ }
+ }
+
// Get the previous health check to detect transitions
const previousCheck = await prisma.healthCheck.findFirst({
where: { instanceId },
@@ -113,9 +220,13 @@ export async function checkInstanceHealth(instanceId: string) {
},
});
+ const healthUpdateData: Record = { lastHealthCheck: new Date() };
+ if (instance.isRemote) {
+ healthUpdateData.agentLastSeen = new Date();
+ }
await prisma.instance.update({
where: { id: instanceId },
- data: { lastHealthCheck: new Date() },
+ data: healthUpdateData,
});
// Create events on health transitions
@@ -160,16 +271,17 @@ export async function checkInstanceHealth(instanceId: string) {
}
/**
- * Check all running instances sequentially.
+ * Check all checkable instances (RUNNING + STOPPED) sequentially.
+ * STOPPED instances are checked so we can detect when they come back online.
*/
export async function checkAllInstances(): Promise {
const instances = await prisma.instance.findMany({
- where: { status: InstanceStatus.RUNNING },
- select: { id: true, slug: true },
+ where: { status: { in: [InstanceStatus.RUNNING, InstanceStatus.STOPPED] } },
+ select: { id: true, slug: true, status: true },
});
if (instances.length === 0) {
- logger.debug('[health] No running instances to check');
+ logger.debug('[health] No checkable instances');
return;
}
diff --git a/changemaker-control-panel/api/src/services/invite-code.service.ts b/changemaker-control-panel/api/src/services/invite-code.service.ts
new file mode 100644
index 00000000..082a8222
--- /dev/null
+++ b/changemaker-control-panel/api/src/services/invite-code.service.ts
@@ -0,0 +1,122 @@
+import crypto from 'crypto';
+import { prisma } from '../lib/prisma';
+import { AppError } from '../middleware/error-handler';
+
+const CODE_LENGTH = 8; // e.g., "A3X7-K9M2"
+const DEFAULT_EXPIRY_HOURS = 24;
+
+function generateCode(): string {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no I,O,0,1 to avoid confusion
+ const bytes = crypto.randomBytes(CODE_LENGTH);
+ let code = '';
+ for (let i = 0; i < CODE_LENGTH; i++) {
+ code += chars[bytes[i] % chars.length];
+ }
+ // Format as XXXX-XXXX
+ return `${code.slice(0, 4)}-${code.slice(4)}`;
+}
+
+/**
+ * Generate a single-use invite code for agent registration.
+ */
+export async function createInviteCode(userId: string, expiryHours = DEFAULT_EXPIRY_HOURS) {
+ const expiresAt = new Date();
+ expiresAt.setHours(expiresAt.getHours() + expiryHours);
+
+ // Retry up to 3 times in case of code collision (extremely unlikely)
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const code = generateCode();
+ try {
+ return await prisma.agentInviteCode.create({
+ data: {
+ code,
+ createdById: userId,
+ expiresAt,
+ },
+ });
+ } catch (err: unknown) {
+ const prismaError = err as { code?: string };
+ if (prismaError.code === 'P2002' && attempt < 2) continue; // unique constraint, retry
+ throw err;
+ }
+ }
+
+ throw new AppError(500, 'Failed to generate unique invite code');
+}
+
+/**
+ * Validate an invite code. Returns the code record if valid.
+ * Throws if expired, already used, or not found.
+ */
+export async function validateInviteCode(code: string) {
+ const normalized = code.toUpperCase().trim();
+ const invite = await prisma.agentInviteCode.findUnique({
+ where: { code: normalized },
+ });
+
+ if (!invite) {
+ throw new AppError(404, 'Invalid invite code', 'INVALID_CODE');
+ }
+
+ if (invite.usedAt) {
+ throw new AppError(400, 'Invite code has already been used', 'CODE_USED');
+ }
+
+ if (invite.expiresAt < new Date()) {
+ throw new AppError(400, 'Invite code has expired', 'CODE_EXPIRED');
+ }
+
+ return invite;
+}
+
+/**
+ * Mark an invite code as used by an instance.
+ */
+export async function markCodeUsed(code: string, instanceId: string) {
+ const normalized = code.toUpperCase().trim();
+ await prisma.agentInviteCode.update({
+ where: { code: normalized },
+ data: {
+ usedAt: new Date(),
+ usedById: instanceId,
+ },
+ });
+}
+
+/**
+ * List all invite codes with optional filtering.
+ */
+export async function listInviteCodes(page = 1, limit = 50) {
+ const skip = (page - 1) * limit;
+
+ const [data, total] = await Promise.all([
+ prisma.agentInviteCode.findMany({
+ skip,
+ take: limit,
+ orderBy: { createdAt: 'desc' },
+ include: {
+ createdBy: { select: { id: true, name: true, email: true } },
+ },
+ }),
+ prisma.agentInviteCode.count(),
+ ]);
+
+ return { data, total, page, limit };
+}
+
+/**
+ * Revoke (delete) an unused invite code.
+ */
+export async function revokeInviteCode(codeId: string) {
+ const invite = await prisma.agentInviteCode.findUnique({ where: { id: codeId } });
+
+ if (!invite) {
+ throw new AppError(404, 'Invite code not found');
+ }
+
+ if (invite.usedAt) {
+ throw new AppError(400, 'Cannot revoke a code that has already been used');
+ }
+
+ await prisma.agentInviteCode.delete({ where: { id: codeId } });
+}
diff --git a/changemaker-control-panel/api/src/services/local-driver.ts b/changemaker-control-panel/api/src/services/local-driver.ts
new file mode 100644
index 00000000..12c32a61
--- /dev/null
+++ b/changemaker-control-panel/api/src/services/local-driver.ts
@@ -0,0 +1,130 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { promisify } from 'util';
+import { parse as parseDotenv } from 'dotenv';
+import * as docker from './docker.service';
+import type { ExecutionDriver } from './execution-driver';
+import { logger } from '../utils/logger';
+
+/**
+ * LocalDriver wraps existing docker.service.ts functions and filesystem operations.
+ * This is a zero-behavior-change adapter — all existing local instance operations
+ * pass through unchanged.
+ */
+export class LocalDriver implements ExecutionDriver {
+ // ─── Docker Compose Operations ──────────────────────────────
+
+ composeUp(projectDir: string, project: string, services?: string[]) {
+ return docker.composeUp(projectDir, project, services);
+ }
+
+ composeDown(projectDir: string, project: string, removeVolumes?: boolean) {
+ return docker.composeDown(projectDir, project, removeVolumes);
+ }
+
+ composeStop(projectDir: string, project: string) {
+ return docker.composeStop(projectDir, project);
+ }
+
+ composeRestart(projectDir: string, project: string, service?: string) {
+ return docker.composeRestart(projectDir, project, service);
+ }
+
+ composePull(projectDir: string, project: string) {
+ return docker.composePull(projectDir, project);
+ }
+
+ composeBuild(projectDir: string, project: string) {
+ return docker.composeBuild(projectDir, project);
+ }
+
+ composePs(projectDir: string, project: string) {
+ return docker.composePs(projectDir, project);
+ }
+
+ composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string) {
+ return docker.composeLogs(projectDir, project, service, tail, since);
+ }
+
+ composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record) {
+ return docker.composeExec(projectDir, project, service, command, timeoutMs, envVars);
+ }
+
+ // ─── Container Health ───────────────────────────────────────
+
+ waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number) {
+ return docker.waitForHealthy(containerName, timeoutMs, pollIntervalMs);
+ }
+
+ waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number) {
+ return docker.waitForHttp(url, timeoutMs, pollIntervalMs);
+ }
+
+ // ─── Filesystem Operations ──────────────────────────────────
+
+ async readEnvFile(basePath: string): Promise | null> {
+ try {
+ const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8');
+ return parseDotenv(Buffer.from(content));
+ } catch {
+ return null;
+ }
+ }
+
+ async writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) {
+ for (const file of files) {
+ const filePath = path.join(basePath, file.relativePath);
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, file.content, 'utf-8');
+ logger.debug(`[local-driver] Wrote ${filePath}`);
+ }
+ }
+
+ async mkdir(basePath: string, relativePath: string) {
+ await fs.mkdir(path.join(basePath, relativePath), { recursive: true });
+ }
+
+ async fileExists(basePath: string, relativePath: string): Promise {
+ try {
+ await fs.access(path.join(basePath, relativePath));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async deleteDirectory(dirPath: string) {
+ await fs.rm(dirPath, { recursive: true, force: true });
+ }
+
+ async cloneSource(basePath: string, _gitRepo: string, _gitBranch: string, excludes?: string[]) {
+ // Local provisioning uses rsync from CML_SOURCE_PATH
+ const { CML_SOURCE_PATH } = await import('../config/env').then((m) => m.env);
+ if (!CML_SOURCE_PATH) {
+ throw new Error('CML_SOURCE_PATH not configured — cannot clone source');
+ }
+
+ // SECURITY: Validate exclude entries — reject anything with shell metacharacters
+ const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;
+ const safeExcludes = (excludes || [
+ 'node_modules', '.git', '.env', 'changemaker-control-panel', '.claude',
+ 'api/dist', 'admin/dist', 'uploads', 'data',
+ ]).filter((e) => SAFE_EXCLUDE.test(e));
+
+ // SECURITY: Use execFile with args array — no shell interpolation
+ const { execFile: execFileCb } = await import('child_process');
+ const execFileAsync = promisify(execFileCb);
+ const args = ['-a', ...safeExcludes.flatMap((e) => ['--exclude', e]), `${CML_SOURCE_PATH}/`, `${basePath}/`];
+ await execFileAsync('rsync', args, { timeout: 120_000 });
+ }
+}
+
+/** Singleton local driver instance. */
+let _localDriver: LocalDriver | null = null;
+
+export function getLocalDriver(): LocalDriver {
+ if (!_localDriver) {
+ _localDriver = new LocalDriver();
+ }
+ return _localDriver;
+}
diff --git a/changemaker-control-panel/api/src/services/remote-driver.ts b/changemaker-control-panel/api/src/services/remote-driver.ts
new file mode 100644
index 00000000..5d10ca7e
--- /dev/null
+++ b/changemaker-control-panel/api/src/services/remote-driver.ts
@@ -0,0 +1,264 @@
+import https from 'https';
+import { env } from '../config/env';
+import type { ExecutionDriver } from './execution-driver';
+import { AgentUnreachableError } from './execution-driver';
+import type { ContainerInfo } from './docker.service';
+import { logger } from '../utils/logger';
+
+interface AgentRequestOptions {
+ method: 'GET' | 'POST' | 'DELETE';
+ path: string;
+ body?: unknown;
+ timeoutMs?: number;
+}
+
+/**
+ * RemoteDriver makes HTTPS calls to a remote CCP agent for all operations.
+ * Uses mTLS — both CCP (client) and agent (server) present certificates.
+ */
+export class RemoteDriver implements ExecutionDriver {
+ constructor(
+ private agentUrl: string,
+ private slug: string,
+ private clientCert: Buffer,
+ private clientKey: Buffer,
+ private caCert: Buffer
+ ) {}
+
+ // ─── HTTP Client ────────────────────────────────────────────
+
+ private async request(opts: AgentRequestOptions): Promise {
+ const url = new URL(opts.path, this.agentUrl);
+ const timeoutMs = opts.timeoutMs || env.AGENT_REQUEST_TIMEOUT_MS;
+
+ const payload = opts.body ? JSON.stringify(opts.body) : undefined;
+
+ return new Promise((resolve, reject) => {
+ const req = https.request(
+ {
+ hostname: url.hostname,
+ port: url.port || 7443,
+ path: url.pathname + url.search,
+ method: opts.method,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
+ },
+ cert: this.clientCert,
+ key: this.clientKey,
+ ca: this.caCert,
+ rejectUnauthorized: true,
+ timeout: timeoutMs,
+ },
+ (res) => {
+ let data = '';
+ res.on('data', (chunk) => (data += chunk));
+ res.on('end', () => {
+ if (res.statusCode && res.statusCode >= 400) {
+ try {
+ const err = JSON.parse(data);
+ reject(new Error(err.message || `Agent returned ${res.statusCode}`));
+ } catch {
+ reject(new Error(`Agent returned ${res.statusCode}: ${data.substring(0, 500)}`));
+ }
+ return;
+ }
+
+ try {
+ resolve(data ? JSON.parse(data) as T : (undefined as T));
+ } catch {
+ resolve(data as unknown as T);
+ }
+ });
+ }
+ );
+
+ req.on('error', (err) => {
+ reject(new AgentUnreachableError(this.agentUrl, err));
+ });
+
+ req.on('timeout', () => {
+ req.destroy();
+ reject(new AgentUnreachableError(this.agentUrl, new Error(`Timed out after ${timeoutMs}ms`)));
+ });
+
+ if (payload) req.write(payload);
+ req.end();
+ });
+ }
+
+ // ─── Docker Compose Operations ──────────────────────────────
+
+ async composeUp(_projectDir: string, _project: string, services?: string[]): Promise {
+ return this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/up`,
+ body: { services },
+ timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
+ });
+ }
+
+ async composeDown(_projectDir: string, _project: string, removeVolumes?: boolean): Promise {
+ return this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/down`,
+ body: { removeVolumes },
+ timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
+ });
+ }
+
+ async composeStop(_projectDir: string, _project: string): Promise {
+ return this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/stop`,
+ });
+ }
+
+ async composeRestart(_projectDir: string, _project: string, service?: string): Promise {
+ return this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/restart`,
+ body: { service },
+ });
+ }
+
+ async composePull(_projectDir: string, _project: string): Promise {
+ return this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/pull`,
+ timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
+ });
+ }
+
+ async composeBuild(_projectDir: string, _project: string): Promise {
+ return this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/build`,
+ timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
+ });
+ }
+
+ async composePs(_projectDir: string, _project: string): Promise {
+ return this.request({
+ method: 'GET',
+ path: `/instance/${this.slug}/ps`,
+ });
+ }
+
+ async composeLogs(_projectDir: string, _project: string, service?: string, tail?: number, since?: string): Promise {
+ const params = new URLSearchParams();
+ if (service) params.set('service', service);
+ if (tail) params.set('tail', String(tail));
+ if (since) params.set('since', since);
+ const qs = params.toString() ? `?${params}` : '';
+
+ return this.request({
+ method: 'GET',
+ path: `/instance/${this.slug}/logs${qs}`,
+ });
+ }
+
+ async composeExec(_projectDir: string, _project: string, service: string, command: string, timeoutMs?: number, envVars?: Record): Promise {
+ return this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/exec`,
+ body: { service, command, envVars },
+ timeoutMs: timeoutMs || env.AGENT_LONG_OP_TIMEOUT_MS,
+ });
+ }
+
+ // ─── Container Health ───────────────────────────────────────
+
+ async waitForHealthy(containerName: string, timeoutMs = 60_000, pollIntervalMs = 2_000): Promise {
+ // For remote instances, poll the agent's ps endpoint
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const containers = await this.composePs('', '');
+ const container = containers.find((c) => c.name.includes(containerName) || c.service === containerName);
+ if (container?.health === 'healthy') return true;
+ if (container?.state === 'exited' || container?.state === 'dead') {
+ throw new Error(`Container ${containerName} exited unexpectedly`);
+ }
+ } catch (err) {
+ if (err instanceof AgentUnreachableError) throw err;
+ // Other errors — keep polling
+ }
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
+ }
+ throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`);
+ }
+
+ async waitForHttp(url: string, timeoutMs = 120_000, pollIntervalMs = 3_000): Promise {
+ // The URL is a local URL on the remote host. We ask the agent to check it.
+ // For now, poll the agent's health endpoint for the instance.
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const containers = await this.composePs('', '');
+ const apiContainer = containers.find((c) => c.service === 'api');
+ if (apiContainer?.state === 'running' && apiContainer?.health === 'healthy') return true;
+ } catch (err) {
+ if (err instanceof AgentUnreachableError) throw err;
+ }
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
+ }
+ throw new Error(`HTTP endpoint did not respond within ${timeoutMs}ms`);
+ }
+
+ // ─── Filesystem Operations ──────────────────────────────────
+
+ async readEnvFile(_basePath: string): Promise | null> {
+ try {
+ return await this.request>({
+ method: 'GET',
+ path: `/instance/${this.slug}/env`,
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ async writeFiles(_basePath: string, files: Array<{ relativePath: string; content: string }>): Promise {
+ await this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/files`,
+ body: { files },
+ timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
+ });
+ }
+
+ async mkdir(_basePath: string, relativePath: string): Promise {
+ await this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/mkdir`,
+ body: { path: relativePath },
+ });
+ }
+
+ async fileExists(_basePath: string, relativePath: string): Promise {
+ try {
+ await this.request({
+ method: 'GET',
+ path: `/instance/${this.slug}/env`, // reuse env endpoint as a proxy for file existence
+ });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async deleteDirectory(_dirPath: string): Promise {
+ // Remote directory deletion is handled by the agent during instance unregistration
+ logger.warn('[remote-driver] deleteDirectory called — remote cleanup handled by agent');
+ }
+
+ async cloneSource(_basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise {
+ await this.request({
+ method: 'POST',
+ path: `/instance/${this.slug}/clone-source`,
+ body: { gitRepo, gitBranch, excludes },
+ timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
+ });
+ }
+}
diff --git a/changemaker-control-panel/api/src/services/template-engine.ts b/changemaker-control-panel/api/src/services/template-engine.ts
index d8020717..cb899e06 100644
--- a/changemaker-control-panel/api/src/services/template-engine.ts
+++ b/changemaker-control-panel/api/src/services/template-engine.ts
@@ -124,6 +124,7 @@ export interface InstanceForTemplate {
enableSms: boolean;
enableSocial: boolean;
enablePeople: boolean;
+ enableAnalytics: boolean;
jvbAdvertiseIp: string | null;
pangolinEndpoint: string | null;
pangolinNewtId: string | null;
@@ -293,3 +294,61 @@ export async function renderAllTemplates(context: TemplateContext, outputDir: st
export function clearTemplateCache(): void {
templateCache.clear();
}
+
+/**
+ * Render all templates in memory and return them as an array of { relativePath, content }.
+ * Used for remote instances where we can't write to the local filesystem — rendered
+ * files are sent to the remote agent via HTTP instead.
+ */
+export async function renderAllTemplatesInMemory(
+ context: TemplateContext
+): Promise> {
+ clearTemplateCache();
+ const templatesDir = path.resolve(__dirname, '../..', 'templates');
+ const result: Array<{ relativePath: string; content: string }> = [];
+
+ const templateFiles = [
+ { template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' },
+ { template: 'env.hbs', output: '.env' },
+ { template: 'nginx/conf.d/default.conf.hbs', output: 'nginx/conf.d/default.conf' },
+ { template: 'nginx/conf.d/api.conf.hbs', output: 'nginx/conf.d/api.conf' },
+ { template: 'nginx/conf.d/services.conf.hbs', output: 'nginx/conf.d/services.conf' },
+ { template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' },
+ { template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' },
+ { template: 'configs/grafana/datasources/datasources.yml.hbs', output: 'configs/grafana/datasources/datasources.yml' },
+ ];
+
+ for (const { template, output } of templateFiles) {
+ const templatePath = path.join(templatesDir, template);
+ try {
+ await fs.access(templatePath);
+ } catch {
+ logger.warn(`Template not found: ${template}, skipping`);
+ continue;
+ }
+ const rendered = await renderTemplate(template, context);
+ result.push({ relativePath: output, content: rendered });
+ }
+
+ // Read static files into memory
+ const staticFiles = [
+ 'nginx/nginx.conf',
+ 'configs/prometheus/alerts.yml',
+ 'configs/alertmanager/alertmanager.yml',
+ 'configs/grafana/dashboards/dashboards.yml',
+ 'configs/grafana/dashboards/application-overview.json',
+ 'configs/grafana/dashboards/api-performance.json',
+ 'configs/grafana/dashboards/system-health.json',
+ ];
+ for (const file of staticFiles) {
+ const srcPath = path.join(templatesDir, file);
+ try {
+ const content = await fs.readFile(srcPath, 'utf-8');
+ result.push({ relativePath: file, content });
+ } catch {
+ logger.warn(`Static file not found: ${file}, skipping`);
+ }
+ }
+
+ return result;
+}
diff --git a/config.sh b/config.sh
index ccf4f9b7..dc4105b3 100755
--- a/config.sh
+++ b/config.sh
@@ -1099,6 +1099,35 @@ pangolin_connect_site() {
fi
}
+configure_control_panel() {
+ header "Control Panel Registration"
+
+ if prompt_yes_no "Register this instance with a Changemaker Control Panel?"; then
+ echo ""
+ read -rp " Enter Control Panel URL (e.g., https://ccp.example.com): " ccp_url
+ read -rp " Enter invite code: " invite_code
+ read -rp " Agent URL (how the CCP reaches this host, e.g., https://this-host:7443): " agent_url
+
+ update_env_var "ENABLE_CCP_AGENT" "true"
+ update_env_var "CCP_URL" "$ccp_url"
+ update_env_var "CCP_INVITE_CODE" "$invite_code"
+ update_env_var "CCP_AGENT_URL" "$agent_url"
+
+ # Add ccp-agent to compose profiles
+ local existing_profiles
+ existing_profiles=$(grep -oP 'COMPOSE_PROFILES=\K.*' "$ENV_FILE" 2>/dev/null || echo "")
+ if [[ -n "$existing_profiles" ]]; then
+ update_env_var "COMPOSE_PROFILES" "${existing_profiles},ccp-agent"
+ else
+ update_env_var "COMPOSE_PROFILES" "ccp-agent"
+ fi
+
+ success "Control Panel registration configured — agent will phone home on startup"
+ else
+ update_env_var "ENABLE_CCP_AGENT" "false"
+ fi
+}
+
configure_cors() {
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
# Include app subdomain + root domain (for MkDocs payment widgets) + localhost fallbacks
@@ -1810,6 +1839,7 @@ main() {
configure_smtp
configure_features
configure_pangolin
+ configure_control_panel
configure_cors
generate_nginx_configs
generate_services_yaml
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index c98d7ae3..0115d8ca 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -1323,6 +1323,34 @@ services:
profiles:
- monitoring
+ # =========================================================================
+ # CCP REMOTE AGENT (optional — enabled via COMPOSE_PROFILES=ccp-agent)
+ # =========================================================================
+
+ ccp-agent:
+ image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/ccp-agent:${IMAGE_TAG:-latest}
+ container_name: ${COMPOSE_PROJECT_NAME:-changemaker-lite}-ccp-agent
+ restart: unless-stopped
+ profiles: ["ccp-agent"]
+ ports:
+ - "${CCP_AGENT_PORT:-7443}:7443"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ccp-agent-data:/var/lib/ccp-agent
+ - ccp-agent-certs:/etc/ccp-agent
+ environment:
+ - AGENT_PORT=7443
+ - AGENT_DATA_DIR=/var/lib/ccp-agent
+ - CCP_URL=${CCP_URL:-}
+ - CCP_INVITE_CODE=${CCP_INVITE_CODE:-}
+ - CCP_AGENT_URL=${CCP_AGENT_URL:-}
+ - INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
+ - INSTANCE_DOMAIN=${DOMAIN:-localhost}
+ - INSTANCE_BASE_PATH=/app/instance
+ logging: *default-logging
+ networks:
+ - changemaker-lite
+
# =============================================================================
# NETWORKS & VOLUMES
# =============================================================================
@@ -1359,3 +1387,6 @@ volumes:
grafana-data:
alertmanager-data:
gotify-data:
+ # CCP Agent
+ ccp-agent-data:
+ ccp-agent-certs:
diff --git a/docker-compose.yml b/docker-compose.yml
index a3a1eda6..61f7149a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1348,6 +1348,37 @@ services:
profiles:
- monitoring
+ # =========================================================================
+ # CCP REMOTE AGENT (optional — enabled via COMPOSE_PROFILES=ccp-agent)
+ # =========================================================================
+
+ ccp-agent:
+ build:
+ context: ./changemaker-control-panel/agent
+ dockerfile: Dockerfile
+ container_name: ${COMPOSE_PROJECT_NAME:-changemaker-lite}-ccp-agent
+ restart: unless-stopped
+ profiles: ["ccp-agent"]
+ ports:
+ - "${CCP_AGENT_PORT:-7443}:7443"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ccp-agent-data:/var/lib/ccp-agent
+ - ccp-agent-certs:/etc/ccp-agent
+ - .:/app/instance:ro
+ environment:
+ - AGENT_PORT=7443
+ - AGENT_DATA_DIR=/var/lib/ccp-agent
+ - CCP_URL=${CCP_URL:-}
+ - CCP_INVITE_CODE=${CCP_INVITE_CODE:-}
+ - CCP_AGENT_URL=${CCP_AGENT_URL:-}
+ - INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
+ - INSTANCE_DOMAIN=${DOMAIN:-localhost}
+ - INSTANCE_BASE_PATH=/app/instance
+ logging: *default-logging
+ networks:
+ - changemaker-lite
+
# =============================================================================
# NETWORKS & VOLUMES
# =============================================================================
@@ -1384,3 +1415,6 @@ volumes:
grafana-data:
alertmanager-data:
gotify-data:
+ # CCP Agent
+ ccp-agent-data:
+ ccp-agent-certs: