@synth-deploy/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/dist/agent/debrief-retention.d.ts +12 -0
  2. package/dist/agent/debrief-retention.d.ts.map +1 -0
  3. package/dist/agent/debrief-retention.js +27 -0
  4. package/dist/agent/debrief-retention.js.map +1 -0
  5. package/dist/agent/envoy-client.d.ts +216 -0
  6. package/dist/agent/envoy-client.d.ts.map +1 -0
  7. package/dist/agent/envoy-client.js +266 -0
  8. package/dist/agent/envoy-client.js.map +1 -0
  9. package/dist/agent/envoy-registry.d.ts +102 -0
  10. package/dist/agent/envoy-registry.d.ts.map +1 -0
  11. package/dist/agent/envoy-registry.js +319 -0
  12. package/dist/agent/envoy-registry.js.map +1 -0
  13. package/dist/agent/health-checker.d.ts +39 -0
  14. package/dist/agent/health-checker.d.ts.map +1 -0
  15. package/dist/agent/health-checker.js +49 -0
  16. package/dist/agent/health-checker.js.map +1 -0
  17. package/dist/agent/mcp-client-manager.d.ts +36 -0
  18. package/dist/agent/mcp-client-manager.d.ts.map +1 -0
  19. package/dist/agent/mcp-client-manager.js +106 -0
  20. package/dist/agent/mcp-client-manager.js.map +1 -0
  21. package/dist/agent/stale-deployment-detector.d.ts +15 -0
  22. package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
  23. package/dist/agent/stale-deployment-detector.js +50 -0
  24. package/dist/agent/stale-deployment-detector.js.map +1 -0
  25. package/dist/agent/step-runner.d.ts +31 -0
  26. package/dist/agent/step-runner.d.ts.map +1 -0
  27. package/dist/agent/step-runner.js +80 -0
  28. package/dist/agent/step-runner.js.map +1 -0
  29. package/dist/agent/synth-agent.d.ts +168 -0
  30. package/dist/agent/synth-agent.d.ts.map +1 -0
  31. package/dist/agent/synth-agent.js +1195 -0
  32. package/dist/agent/synth-agent.js.map +1 -0
  33. package/dist/api/agent.d.ts +36 -0
  34. package/dist/api/agent.d.ts.map +1 -0
  35. package/dist/api/agent.js +867 -0
  36. package/dist/api/agent.js.map +1 -0
  37. package/dist/api/api-keys.d.ts +4 -0
  38. package/dist/api/api-keys.d.ts.map +1 -0
  39. package/dist/api/api-keys.js +118 -0
  40. package/dist/api/api-keys.js.map +1 -0
  41. package/dist/api/artifacts.d.ts +5 -0
  42. package/dist/api/artifacts.d.ts.map +1 -0
  43. package/dist/api/artifacts.js +142 -0
  44. package/dist/api/artifacts.js.map +1 -0
  45. package/dist/api/auth.d.ts +4 -0
  46. package/dist/api/auth.d.ts.map +1 -0
  47. package/dist/api/auth.js +280 -0
  48. package/dist/api/auth.js.map +1 -0
  49. package/dist/api/deployments.d.ts +11 -0
  50. package/dist/api/deployments.d.ts.map +1 -0
  51. package/dist/api/deployments.js +1098 -0
  52. package/dist/api/deployments.js.map +1 -0
  53. package/dist/api/environments.d.ts +5 -0
  54. package/dist/api/environments.d.ts.map +1 -0
  55. package/dist/api/environments.js +69 -0
  56. package/dist/api/environments.js.map +1 -0
  57. package/dist/api/envoy-reports.d.ts +17 -0
  58. package/dist/api/envoy-reports.d.ts.map +1 -0
  59. package/dist/api/envoy-reports.js +138 -0
  60. package/dist/api/envoy-reports.js.map +1 -0
  61. package/dist/api/envoys.d.ts +5 -0
  62. package/dist/api/envoys.d.ts.map +1 -0
  63. package/dist/api/envoys.js +192 -0
  64. package/dist/api/envoys.js.map +1 -0
  65. package/dist/api/fleet.d.ts +11 -0
  66. package/dist/api/fleet.d.ts.map +1 -0
  67. package/dist/api/fleet.js +394 -0
  68. package/dist/api/fleet.js.map +1 -0
  69. package/dist/api/graph.d.ts +8 -0
  70. package/dist/api/graph.d.ts.map +1 -0
  71. package/dist/api/graph.js +355 -0
  72. package/dist/api/graph.js.map +1 -0
  73. package/dist/api/health.d.ts +20 -0
  74. package/dist/api/health.d.ts.map +1 -0
  75. package/dist/api/health.js +248 -0
  76. package/dist/api/health.js.map +1 -0
  77. package/dist/api/idp-schemas.d.ts +41 -0
  78. package/dist/api/idp-schemas.d.ts.map +1 -0
  79. package/dist/api/idp-schemas.js +17 -0
  80. package/dist/api/idp-schemas.js.map +1 -0
  81. package/dist/api/idp.d.ts +6 -0
  82. package/dist/api/idp.d.ts.map +1 -0
  83. package/dist/api/idp.js +620 -0
  84. package/dist/api/idp.js.map +1 -0
  85. package/dist/api/intake.d.ts +10 -0
  86. package/dist/api/intake.d.ts.map +1 -0
  87. package/dist/api/intake.js +418 -0
  88. package/dist/api/intake.js.map +1 -0
  89. package/dist/api/partitions.d.ts +5 -0
  90. package/dist/api/partitions.d.ts.map +1 -0
  91. package/dist/api/partitions.js +113 -0
  92. package/dist/api/partitions.js.map +1 -0
  93. package/dist/api/progress-event-store.d.ts +62 -0
  94. package/dist/api/progress-event-store.d.ts.map +1 -0
  95. package/dist/api/progress-event-store.js +118 -0
  96. package/dist/api/progress-event-store.js.map +1 -0
  97. package/dist/api/schemas.d.ts +1000 -0
  98. package/dist/api/schemas.d.ts.map +1 -0
  99. package/dist/api/schemas.js +328 -0
  100. package/dist/api/schemas.js.map +1 -0
  101. package/dist/api/security-boundaries.d.ts +4 -0
  102. package/dist/api/security-boundaries.d.ts.map +1 -0
  103. package/dist/api/security-boundaries.js +32 -0
  104. package/dist/api/security-boundaries.js.map +1 -0
  105. package/dist/api/settings.d.ts +4 -0
  106. package/dist/api/settings.d.ts.map +1 -0
  107. package/dist/api/settings.js +99 -0
  108. package/dist/api/settings.js.map +1 -0
  109. package/dist/api/system.d.ts +75 -0
  110. package/dist/api/system.d.ts.map +1 -0
  111. package/dist/api/system.js +558 -0
  112. package/dist/api/system.js.map +1 -0
  113. package/dist/api/telemetry.d.ts +4 -0
  114. package/dist/api/telemetry.d.ts.map +1 -0
  115. package/dist/api/telemetry.js +24 -0
  116. package/dist/api/telemetry.js.map +1 -0
  117. package/dist/api/users.d.ts +4 -0
  118. package/dist/api/users.d.ts.map +1 -0
  119. package/dist/api/users.js +173 -0
  120. package/dist/api/users.js.map +1 -0
  121. package/dist/archive-unpacker.d.ts +24 -0
  122. package/dist/archive-unpacker.d.ts.map +1 -0
  123. package/dist/archive-unpacker.js +239 -0
  124. package/dist/archive-unpacker.js.map +1 -0
  125. package/dist/artifact-analyzer.d.ts +59 -0
  126. package/dist/artifact-analyzer.d.ts.map +1 -0
  127. package/dist/artifact-analyzer.js +334 -0
  128. package/dist/artifact-analyzer.js.map +1 -0
  129. package/dist/auth/idp/index.d.ts +9 -0
  130. package/dist/auth/idp/index.d.ts.map +1 -0
  131. package/dist/auth/idp/index.js +5 -0
  132. package/dist/auth/idp/index.js.map +1 -0
  133. package/dist/auth/idp/ldap.d.ts +56 -0
  134. package/dist/auth/idp/ldap.d.ts.map +1 -0
  135. package/dist/auth/idp/ldap.js +276 -0
  136. package/dist/auth/idp/ldap.js.map +1 -0
  137. package/dist/auth/idp/oidc.d.ts +27 -0
  138. package/dist/auth/idp/oidc.d.ts.map +1 -0
  139. package/dist/auth/idp/oidc.js +97 -0
  140. package/dist/auth/idp/oidc.js.map +1 -0
  141. package/dist/auth/idp/role-mapping.d.ts +9 -0
  142. package/dist/auth/idp/role-mapping.d.ts.map +1 -0
  143. package/dist/auth/idp/role-mapping.js +16 -0
  144. package/dist/auth/idp/role-mapping.js.map +1 -0
  145. package/dist/auth/idp/saml.d.ts +40 -0
  146. package/dist/auth/idp/saml.d.ts.map +1 -0
  147. package/dist/auth/idp/saml.js +117 -0
  148. package/dist/auth/idp/saml.js.map +1 -0
  149. package/dist/auth/idp/types.d.ts +23 -0
  150. package/dist/auth/idp/types.d.ts.map +1 -0
  151. package/dist/auth/idp/types.js +2 -0
  152. package/dist/auth/idp/types.js.map +1 -0
  153. package/dist/fleet/fleet-executor.d.ts +35 -0
  154. package/dist/fleet/fleet-executor.d.ts.map +1 -0
  155. package/dist/fleet/fleet-executor.js +228 -0
  156. package/dist/fleet/fleet-executor.js.map +1 -0
  157. package/dist/fleet/fleet-store.d.ts +13 -0
  158. package/dist/fleet/fleet-store.d.ts.map +1 -0
  159. package/dist/fleet/fleet-store.js +13 -0
  160. package/dist/fleet/fleet-store.js.map +1 -0
  161. package/dist/fleet/index.d.ts +5 -0
  162. package/dist/fleet/index.d.ts.map +1 -0
  163. package/dist/fleet/index.js +4 -0
  164. package/dist/fleet/index.js.map +1 -0
  165. package/dist/fleet/representative-selector.d.ts +15 -0
  166. package/dist/fleet/representative-selector.d.ts.map +1 -0
  167. package/dist/fleet/representative-selector.js +71 -0
  168. package/dist/fleet/representative-selector.js.map +1 -0
  169. package/dist/graph/graph-executor.d.ts +36 -0
  170. package/dist/graph/graph-executor.d.ts.map +1 -0
  171. package/dist/graph/graph-executor.js +348 -0
  172. package/dist/graph/graph-executor.js.map +1 -0
  173. package/dist/graph/graph-inference.d.ts +22 -0
  174. package/dist/graph/graph-inference.d.ts.map +1 -0
  175. package/dist/graph/graph-inference.js +149 -0
  176. package/dist/graph/graph-inference.js.map +1 -0
  177. package/dist/graph/graph-store.d.ts +12 -0
  178. package/dist/graph/graph-store.d.ts.map +1 -0
  179. package/dist/graph/graph-store.js +61 -0
  180. package/dist/graph/graph-store.js.map +1 -0
  181. package/dist/graph/index.d.ts +5 -0
  182. package/dist/graph/index.d.ts.map +1 -0
  183. package/dist/graph/index.js +4 -0
  184. package/dist/graph/index.js.map +1 -0
  185. package/dist/index.d.ts +2 -0
  186. package/dist/index.d.ts.map +1 -0
  187. package/dist/index.js +837 -0
  188. package/dist/index.js.map +1 -0
  189. package/dist/intake/index.d.ts +6 -0
  190. package/dist/intake/index.d.ts.map +1 -0
  191. package/dist/intake/index.js +5 -0
  192. package/dist/intake/index.js.map +1 -0
  193. package/dist/intake/intake-processor.d.ts +17 -0
  194. package/dist/intake/intake-processor.d.ts.map +1 -0
  195. package/dist/intake/intake-processor.js +99 -0
  196. package/dist/intake/intake-processor.js.map +1 -0
  197. package/dist/intake/intake-store.d.ts +7 -0
  198. package/dist/intake/intake-store.d.ts.map +1 -0
  199. package/dist/intake/intake-store.js +7 -0
  200. package/dist/intake/intake-store.js.map +1 -0
  201. package/dist/intake/registry-poller.d.ts +41 -0
  202. package/dist/intake/registry-poller.d.ts.map +1 -0
  203. package/dist/intake/registry-poller.js +202 -0
  204. package/dist/intake/registry-poller.js.map +1 -0
  205. package/dist/intake/webhook-handlers.d.ts +37 -0
  206. package/dist/intake/webhook-handlers.d.ts.map +1 -0
  207. package/dist/intake/webhook-handlers.js +268 -0
  208. package/dist/intake/webhook-handlers.js.map +1 -0
  209. package/dist/logger.d.ts +5 -0
  210. package/dist/logger.d.ts.map +1 -0
  211. package/dist/logger.js +15 -0
  212. package/dist/logger.js.map +1 -0
  213. package/dist/mcp/resources.d.ts +9 -0
  214. package/dist/mcp/resources.d.ts.map +1 -0
  215. package/dist/mcp/resources.js +72 -0
  216. package/dist/mcp/resources.js.map +1 -0
  217. package/dist/mcp/server.d.ts +15 -0
  218. package/dist/mcp/server.d.ts.map +1 -0
  219. package/dist/mcp/server.js +20 -0
  220. package/dist/mcp/server.js.map +1 -0
  221. package/dist/mcp/tools.d.ts +9 -0
  222. package/dist/mcp/tools.d.ts.map +1 -0
  223. package/dist/mcp/tools.js +88 -0
  224. package/dist/mcp/tools.js.map +1 -0
  225. package/dist/middleware/auth.d.ts +29 -0
  226. package/dist/middleware/auth.d.ts.map +1 -0
  227. package/dist/middleware/auth.js +76 -0
  228. package/dist/middleware/auth.js.map +1 -0
  229. package/dist/middleware/permissions.d.ts +13 -0
  230. package/dist/middleware/permissions.d.ts.map +1 -0
  231. package/dist/middleware/permissions.js +32 -0
  232. package/dist/middleware/permissions.js.map +1 -0
  233. package/dist/pattern-store.d.ts +104 -0
  234. package/dist/pattern-store.d.ts.map +1 -0
  235. package/dist/pattern-store.js +299 -0
  236. package/dist/pattern-store.js.map +1 -0
  237. package/package.json +54 -0
  238. package/src/agent/debrief-retention.ts +44 -0
  239. package/src/agent/envoy-client.ts +474 -0
  240. package/src/agent/envoy-registry.ts +384 -0
  241. package/src/agent/health-checker.ts +70 -0
  242. package/src/agent/mcp-client-manager.ts +131 -0
  243. package/src/agent/stale-deployment-detector.ts +79 -0
  244. package/src/agent/step-runner.ts +124 -0
  245. package/src/agent/synth-agent.ts +1567 -0
  246. package/src/api/agent.ts +1075 -0
  247. package/src/api/api-keys.ts +129 -0
  248. package/src/api/artifacts.ts +194 -0
  249. package/src/api/auth.ts +320 -0
  250. package/src/api/deployments.ts +1347 -0
  251. package/src/api/environments.ts +97 -0
  252. package/src/api/envoy-reports.ts +159 -0
  253. package/src/api/envoys.ts +237 -0
  254. package/src/api/fleet.ts +510 -0
  255. package/src/api/graph.ts +516 -0
  256. package/src/api/health.ts +311 -0
  257. package/src/api/idp-schemas.ts +19 -0
  258. package/src/api/idp.ts +735 -0
  259. package/src/api/intake.ts +537 -0
  260. package/src/api/partitions.ts +147 -0
  261. package/src/api/progress-event-store.ts +153 -0
  262. package/src/api/schemas.ts +376 -0
  263. package/src/api/security-boundaries.ts +54 -0
  264. package/src/api/settings.ts +118 -0
  265. package/src/api/system.ts +704 -0
  266. package/src/api/telemetry.ts +32 -0
  267. package/src/api/users.ts +210 -0
  268. package/src/archive-unpacker.ts +271 -0
  269. package/src/artifact-analyzer.ts +438 -0
  270. package/src/auth/idp/index.ts +8 -0
  271. package/src/auth/idp/ldap.ts +340 -0
  272. package/src/auth/idp/oidc.ts +117 -0
  273. package/src/auth/idp/role-mapping.ts +22 -0
  274. package/src/auth/idp/saml.ts +148 -0
  275. package/src/auth/idp/types.ts +22 -0
  276. package/src/fleet/fleet-executor.ts +309 -0
  277. package/src/fleet/fleet-store.ts +13 -0
  278. package/src/fleet/index.ts +4 -0
  279. package/src/fleet/representative-selector.ts +83 -0
  280. package/src/graph/graph-executor.ts +446 -0
  281. package/src/graph/graph-inference.ts +184 -0
  282. package/src/graph/graph-store.ts +75 -0
  283. package/src/graph/index.ts +4 -0
  284. package/src/index.ts +916 -0
  285. package/src/intake/index.ts +5 -0
  286. package/src/intake/intake-processor.ts +111 -0
  287. package/src/intake/intake-store.ts +7 -0
  288. package/src/intake/registry-poller.ts +230 -0
  289. package/src/intake/webhook-handlers.ts +328 -0
  290. package/src/logger.ts +19 -0
  291. package/src/mcp/resources.ts +98 -0
  292. package/src/mcp/server.ts +34 -0
  293. package/src/mcp/tools.ts +117 -0
  294. package/src/middleware/auth.ts +103 -0
  295. package/src/middleware/permissions.ts +35 -0
  296. package/src/pattern-store.ts +409 -0
  297. package/tests/agent-mode.test.ts +536 -0
  298. package/tests/api-handlers.test.ts +1245 -0
  299. package/tests/archive-unpacker.test.ts +179 -0
  300. package/tests/artifact-analyzer.test.ts +240 -0
  301. package/tests/auth-middleware.test.ts +189 -0
  302. package/tests/decision-diary.test.ts +957 -0
  303. package/tests/diary-reader.test.ts +782 -0
  304. package/tests/envoy-client.test.ts +342 -0
  305. package/tests/envoy-reports.test.ts +156 -0
  306. package/tests/mcp-tools.test.ts +213 -0
  307. package/tests/orchestration.test.ts +536 -0
  308. package/tests/partition-deletion.test.ts +143 -0
  309. package/tests/partition-isolation.test.ts +830 -0
  310. package/tests/pattern-store.test.ts +371 -0
  311. package/tests/rbac-enforcement.test.ts +409 -0
  312. package/tests/ssrf-validation.test.ts +56 -0
  313. package/tests/stale-deployment.test.ts +85 -0
  314. package/tests/step-runner.test.ts +308 -0
  315. package/tests/ui-journey.test.ts +330 -0
  316. package/tsconfig.json +11 -0
  317. package/vitest.config.ts +27 -0
@@ -0,0 +1,536 @@
1
+ import { describe, it, expect, beforeAll } from "vitest";
2
+ import Fastify from "fastify";
3
+ import type { FastifyInstance } from "fastify";
4
+ import { DecisionDebrief, PartitionStore, EnvironmentStore, ArtifactStore, SettingsStore, TelemetryStore, LlmClient } from "@synth-deploy/core";
5
+ import type { LlmResult } from "@synth-deploy/core";
6
+ import { SynthAgent, InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
7
+ import { registerDeploymentRoutes } from "../src/api/deployments.js";
8
+ import { registerPartitionRoutes } from "../src/api/partitions.js";
9
+ import { registerEnvironmentRoutes } from "../src/api/environments.js";
10
+ import { registerArtifactRoutes } from "../src/api/artifacts.js";
11
+ import { registerAgentRoutes, sanitizeUserInput, validateExtractedVersion, validateExtractedVariables } from "../src/api/agent.js";
12
+ import { registerSettingsRoutes } from "../src/api/settings.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock auth — inject a test user with all permissions on every request
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function addMockAuth(app: FastifyInstance) {
19
+ app.addHook("onRequest", async (request) => {
20
+ request.user = {
21
+ id: "test-user-id" as any,
22
+ email: "test@example.com",
23
+ name: "Test User",
24
+ permissions: [
25
+ "deployment.create", "deployment.approve", "deployment.reject", "deployment.view", "deployment.rollback",
26
+ "artifact.create", "artifact.update", "artifact.annotate", "artifact.delete", "artifact.view",
27
+ "environment.create", "environment.update", "environment.delete", "environment.view",
28
+ "partition.create", "partition.update", "partition.delete", "partition.view",
29
+ "envoy.register", "envoy.configure", "envoy.view",
30
+ "settings.manage", "users.manage", "roles.manage",
31
+ ],
32
+ };
33
+ });
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Test server setup
38
+ // ---------------------------------------------------------------------------
39
+
40
+ let app: FastifyInstance;
41
+ let diary: DecisionDebrief;
42
+ let partitions: PartitionStore;
43
+ let environments: EnvironmentStore;
44
+ let deployments: InMemoryDeploymentStore;
45
+ let artifactStore: ArtifactStore;
46
+ let settings: SettingsStore;
47
+ let telemetry: TelemetryStore;
48
+ let agent: SynthAgent;
49
+
50
+ let artifactId: string;
51
+ let partitionId: string;
52
+ let productionEnvId: string;
53
+ let stagingEnvId: string;
54
+
55
+ beforeAll(async () => {
56
+ diary = new DecisionDebrief();
57
+ partitions = new PartitionStore();
58
+ environments = new EnvironmentStore();
59
+ deployments = new InMemoryDeploymentStore();
60
+ artifactStore = new ArtifactStore();
61
+ settings = new SettingsStore();
62
+ telemetry = new TelemetryStore();
63
+ agent = new SynthAgent(
64
+ diary, deployments, artifactStore, environments, partitions,
65
+ undefined, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
66
+ );
67
+
68
+ app = Fastify();
69
+ addMockAuth(app);
70
+ registerDeploymentRoutes(app, deployments, diary, partitions, environments, artifactStore, settings, telemetry);
71
+ registerPartitionRoutes(app, partitions, deployments, diary, telemetry);
72
+ registerEnvironmentRoutes(app, environments, deployments, telemetry);
73
+ registerArtifactRoutes(app, artifactStore, telemetry);
74
+ registerSettingsRoutes(app, settings, telemetry);
75
+ registerAgentRoutes(app, agent, partitions, environments, artifactStore, deployments, diary, settings);
76
+
77
+ await app.ready();
78
+
79
+ // Seed test data
80
+ const envRes = await app.inject({
81
+ method: "POST",
82
+ url: "/api/environments",
83
+ payload: { name: "production", variables: { APP_ENV: "production", LOG_LEVEL: "warn" } },
84
+ });
85
+ productionEnvId = JSON.parse(envRes.payload).environment.id;
86
+
87
+ const stagingRes = await app.inject({
88
+ method: "POST",
89
+ url: "/api/environments",
90
+ payload: { name: "staging", variables: { APP_ENV: "staging", LOG_LEVEL: "debug" } },
91
+ });
92
+ stagingEnvId = JSON.parse(stagingRes.payload).environment.id;
93
+
94
+ const artifactRes = await app.inject({
95
+ method: "POST",
96
+ url: "/api/artifacts",
97
+ payload: { name: "web-app", type: "nodejs" },
98
+ });
99
+ artifactId = JSON.parse(artifactRes.payload).artifact.id;
100
+
101
+ const partitionRes = await app.inject({
102
+ method: "POST",
103
+ url: "/api/partitions",
104
+ payload: { name: "Acme Corp" },
105
+ });
106
+ partitionId = JSON.parse(partitionRes.payload).partition.id;
107
+
108
+ await app.inject({
109
+ method: "PUT",
110
+ url: `/api/partitions/${partitionId}/variables`,
111
+ payload: { variables: { DB_HOST: "acme-db-1", APP_ENV: "production" } },
112
+ });
113
+ });
114
+
115
+ /**
116
+ * Helper: creates a deployment via the new artifact-based API.
117
+ */
118
+ async function deployViaHttp(
119
+ server: FastifyInstance,
120
+ params: { artifactId: string; partitionId?: string; environmentId: string; version?: string; variables?: Record<string, string> },
121
+ ) {
122
+ return server.inject({
123
+ method: "POST",
124
+ url: "/api/deployments",
125
+ payload: {
126
+ artifactId: params.artifactId,
127
+ environmentId: params.environmentId,
128
+ partitionId: params.partitionId,
129
+ version: params.version,
130
+ },
131
+ });
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Deployment context tests
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe("Agent mode — deployment context", () => {
139
+ it("returns context with signals and environment summary", async () => {
140
+ // Trigger a deployment first to have some data
141
+ await deployViaHttp(app, { artifactId, partitionId, environmentId: productionEnvId, version: "1.0.0" });
142
+
143
+ const res = await app.inject({ method: "GET", url: "/api/agent/context" });
144
+
145
+ expect(res.statusCode).toBe(200);
146
+ const context = JSON.parse(res.payload);
147
+
148
+ expect(context.recentActivity).toBeDefined();
149
+ expect(context.recentActivity.deploymentsLast24h).toBeGreaterThanOrEqual(1);
150
+ expect(context.recentActivity.successRate).toBeDefined();
151
+ expect(context.environmentSummary).toBeDefined();
152
+ expect(context.environmentSummary.length).toBeGreaterThanOrEqual(2);
153
+ expect(context.signals).toBeDefined();
154
+ });
155
+
156
+ it("environment summary reflects deployment status", async () => {
157
+ const res = await app.inject({ method: "GET", url: "/api/agent/context" });
158
+ const context = JSON.parse(res.payload);
159
+
160
+ const prodSummary = context.environmentSummary.find((e: any) => e.name === "production");
161
+ expect(prodSummary).toBeDefined();
162
+ expect(prodSummary.deployCount).toBeGreaterThanOrEqual(1);
163
+ });
164
+ });
165
+
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Input sanitization tests
169
+ // ---------------------------------------------------------------------------
170
+
171
+ describe("Agent mode — input sanitization", () => {
172
+ it("strips control characters from intent", () => {
173
+ const input = "Deploy\x01\x02\x03\x07 web-app\x0B\x0C\x0E v1.0.0";
174
+ const result = sanitizeUserInput(input);
175
+ // Control characters should be removed
176
+ expect(result).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F]/);
177
+ // Printable content should remain
178
+ expect(result).toContain("Deploy");
179
+ expect(result).toContain("web-app");
180
+ expect(result).toContain("v1.0.0");
181
+ });
182
+
183
+ it("truncates long inputs to 1000 characters", () => {
184
+ const longInput = "a".repeat(2000);
185
+ const result = sanitizeUserInput(longInput);
186
+ expect(result.length).toBe(1000);
187
+ });
188
+
189
+ it("escapes XML tags in user input", () => {
190
+ const input = "<script>alert('xss')</script>";
191
+ const result = sanitizeUserInput(input);
192
+ expect(result).not.toContain("<script>");
193
+ expect(result).not.toContain("</script>");
194
+ expect(result).toContain("&lt;script&gt;");
195
+ expect(result).toContain("&lt;/script&gt;");
196
+ });
197
+
198
+ it("validates semver version format", () => {
199
+ // Valid formats
200
+ expect(validateExtractedVersion("1.2.3")).toBe(true);
201
+ expect(validateExtractedVersion("1.2.3-beta.1")).toBe(true);
202
+ expect(validateExtractedVersion("0.0.1")).toBe(true);
203
+ expect(validateExtractedVersion("10.20.30-alpha")).toBe(true);
204
+
205
+ // Invalid formats
206
+ expect(validateExtractedVersion("not-a-version")).toBe(false);
207
+ expect(validateExtractedVersion("1.2")).toBe(false);
208
+ expect(validateExtractedVersion("../../../etc/passwd")).toBe(false);
209
+ expect(validateExtractedVersion("v1.2.3")).toBe(false);
210
+ expect(validateExtractedVersion("")).toBe(false);
211
+ });
212
+
213
+ it("validates variable key format", () => {
214
+ // Valid keys
215
+ const valid = validateExtractedVariables({ APP_ENV: "production", DB_HOST: "localhost" });
216
+ expect(valid).toHaveProperty("APP_ENV", "production");
217
+ expect(valid).toHaveProperty("DB_HOST", "localhost");
218
+
219
+ // Invalid keys should be excluded
220
+ const invalid = validateExtractedVariables({
221
+ "../../path": "value",
222
+ "key with spaces": "value",
223
+ "": "value",
224
+ "123invalid": "value",
225
+ });
226
+ expect(Object.keys(invalid)).toHaveLength(0);
227
+ });
228
+
229
+ it("rejects variables with values exceeding 500 chars", () => {
230
+ const longValue = "x".repeat(600);
231
+ const result = validateExtractedVariables({ VALID_KEY: longValue, SHORT_KEY: "ok" });
232
+ expect(result).not.toHaveProperty("VALID_KEY");
233
+ expect(result).toHaveProperty("SHORT_KEY", "ok");
234
+ });
235
+ });
236
+
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // LLM-powered query classification tests
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe("Agent mode — LLM query classification", () => {
243
+ let qApp: FastifyInstance;
244
+ let qDiary: DecisionDebrief;
245
+ let qPartitions: PartitionStore;
246
+ let qEnvironments: EnvironmentStore;
247
+ let qDeployments: InMemoryDeploymentStore;
248
+ let qArtifactStore: ArtifactStore;
249
+ let qSettings: SettingsStore;
250
+ let qTelemetry: TelemetryStore;
251
+ let qAgent: SynthAgent;
252
+ let qMockLlm: LlmClient;
253
+
254
+ let qArtifactId: string;
255
+ let qPartitionId: string;
256
+ let qProdEnvId: string;
257
+ let qStagingEnvId: string;
258
+
259
+ // Track what classify() should return for query classification
260
+ let qClassifyResponse: LlmResult;
261
+
262
+ beforeAll(async () => {
263
+ qDiary = new DecisionDebrief();
264
+ qPartitions = new PartitionStore();
265
+ qEnvironments = new EnvironmentStore();
266
+ qDeployments = new InMemoryDeploymentStore();
267
+ qArtifactStore = new ArtifactStore();
268
+ qSettings = new SettingsStore();
269
+ qTelemetry = new TelemetryStore();
270
+ qAgent = new SynthAgent(
271
+ qDiary, qDeployments, qArtifactStore, qEnvironments, qPartitions,
272
+ undefined, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
273
+ );
274
+
275
+ qMockLlm = new LlmClient(qDiary, "command", { apiKey: "test-key" });
276
+ qMockLlm.classify = async () => qClassifyResponse;
277
+ qMockLlm.isAvailable = () => true;
278
+
279
+ qApp = Fastify();
280
+ addMockAuth(qApp);
281
+ registerDeploymentRoutes(qApp, qDeployments, qDiary, qPartitions, qEnvironments, qArtifactStore, qSettings, qTelemetry);
282
+ registerPartitionRoutes(qApp, qPartitions, qDeployments, qDiary, qTelemetry);
283
+ registerEnvironmentRoutes(qApp, qEnvironments, qDeployments, qTelemetry);
284
+ registerArtifactRoutes(qApp, qArtifactStore, qTelemetry);
285
+ registerSettingsRoutes(qApp, qSettings, qTelemetry);
286
+ registerAgentRoutes(qApp, qAgent, qPartitions, qEnvironments, qArtifactStore, qDeployments, qDiary, qSettings, qMockLlm);
287
+
288
+ await qApp.ready();
289
+
290
+ // Seed test data
291
+ const envRes = await qApp.inject({
292
+ method: "POST",
293
+ url: "/api/environments",
294
+ payload: { name: "production", variables: { APP_ENV: "production" } },
295
+ });
296
+ qProdEnvId = JSON.parse(envRes.payload).environment.id;
297
+
298
+ const stagingRes = await qApp.inject({
299
+ method: "POST",
300
+ url: "/api/environments",
301
+ payload: { name: "staging", variables: { APP_ENV: "staging" } },
302
+ });
303
+ qStagingEnvId = JSON.parse(stagingRes.payload).environment.id;
304
+
305
+ const artifactRes = await qApp.inject({
306
+ method: "POST",
307
+ url: "/api/artifacts",
308
+ payload: { name: "web-app", type: "nodejs" },
309
+ });
310
+ qArtifactId = JSON.parse(artifactRes.payload).artifact.id;
311
+
312
+ const partRes = await qApp.inject({
313
+ method: "POST",
314
+ url: "/api/partitions",
315
+ payload: { name: "Acme Corp" },
316
+ });
317
+ qPartitionId = JSON.parse(partRes.payload).partition.id;
318
+
319
+ // Create a deployment so data queries have something to find
320
+ await deployViaHttp(qApp, { artifactId: qArtifactId, partitionId: qPartitionId, environmentId: qProdEnvId, version: "1.0.0" });
321
+ });
322
+
323
+ it("navigate action: resolves 'show partition Acme Corp' to partition-detail", async () => {
324
+ qClassifyResponse = {
325
+ ok: true,
326
+ text: JSON.stringify({
327
+ action: "navigate",
328
+ view: "partition-detail",
329
+ params: { id: "Acme Corp" },
330
+ title: "Acme Corp",
331
+ }),
332
+ model: "claude-haiku-4-5-20251001",
333
+ responseTimeMs: 80,
334
+ };
335
+
336
+ const res = await qApp.inject({
337
+ method: "POST",
338
+ url: "/api/agent/query",
339
+ payload: { query: "show partition Acme Corp" },
340
+ });
341
+
342
+ expect(res.statusCode).toBe(200);
343
+ const result = JSON.parse(res.payload);
344
+ expect(result.action).toBe("navigate");
345
+ expect(result.view).toBe("partition-detail");
346
+ expect(result.params.id).toBe(qPartitionId);
347
+ });
348
+
349
+ it("data action: resolves 'recent deployments' to deployment-list", async () => {
350
+ qClassifyResponse = {
351
+ ok: true,
352
+ text: JSON.stringify({
353
+ action: "data",
354
+ view: "deployment-list",
355
+ params: {},
356
+ title: "Recent Deployments",
357
+ }),
358
+ model: "claude-haiku-4-5-20251001",
359
+ responseTimeMs: 60,
360
+ };
361
+
362
+ const res = await qApp.inject({
363
+ method: "POST",
364
+ url: "/api/agent/query",
365
+ payload: { query: "recent deployments" },
366
+ });
367
+
368
+ expect(res.statusCode).toBe(200);
369
+ const result = JSON.parse(res.payload);
370
+ expect(result.action).toBe("data");
371
+ expect(result.view).toBe("deployment-list");
372
+ });
373
+
374
+ it("create action: returns create intent for UI confirmation", async () => {
375
+ qClassifyResponse = {
376
+ ok: true,
377
+ text: JSON.stringify({
378
+ action: "create",
379
+ view: "partition-list",
380
+ params: { name: "New Corp" },
381
+ title: "Create Partition",
382
+ }),
383
+ model: "claude-haiku-4-5-20251001",
384
+ responseTimeMs: 90,
385
+ };
386
+
387
+ const res = await qApp.inject({
388
+ method: "POST",
389
+ url: "/api/agent/query",
390
+ payload: { query: "create partition New Corp" },
391
+ });
392
+
393
+ expect(res.statusCode).toBe(200);
394
+ const result = JSON.parse(res.payload);
395
+ // After #63, create actions are returned as-is for UI confirmation
396
+ expect(result.action).toBe("create");
397
+ expect(result.view).toBe("partition-list");
398
+ expect(result.params.name).toBe("New Corp");
399
+ });
400
+
401
+ it("falls back to regex when LLM returns invalid JSON", async () => {
402
+ qClassifyResponse = {
403
+ ok: true,
404
+ text: "This is not valid JSON, just random text",
405
+ model: "claude-haiku-4-5-20251001",
406
+ responseTimeMs: 100,
407
+ };
408
+
409
+ const res = await qApp.inject({
410
+ method: "POST",
411
+ url: "/api/agent/query",
412
+ payload: { query: "show partition Acme Corp" },
413
+ });
414
+
415
+ expect(res.statusCode).toBe(200);
416
+ const result = JSON.parse(res.payload);
417
+ // Regex fallback should still classify the query — it matches partition name + "show"
418
+ expect(result.action).toBe("navigate");
419
+ expect(result.view).toBe("partition-detail");
420
+ expect(result.params.id).toBe(qPartitionId);
421
+ });
422
+
423
+ it("falls back to regex when LLM returns hallucinated entity names", async () => {
424
+ qClassifyResponse = {
425
+ ok: true,
426
+ text: JSON.stringify({
427
+ action: "navigate",
428
+ view: "partition-detail",
429
+ params: { id: "Nonexistent Partition" },
430
+ title: "Nonexistent Partition",
431
+ }),
432
+ model: "claude-haiku-4-5-20251001",
433
+ responseTimeMs: 90,
434
+ };
435
+
436
+ const res = await qApp.inject({
437
+ method: "POST",
438
+ url: "/api/agent/query",
439
+ payload: { query: "show partition Acme Corp" },
440
+ });
441
+
442
+ expect(res.statusCode).toBe(200);
443
+ const result = JSON.parse(res.payload);
444
+ // classifyQueryWithLlm validates the partition name and returns null for unknown names,
445
+ // causing fallback to regex which finds "Acme Corp" in the query
446
+ expect(result.action).toBe("navigate");
447
+ expect(result.view).toBe("partition-detail");
448
+ expect(result.params.id).toBe(qPartitionId);
449
+ });
450
+
451
+ it("falls back to regex when LLM call fails", async () => {
452
+ qClassifyResponse = {
453
+ ok: false,
454
+ fallback: true,
455
+ reason: "LLM rate limit exceeded (20 calls/min)",
456
+ };
457
+
458
+ const res = await qApp.inject({
459
+ method: "POST",
460
+ url: "/api/agent/query",
461
+ payload: { query: "show all deployments" },
462
+ });
463
+
464
+ expect(res.statusCode).toBe(200);
465
+ const result = JSON.parse(res.payload);
466
+ // Regex fallback detects "deployments" keyword → returns inline markdown table
467
+ expect(result.action).toBe("answer");
468
+ expect(result.content).toBeDefined();
469
+ });
470
+
471
+ it("records debrief entry for LLM-classified queries", async () => {
472
+ const existingIds = new Set(qDiary.getRecent(200).map((e) => e.id));
473
+
474
+ qClassifyResponse = {
475
+ ok: true,
476
+ text: JSON.stringify({
477
+ action: "data",
478
+ view: "deployment-list",
479
+ params: {},
480
+ title: "Deployments",
481
+ }),
482
+ model: "claude-haiku-4-5-20251001",
483
+ responseTimeMs: 50,
484
+ };
485
+
486
+ await qApp.inject({
487
+ method: "POST",
488
+ url: "/api/agent/query",
489
+ payload: { query: "show all deployments" },
490
+ });
491
+
492
+ const allEntries = qDiary.getRecent(200);
493
+ const newEntries = allEntries.filter((e) => !existingIds.has(e.id));
494
+ const queryEntry = newEntries.find(
495
+ (e) => e.decisionType === "system" && e.decision.includes("Canvas query"),
496
+ );
497
+ expect(queryEntry).toBeDefined();
498
+ expect(queryEntry!.decision).toContain("data");
499
+ expect(queryEntry!.decision).toContain("deployment-list");
500
+ });
501
+
502
+ it("returns 400 for empty query", async () => {
503
+ const res = await qApp.inject({
504
+ method: "POST",
505
+ url: "/api/agent/query",
506
+ payload: { query: "" },
507
+ });
508
+
509
+ expect(res.statusCode).toBe(400);
510
+ });
511
+
512
+ it("handles LLM response missing action field by falling back to regex", async () => {
513
+ qClassifyResponse = {
514
+ ok: true,
515
+ text: JSON.stringify({
516
+ view: "deployment-list",
517
+ params: {},
518
+ }),
519
+ model: "claude-haiku-4-5-20251001",
520
+ responseTimeMs: 100,
521
+ };
522
+
523
+ const res = await qApp.inject({
524
+ method: "POST",
525
+ url: "/api/agent/query",
526
+ payload: { query: "recent deployments" },
527
+ });
528
+
529
+ expect(res.statusCode).toBe(200);
530
+ const result = JSON.parse(res.payload);
531
+ // Missing action field → classifyQueryWithLlm returns null → regex fallback
532
+ // "deployments" matches the deployment list pattern → returns inline markdown table
533
+ expect(result.action).toBe("answer");
534
+ expect(result.content).toBeDefined();
535
+ });
536
+ });