@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,409 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import Fastify from "fastify";
3
+ import type { FastifyInstance } from "fastify";
4
+ import {
5
+ DecisionDebrief,
6
+ PartitionStore,
7
+ EnvironmentStore,
8
+ ArtifactStore,
9
+ SettingsStore,
10
+ TelemetryStore,
11
+ UserStore,
12
+ RoleStore,
13
+ UserRoleStore,
14
+ SessionStore,
15
+ } from "@synth-deploy/core";
16
+ import type { UserId, RoleId, Permission } from "@synth-deploy/core";
17
+ import { InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
18
+ import { registerPartitionRoutes } from "../src/api/partitions.js";
19
+ import { registerEnvironmentRoutes } from "../src/api/environments.js";
20
+ import { registerSettingsRoutes } from "../src/api/settings.js";
21
+ import { registerDeploymentRoutes } from "../src/api/deployments.js";
22
+ import { registerArtifactRoutes } from "../src/api/artifacts.js";
23
+ import { registerAuthMiddleware, generateTokens } from "../src/middleware/auth.js";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Constants
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const JWT_SECRET = new TextEncoder().encode("rbac-test-secret");
30
+ const VIEWER_USER_ID = "viewer-user" as UserId;
31
+ const DEPLOYER_USER_ID = "deployer-user" as UserId;
32
+ const VIEWER_ROLE_ID = "role-viewer" as RoleId;
33
+ const DEPLOYER_ROLE_ID = "role-deployer" as RoleId;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Test helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface TestContext {
40
+ app: FastifyInstance;
41
+ viewerToken: string;
42
+ deployerToken: string;
43
+ }
44
+
45
+ async function createTestServer(): Promise<TestContext> {
46
+ const userStore = new UserStore();
47
+ const roleStore = new RoleStore();
48
+ const userRoleStore = new UserRoleStore(roleStore);
49
+ const sessionStore = new SessionStore();
50
+ const diary = new DecisionDebrief();
51
+ const partitions = new PartitionStore();
52
+ const environments = new EnvironmentStore();
53
+ const deployments = new InMemoryDeploymentStore();
54
+ const artifactStore = new ArtifactStore();
55
+ const settings = new SettingsStore();
56
+ const telemetry = new TelemetryStore();
57
+
58
+ // Create viewer role — only view permissions
59
+ roleStore.create({
60
+ id: VIEWER_ROLE_ID,
61
+ name: "Viewer",
62
+ permissions: [
63
+ "deployment.view",
64
+ "artifact.view",
65
+ "environment.view",
66
+ "partition.view",
67
+ "envoy.view",
68
+ ] as Permission[],
69
+ isBuiltIn: false,
70
+ createdAt: new Date(),
71
+ });
72
+
73
+ // Create deployer role — create/approve but no settings/users
74
+ roleStore.create({
75
+ id: DEPLOYER_ROLE_ID,
76
+ name: "Deployer",
77
+ permissions: [
78
+ "deployment.create",
79
+ "deployment.approve",
80
+ "deployment.reject",
81
+ "deployment.view",
82
+ "deployment.rollback",
83
+ "artifact.create",
84
+ "artifact.update",
85
+ "artifact.view",
86
+ "environment.view",
87
+ "partition.view",
88
+ "envoy.view",
89
+ ] as Permission[],
90
+ isBuiltIn: false,
91
+ createdAt: new Date(),
92
+ });
93
+
94
+ // Create viewer user
95
+ userStore.create({
96
+ id: VIEWER_USER_ID,
97
+ email: "viewer@example.com",
98
+ name: "Viewer User",
99
+ passwordHash: "hashed",
100
+ createdAt: new Date(),
101
+ updatedAt: new Date(),
102
+ });
103
+ userRoleStore.assign(VIEWER_USER_ID, VIEWER_ROLE_ID, VIEWER_USER_ID);
104
+
105
+ // Create deployer user
106
+ userStore.create({
107
+ id: DEPLOYER_USER_ID,
108
+ email: "deployer@example.com",
109
+ name: "Deployer User",
110
+ passwordHash: "hashed",
111
+ createdAt: new Date(),
112
+ updatedAt: new Date(),
113
+ });
114
+ userRoleStore.assign(DEPLOYER_USER_ID, DEPLOYER_ROLE_ID, DEPLOYER_USER_ID);
115
+
116
+ const app = Fastify({ logger: false });
117
+ registerAuthMiddleware(app, userStore, userRoleStore, sessionStore, JWT_SECRET);
118
+
119
+ // Register routes
120
+ registerPartitionRoutes(app, partitions, deployments, diary, telemetry);
121
+ registerEnvironmentRoutes(app, environments, deployments, telemetry);
122
+ registerSettingsRoutes(app, settings, telemetry);
123
+ registerDeploymentRoutes(app, deployments, diary, partitions, environments, artifactStore, settings, telemetry);
124
+ registerArtifactRoutes(app, artifactStore, telemetry);
125
+
126
+ await app.ready();
127
+
128
+ // Generate tokens and sessions
129
+ const viewerTokens = await generateTokens(VIEWER_USER_ID, JWT_SECRET);
130
+ sessionStore.create({
131
+ id: "session-viewer",
132
+ userId: VIEWER_USER_ID,
133
+ token: viewerTokens.token,
134
+ refreshToken: viewerTokens.refreshToken,
135
+ expiresAt: viewerTokens.expiresAt,
136
+ createdAt: new Date(),
137
+ });
138
+
139
+ const deployerTokens = await generateTokens(DEPLOYER_USER_ID, JWT_SECRET);
140
+ sessionStore.create({
141
+ id: "session-deployer",
142
+ userId: DEPLOYER_USER_ID,
143
+ token: deployerTokens.token,
144
+ refreshToken: deployerTokens.refreshToken,
145
+ expiresAt: deployerTokens.expiresAt,
146
+ createdAt: new Date(),
147
+ });
148
+
149
+ return {
150
+ app,
151
+ viewerToken: viewerTokens.token,
152
+ deployerToken: deployerTokens.token,
153
+ };
154
+ }
155
+
156
+ // ===========================================================================
157
+ // Tests
158
+ // ===========================================================================
159
+
160
+ describe("RBAC enforcement", () => {
161
+ let ctx: TestContext;
162
+
163
+ beforeEach(async () => {
164
+ ctx = await createTestServer();
165
+ });
166
+
167
+ afterEach(async () => {
168
+ await ctx.app.close();
169
+ });
170
+
171
+ // -------------------------------------------------------------------------
172
+ // 401 — no auth
173
+ // -------------------------------------------------------------------------
174
+
175
+ describe("unauthenticated requests return 401", () => {
176
+ const routes: Array<{ method: "GET" | "POST" | "PUT" | "DELETE"; url: string }> = [
177
+ // Deployments
178
+ { method: "GET", url: "/api/deployments" },
179
+ { method: "POST", url: "/api/deployments" },
180
+ { method: "GET", url: "/api/debrief" },
181
+ // Artifacts
182
+ { method: "GET", url: "/api/artifacts" },
183
+ { method: "POST", url: "/api/artifacts" },
184
+ { method: "GET", url: "/api/artifacts/fake-id" },
185
+ { method: "PUT", url: "/api/artifacts/fake-id" },
186
+ { method: "DELETE", url: "/api/artifacts/fake-id" },
187
+ // Environments
188
+ { method: "GET", url: "/api/environments" },
189
+ { method: "POST", url: "/api/environments" },
190
+ { method: "GET", url: "/api/environments/fake-id" },
191
+ { method: "PUT", url: "/api/environments/fake-id" },
192
+ { method: "DELETE", url: "/api/environments/fake-id" },
193
+ // Partitions
194
+ { method: "GET", url: "/api/partitions" },
195
+ { method: "POST", url: "/api/partitions" },
196
+ { method: "GET", url: "/api/partitions/fake-id" },
197
+ { method: "PUT", url: "/api/partitions/fake-id" },
198
+ { method: "DELETE", url: "/api/partitions/fake-id" },
199
+ // Settings
200
+ { method: "GET", url: "/api/settings" },
201
+ { method: "PUT", url: "/api/settings" },
202
+ { method: "GET", url: "/api/settings/command-info" },
203
+ ];
204
+
205
+ for (const { method, url } of routes) {
206
+ it(`${method} ${url} returns 401 without auth`, async () => {
207
+ const res = await ctx.app.inject({ method, url });
208
+ expect(res.statusCode).toBe(401);
209
+ });
210
+ }
211
+ });
212
+
213
+ // -------------------------------------------------------------------------
214
+ // 403 — wrong permissions
215
+ // -------------------------------------------------------------------------
216
+
217
+ describe("viewer cannot perform write operations (403)", () => {
218
+ it("POST /api/deployments returns 403 for viewer", async () => {
219
+ const res = await ctx.app.inject({
220
+ method: "POST",
221
+ url: "/api/deployments",
222
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
223
+ payload: { artifactId: "a1", environmentId: "e1" },
224
+ });
225
+ expect(res.statusCode).toBe(403);
226
+ });
227
+
228
+ it("POST /api/artifacts returns 403 for viewer", async () => {
229
+ const res = await ctx.app.inject({
230
+ method: "POST",
231
+ url: "/api/artifacts",
232
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
233
+ payload: { name: "test", type: "docker" },
234
+ });
235
+ expect(res.statusCode).toBe(403);
236
+ });
237
+
238
+ it("POST /api/environments returns 403 for viewer", async () => {
239
+ const res = await ctx.app.inject({
240
+ method: "POST",
241
+ url: "/api/environments",
242
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
243
+ payload: { name: "staging" },
244
+ });
245
+ expect(res.statusCode).toBe(403);
246
+ });
247
+
248
+ it("POST /api/partitions returns 403 for viewer", async () => {
249
+ const res = await ctx.app.inject({
250
+ method: "POST",
251
+ url: "/api/partitions",
252
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
253
+ payload: { name: "region-us" },
254
+ });
255
+ expect(res.statusCode).toBe(403);
256
+ });
257
+
258
+ it("DELETE /api/artifacts/fake-id returns 403 for viewer", async () => {
259
+ const res = await ctx.app.inject({
260
+ method: "DELETE",
261
+ url: "/api/artifacts/fake-id",
262
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
263
+ });
264
+ expect(res.statusCode).toBe(403);
265
+ });
266
+
267
+ it("DELETE /api/environments/fake-id returns 403 for viewer", async () => {
268
+ const res = await ctx.app.inject({
269
+ method: "DELETE",
270
+ url: "/api/environments/fake-id",
271
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
272
+ });
273
+ expect(res.statusCode).toBe(403);
274
+ });
275
+
276
+ it("DELETE /api/partitions/fake-id returns 403 for viewer", async () => {
277
+ const res = await ctx.app.inject({
278
+ method: "DELETE",
279
+ url: "/api/partitions/fake-id",
280
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
281
+ });
282
+ expect(res.statusCode).toBe(403);
283
+ });
284
+ });
285
+
286
+ describe("deployer cannot access settings (403)", () => {
287
+ it("GET /api/settings returns 403 for deployer", async () => {
288
+ const res = await ctx.app.inject({
289
+ method: "GET",
290
+ url: "/api/settings",
291
+ headers: { authorization: `Bearer ${ctx.deployerToken}` },
292
+ });
293
+ expect(res.statusCode).toBe(403);
294
+ });
295
+
296
+ it("PUT /api/settings returns 403 for deployer", async () => {
297
+ const res = await ctx.app.inject({
298
+ method: "PUT",
299
+ url: "/api/settings",
300
+ headers: { authorization: `Bearer ${ctx.deployerToken}` },
301
+ payload: { environmentsEnabled: true },
302
+ });
303
+ expect(res.statusCode).toBe(403);
304
+ });
305
+
306
+ it("GET /api/settings/command-info returns 403 for deployer", async () => {
307
+ const res = await ctx.app.inject({
308
+ method: "GET",
309
+ url: "/api/settings/command-info",
310
+ headers: { authorization: `Bearer ${ctx.deployerToken}` },
311
+ });
312
+ expect(res.statusCode).toBe(403);
313
+ });
314
+ });
315
+
316
+ // -------------------------------------------------------------------------
317
+ // 200 — correct permissions succeed
318
+ // -------------------------------------------------------------------------
319
+
320
+ describe("viewer can access read-only routes (200)", () => {
321
+ it("GET /api/deployments returns 200 for viewer", async () => {
322
+ const res = await ctx.app.inject({
323
+ method: "GET",
324
+ url: "/api/deployments",
325
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
326
+ });
327
+ expect(res.statusCode).toBe(200);
328
+ });
329
+
330
+ it("GET /api/artifacts returns 200 for viewer", async () => {
331
+ const res = await ctx.app.inject({
332
+ method: "GET",
333
+ url: "/api/artifacts",
334
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
335
+ });
336
+ expect(res.statusCode).toBe(200);
337
+ });
338
+
339
+ it("GET /api/environments returns 200 for viewer", async () => {
340
+ const res = await ctx.app.inject({
341
+ method: "GET",
342
+ url: "/api/environments",
343
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
344
+ });
345
+ expect(res.statusCode).toBe(200);
346
+ });
347
+
348
+ it("GET /api/partitions returns 200 for viewer", async () => {
349
+ const res = await ctx.app.inject({
350
+ method: "GET",
351
+ url: "/api/partitions",
352
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
353
+ });
354
+ expect(res.statusCode).toBe(200);
355
+ });
356
+
357
+ it("GET /api/debrief returns 200 for viewer", async () => {
358
+ const res = await ctx.app.inject({
359
+ method: "GET",
360
+ url: "/api/debrief",
361
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
362
+ });
363
+ expect(res.statusCode).toBe(200);
364
+ });
365
+ });
366
+
367
+ describe("deployer can create entities", () => {
368
+ it("POST /api/artifacts returns 201 for deployer", async () => {
369
+ const res = await ctx.app.inject({
370
+ method: "POST",
371
+ url: "/api/artifacts",
372
+ headers: { authorization: `Bearer ${ctx.deployerToken}` },
373
+ payload: { name: "my-app", type: "docker" },
374
+ });
375
+ expect(res.statusCode).toBe(201);
376
+ });
377
+
378
+ it("POST /api/partitions returns 201 for deployer (deployer lacks partition.create)", async () => {
379
+ const res = await ctx.app.inject({
380
+ method: "POST",
381
+ url: "/api/partitions",
382
+ headers: { authorization: `Bearer ${ctx.deployerToken}` },
383
+ payload: { name: "region-us" },
384
+ });
385
+ // Deployer role does NOT have partition.create — should be 403
386
+ expect(res.statusCode).toBe(403);
387
+ });
388
+ });
389
+
390
+ // -------------------------------------------------------------------------
391
+ // 403 error format
392
+ // -------------------------------------------------------------------------
393
+
394
+ describe("403 response includes required permissions", () => {
395
+ it("includes required and message fields", async () => {
396
+ const res = await ctx.app.inject({
397
+ method: "POST",
398
+ url: "/api/partitions",
399
+ headers: { authorization: `Bearer ${ctx.viewerToken}` },
400
+ payload: { name: "test" },
401
+ });
402
+ expect(res.statusCode).toBe(403);
403
+ const body = JSON.parse(res.payload);
404
+ expect(body.error).toBe("Forbidden");
405
+ expect(body.required).toContain("partition.create");
406
+ expect(body.message).toBeTruthy();
407
+ });
408
+ });
409
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { UpdateSettingsSchema } from "../src/api/schemas.js";
3
+
4
+ describe("SSRF URL validation", () => {
5
+ function validateEnvoyUrl(url: string) {
6
+ return UpdateSettingsSchema.safeParse({
7
+ envoy: { url },
8
+ });
9
+ }
10
+
11
+ it("accepts valid external URLs", () => {
12
+ expect(validateEnvoyUrl("https://envoy.example.com").success).toBe(true);
13
+ expect(validateEnvoyUrl("http://deploy.company.com:8080").success).toBe(true);
14
+ expect(validateEnvoyUrl("https://203.0.113.50:3000").success).toBe(true);
15
+ });
16
+
17
+ it("rejects localhost", () => {
18
+ expect(validateEnvoyUrl("http://localhost:3000").success).toBe(false);
19
+ expect(validateEnvoyUrl("http://127.0.0.1:8080").success).toBe(false);
20
+ });
21
+
22
+ it("rejects private 10.x.x.x range", () => {
23
+ expect(validateEnvoyUrl("http://10.0.0.1:8080").success).toBe(false);
24
+ expect(validateEnvoyUrl("http://10.255.255.255").success).toBe(false);
25
+ });
26
+
27
+ it("rejects private 172.16-31.x.x range", () => {
28
+ expect(validateEnvoyUrl("http://172.16.0.1").success).toBe(false);
29
+ expect(validateEnvoyUrl("http://172.31.255.255").success).toBe(false);
30
+ // 172.15.x.x should be allowed
31
+ expect(validateEnvoyUrl("http://172.15.0.1").success).toBe(true);
32
+ // 172.32.x.x should be allowed
33
+ expect(validateEnvoyUrl("http://172.32.0.1").success).toBe(true);
34
+ });
35
+
36
+ it("rejects private 192.168.x.x range", () => {
37
+ expect(validateEnvoyUrl("http://192.168.1.1").success).toBe(false);
38
+ expect(validateEnvoyUrl("http://192.168.0.100").success).toBe(false);
39
+ });
40
+
41
+ it("rejects link-local / AWS metadata (169.254.x.x)", () => {
42
+ expect(validateEnvoyUrl("http://169.254.169.254/latest/meta-data/").success).toBe(false);
43
+ });
44
+
45
+ it("rejects non-http protocols", () => {
46
+ expect(validateEnvoyUrl("ftp://envoy.example.com").success).toBe(false);
47
+ expect(validateEnvoyUrl("file:///etc/passwd").success).toBe(false);
48
+ });
49
+
50
+ it("validates MCP server URLs too", () => {
51
+ const result = UpdateSettingsSchema.safeParse({
52
+ mcpServers: [{ name: "evil", url: "http://169.254.169.254" }],
53
+ });
54
+ expect(result.success).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { DecisionDebrief } from "@synth-deploy/core";
3
+ import { InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
4
+ import { markStaleDeployments } from "../src/agent/stale-deployment-detector.js";
5
+ import type { Deployment } from "@synth-deploy/core";
6
+
7
+ function makeDeployment(overrides: Partial<Deployment> = {}): Deployment {
8
+ return {
9
+ id: "dep-1",
10
+ operationId: "op-1",
11
+ partitionId: "part-1",
12
+ environmentId: "env-1",
13
+ version: "1.0",
14
+ status: "running",
15
+ variables: {},
16
+ debriefEntryIds: [],
17
+ orderId: null,
18
+ createdAt: new Date(),
19
+ completedAt: null,
20
+ failureReason: null,
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("markStaleDeployments", () => {
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ it("marks running deployments older than threshold as failed", () => {
31
+ const deployments = new InMemoryDeploymentStore();
32
+ const debrief = new DecisionDebrief();
33
+
34
+ // Deployment created 35 minutes ago
35
+ const oldDate = new Date(Date.now() - 35 * 60 * 1000);
36
+ deployments.save(makeDeployment({ createdAt: oldDate }));
37
+
38
+ const count = markStaleDeployments(deployments, debrief, 30 * 60 * 1000);
39
+
40
+ expect(count).toBe(1);
41
+ expect(deployments.get("dep-1")?.status).toBe("failed");
42
+ expect(debrief.getRecent(1)[0].decisionType).toBe("deployment-failure");
43
+ });
44
+
45
+ it("does not touch running deployments within threshold", () => {
46
+ const deployments = new InMemoryDeploymentStore();
47
+ const debrief = new DecisionDebrief();
48
+
49
+ // Deployment created 5 minutes ago
50
+ const recentDate = new Date(Date.now() - 5 * 60 * 1000);
51
+ deployments.save(makeDeployment({ createdAt: recentDate }));
52
+
53
+ const count = markStaleDeployments(deployments, debrief, 30 * 60 * 1000);
54
+
55
+ expect(count).toBe(0);
56
+ expect(deployments.get("dep-1")?.status).toBe("running");
57
+ });
58
+
59
+ it("does not touch completed deployments", () => {
60
+ const deployments = new InMemoryDeploymentStore();
61
+ const debrief = new DecisionDebrief();
62
+
63
+ const oldDate = new Date(Date.now() - 60 * 60 * 1000);
64
+ deployments.save(makeDeployment({ status: "completed" as any, createdAt: oldDate }));
65
+
66
+ const count = markStaleDeployments(deployments, debrief, 30 * 60 * 1000);
67
+
68
+ expect(count).toBe(0);
69
+ });
70
+
71
+ it("handles multiple stale deployments", () => {
72
+ const deployments = new InMemoryDeploymentStore();
73
+ const debrief = new DecisionDebrief();
74
+
75
+ const oldDate = new Date(Date.now() - 45 * 60 * 1000);
76
+ deployments.save(makeDeployment({ id: "d1", createdAt: oldDate }));
77
+ deployments.save(makeDeployment({ id: "d2", createdAt: oldDate }));
78
+
79
+ const count = markStaleDeployments(deployments, debrief, 30 * 60 * 1000);
80
+
81
+ expect(count).toBe(2);
82
+ expect(deployments.get("d1")?.status).toBe("failed");
83
+ expect(deployments.get("d2")?.status).toBe("failed");
84
+ });
85
+ });