diff --git a/.env.example b/.env.example index c9be760c..99421177 100644 --- a/.env.example +++ b/.env.example @@ -403,6 +403,26 @@ SMS_MAX_RETRIES=3 SMS_RESPONSE_SYNC_INTERVAL_MS=120000 SMS_DEVICE_MONITOR_INTERVAL_MS=300000 +# --- Social, People & Analytics --- +# ENABLE_SOCIAL is the initial default; once saved in admin Settings, the DB value is authoritative +ENABLE_SOCIAL=false +# ENABLE_PEOPLE is the initial default; once saved in admin Settings, the DB value is authoritative +ENABLE_PEOPLE=false +# ENABLE_ANALYTICS is the initial default; once saved in admin Settings, the DB value is authoritative +ENABLE_ANALYTICS=false + +# --- Control Panel Agent --- +# Set to true to enable the CCP remote management agent +ENABLE_CCP_AGENT=false +# URL of the Changemaker Control Panel +CCP_URL= +# One-time invite code for registration +CCP_INVITE_CODE= +# How the CCP can reach this agent (must be externally accessible) +CCP_AGENT_URL= +# Agent port (default 7443) +CCP_AGENT_PORT=7443 + # --- Monitoring (only used with --profile monitoring) --- PROMETHEUS_PORT=9090 GRAFANA_PORT=3005 diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 20c52cb6..55377b18 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1025,7 +1025,7 @@ model MapSettings { qrCode2Label String? qrCode3Url String? qrCode3Label String? - publicMapEnabled Boolean @default(true) + publicMapEnabled Boolean @default(false) publicShowLocations Boolean @default(true) publicShowSupportLevels Boolean @default(true) publicShowCuts Boolean @default(true) @@ -1087,7 +1087,7 @@ model SiteSettings { // Feature toggles enableInfluence Boolean @default(true) - enableMap Boolean @default(true) + enableMap Boolean @default(false) enableNewsletter Boolean @default(true) enableLandingPages Boolean @default(true) enableMediaFeatures Boolean @default(true) @map("enable_media_features") diff --git a/api/prisma/seed.ts b/api/prisma/seed.ts index 0de35c9a..808f0ead 100644 --- a/api/prisma/seed.ts +++ b/api/prisma/seed.ts @@ -102,6 +102,17 @@ async function main() { smtpActiveProvider: isMailhog ? 'mailhog' : 'production', emailTestMode: env.EMAIL_TEST_MODE === 'true', testEmailRecipient: env.TEST_EMAIL_RECIPIENT, + // Feature flags from .env (DB authoritative once admin saves settings) + enableMediaFeatures: env.ENABLE_MEDIA_FEATURES !== 'false', + enableChat: env.ENABLE_CHAT === 'true', + enableMeet: env.ENABLE_MEET === 'true', + enableSms: env.ENABLE_SMS === 'true', + enablePayments: env.ENABLE_PAYMENTS === 'true', + enableSocial: env.ENABLE_SOCIAL === 'true', + enablePeople: env.ENABLE_PEOPLE === 'true', + enableAnalytics: env.ENABLE_ANALYTICS === 'true', + enableEvents: env.GANCIO_SYNC_ENABLED === 'true', + enableNewsletter: env.LISTMONK_SYNC_ENABLED === 'true', navConfig: { items: [ { id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true }, diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 8fd388f9..cb8acdb4 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -207,6 +207,11 @@ const envSchema = z.object({ // SMS Campaigns (Termux Android bridge) ENABLE_SMS: z.string().default('false'), + + // Social, People, Analytics (initial defaults; DB authoritative once admin saves) + ENABLE_SOCIAL: z.string().default('false'), + ENABLE_PEOPLE: z.string().default('false'), + ENABLE_ANALYTICS: z.string().default('false'), TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'), TERMUX_API_KEY: z.string().default(''), SMS_DELAY_BETWEEN_MS: z.coerce.number().default(3000), diff --git a/changemaker-control-panel/admin/src/App.tsx b/changemaker-control-panel/admin/src/App.tsx index a66e2cf6..b6b03144 100644 --- a/changemaker-control-panel/admin/src/App.tsx +++ b/changemaker-control-panel/admin/src/App.tsx @@ -10,6 +10,8 @@ import InstanceListPage from '@/pages/InstanceListPage'; import CreateWizardPage from '@/pages/CreateWizardPage'; import InstanceDetailPage from '@/pages/InstanceDetailPage'; import RegisterInstancePage from '@/pages/RegisterInstancePage'; +import AgentRegistrationsPage from '@/pages/AgentRegistrationsPage'; +import InviteCodesPage from '@/pages/InviteCodesPage'; import BackupsPage from '@/pages/BackupsPage'; import AuditLogPage from '@/pages/AuditLogPage'; import SettingsPage from '@/pages/SettingsPage'; @@ -58,6 +60,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/changemaker-control-panel/admin/src/components/AppLayout.tsx b/changemaker-control-panel/admin/src/components/AppLayout.tsx index c5e6ae15..7c8cde07 100644 --- a/changemaker-control-panel/admin/src/components/AppLayout.tsx +++ b/changemaker-control-panel/admin/src/components/AppLayout.tsx @@ -11,6 +11,8 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, MenuOutlined, + ApiOutlined, + KeyOutlined, } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth.store'; @@ -42,6 +44,15 @@ export default function AppLayout() { icon: , label: 'Backups', }, + { + key: '/app/agents', + icon: , + label: 'Remote Agents', + children: [ + { key: '/app/agents/registrations', icon: , label: 'Registrations' }, + { key: '/app/agents/invite-codes', icon: , label: 'Invite Codes' }, + ], + }, { key: '/app/audit', icon: , @@ -56,7 +67,13 @@ export default function AppLayout() { // Use startsWith matching with longest-match preference so sub-routes // like /app/instances/123 highlight the "Instances" menu item, not Dashboard. - const selectedKey = menuItems + // Flatten children for matching. + const allKeys = menuItems.flatMap((item) => + 'children' in item && item.children + ? item.children.map((c) => ({ key: c.key as string })) + : [{ key: item.key as string }] + ); + const selectedKey = allKeys .filter((item) => location.pathname === item.key || location.pathname.startsWith(item.key + '/')) .sort((a, b) => b.key.length - a.key.length)[0]?.key || '/app'; diff --git a/changemaker-control-panel/admin/src/pages/AgentRegistrationsPage.tsx b/changemaker-control-panel/admin/src/pages/AgentRegistrationsPage.tsx new file mode 100644 index 00000000..05eef6b8 --- /dev/null +++ b/changemaker-control-panel/admin/src/pages/AgentRegistrationsPage.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Typography, Button, Table, Tag, Space, message, Card, Modal, Descriptions, Alert } from 'antd'; +import { CheckOutlined, CloseOutlined, ReloadOutlined } from '@ant-design/icons'; +import { api } from '@/lib/api'; +import type { AgentRegistration } from '@/types/api'; + +const { Title } = Typography; + +export default function AgentRegistrationsPage() { + const [registrations, setRegistrations] = useState([]); + const [loading, setLoading] = useState(true); + const [detailModal, setDetailModal] = useState(null); + + const fetchRegistrations = useCallback(async () => { + try { + setLoading(true); + const { data } = await api.get('/api/agents/registrations'); + setRegistrations(data); + } catch { + message.error('Failed to load registrations'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchRegistrations(); }, [fetchRegistrations]); + + const handleApprove = async (id: string) => { + try { + await api.post(`/api/agents/registrations/${id}/approve`); + message.success('Registration approved — agent will receive certificates on next poll'); + fetchRegistrations(); + setDetailModal(null); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + message.error(error?.response?.data?.message || 'Failed to approve'); + } + }; + + const handleReject = async (id: string) => { + try { + await api.post(`/api/agents/registrations/${id}/reject`); + message.success('Registration rejected'); + fetchRegistrations(); + setDetailModal(null); + } catch { + message.error('Failed to reject'); + } + }; + + const statusColor: Record = { + PENDING: 'orange', + APPROVED: 'green', + REJECTED: 'red', + EXPIRED: 'default', + }; + + const columns = [ + { title: 'Slug', dataIndex: 'slug', key: 'slug' }, + { title: 'Domain', dataIndex: 'domain', key: 'domain' }, + { title: 'Agent URL', dataIndex: 'agentUrl', key: 'agentUrl' }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: string) => {status}, + }, + { + title: 'Submitted', + dataIndex: 'createdAt', + key: 'createdAt', + render: (date: string) => new Date(date).toLocaleString(), + }, + { + title: 'Actions', + key: 'actions', + render: (_: unknown, record: AgentRegistration) => ( + + + {record.status === 'PENDING' && ( + <> + + + + )} + + ), + }, + ]; + + const pendingCount = registrations.filter(r => r.status === 'PENDING').length; + + return ( +
+ +
+ + Agent Registrations + {pendingCount > 0 && <Tag color="orange" style={{ marginLeft: 8 }}>{pendingCount} pending</Tag>} + + +
+ + {pendingCount > 0 && ( + 1 ? 's' : ''} awaiting approval`} + description="Review the agent details below and approve or reject the registration. Approved agents will receive mTLS certificates automatically." + showIcon + /> + )} + + + + + + + setDetailModal(null)} + footer={detailModal?.status === 'PENDING' ? [ + , + , + ] : null} + width={600} + > + {detailModal && ( + + {detailModal.slug} + {detailModal.name} + {detailModal.domain} + {detailModal.agentUrl} + {detailModal.basePath} + {detailModal.composeProject} + + {detailModal.status} + + {new Date(detailModal.createdAt).toLocaleString()} + {detailModal.approvedAt && ( + {new Date(detailModal.approvedAt).toLocaleString()} + )} + + )} + + + ); +} diff --git a/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx b/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx index c23d518a..140a9107 100644 --- a/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx +++ b/changemaker-control-panel/admin/src/pages/CreateWizardPage.tsx @@ -37,6 +37,7 @@ interface WizardData { enableSms: boolean; enableSocial: boolean; enablePeople: boolean; + enableAnalytics: boolean; jvbAdvertiseIp: string; smtpHost: string; smtpPort: number; @@ -66,6 +67,7 @@ const defaultData: WizardData = { enableSms: false, enableSocial: false, enablePeople: false, + enableAnalytics: false, jvbAdvertiseIp: '', smtpHost: '', smtpPort: 587, @@ -246,10 +248,10 @@ export default function CreateWizardPage() { Code Server, Gitea, n8n, Homepage, Excalidraw - + update({ enablePayments: v })} /> - Vaultwarden (secrets vault, future) + Products, donations, subscriptions, ticketed events diff --git a/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx index 2caa2b29..1269ac50 100644 --- a/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx +++ b/changemaker-control-panel/admin/src/pages/InstanceDetailPage.tsx @@ -405,6 +405,7 @@ export default function InstanceDetailPage() { enableSms: instance.enableSms, enableSocial: instance.enableSocial, enablePeople: instance.enablePeople, + enableAnalytics: instance.enableAnalytics, }); tunnelForm.setFieldsValue({ pangolinEndpoint: instance.pangolinEndpoint || '', @@ -492,7 +493,8 @@ export default function InstanceDetailPage() { featureFlags.enableMeet !== instance.enableMeet || featureFlags.enableSms !== instance.enableSms || featureFlags.enableSocial !== instance.enableSocial || - featureFlags.enablePeople !== instance.enablePeople + featureFlags.enablePeople !== instance.enablePeople || + featureFlags.enableAnalytics !== instance.enableAnalytics ) : false; if (loading || !instance) { @@ -821,7 +823,7 @@ export default function InstanceDetailPage() { {isRegistered && ( @@ -850,6 +852,19 @@ export default function InstanceDetailPage() { /> +
+
+ 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} + + + ); + }, + }, + ]; + + return ( +
+ +
+ Agent Invite Codes + +
+ + + + +
+ + + + ); +} 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: