palaryn 0.1.0 → 0.3.2

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 (344) hide show
  1. package/README.md +243 -588
  2. package/dist/sdk/typescript/src/client.js +2 -2
  3. package/dist/sdk/typescript/src/client.js.map +1 -1
  4. package/dist/src/anomaly/detector.d.ts +7 -4
  5. package/dist/src/anomaly/detector.d.ts.map +1 -1
  6. package/dist/src/anomaly/detector.js +22 -12
  7. package/dist/src/anomaly/detector.js.map +1 -1
  8. package/dist/src/audit/logger.d.ts +10 -0
  9. package/dist/src/audit/logger.d.ts.map +1 -1
  10. package/dist/src/audit/logger.js +52 -38
  11. package/dist/src/audit/logger.js.map +1 -1
  12. package/dist/src/auth/routes.d.ts.map +1 -1
  13. package/dist/src/auth/routes.js +35 -0
  14. package/dist/src/auth/routes.js.map +1 -1
  15. package/dist/src/budget/manager.d.ts +5 -0
  16. package/dist/src/budget/manager.d.ts.map +1 -1
  17. package/dist/src/budget/manager.js +32 -0
  18. package/dist/src/budget/manager.js.map +1 -1
  19. package/dist/src/budget/model-pricing.d.ts +20 -0
  20. package/dist/src/budget/model-pricing.d.ts.map +1 -0
  21. package/dist/src/budget/model-pricing.js +107 -0
  22. package/dist/src/budget/model-pricing.js.map +1 -0
  23. package/dist/src/budget/usage-extractor.d.ts +3 -1
  24. package/dist/src/budget/usage-extractor.d.ts.map +1 -1
  25. package/dist/src/budget/usage-extractor.js +47 -3
  26. package/dist/src/budget/usage-extractor.js.map +1 -1
  27. package/dist/src/config/defaults.d.ts.map +1 -1
  28. package/dist/src/config/defaults.js +65 -13
  29. package/dist/src/config/defaults.js.map +1 -1
  30. package/dist/src/dlp/tool-patterns.d.ts +7 -0
  31. package/dist/src/dlp/tool-patterns.d.ts.map +1 -0
  32. package/dist/src/dlp/tool-patterns.js +34 -0
  33. package/dist/src/dlp/tool-patterns.js.map +1 -0
  34. package/dist/src/executor/filesystem-executor.d.ts +28 -0
  35. package/dist/src/executor/filesystem-executor.d.ts.map +1 -0
  36. package/dist/src/executor/filesystem-executor.js +192 -0
  37. package/dist/src/executor/filesystem-executor.js.map +1 -0
  38. package/dist/src/executor/http-executor.d.ts.map +1 -1
  39. package/dist/src/executor/http-executor.js +22 -2
  40. package/dist/src/executor/http-executor.js.map +1 -1
  41. package/dist/src/executor/index.d.ts +4 -0
  42. package/dist/src/executor/index.d.ts.map +1 -1
  43. package/dist/src/executor/index.js +9 -1
  44. package/dist/src/executor/index.js.map +1 -1
  45. package/dist/src/executor/shell-executor.d.ts +22 -0
  46. package/dist/src/executor/shell-executor.d.ts.map +1 -0
  47. package/dist/src/executor/shell-executor.js +119 -0
  48. package/dist/src/executor/shell-executor.js.map +1 -0
  49. package/dist/src/executor/sql-executor.d.ts +29 -0
  50. package/dist/src/executor/sql-executor.d.ts.map +1 -0
  51. package/dist/src/executor/sql-executor.js +114 -0
  52. package/dist/src/executor/sql-executor.js.map +1 -0
  53. package/dist/src/executor/websocket-executor.d.ts +26 -0
  54. package/dist/src/executor/websocket-executor.d.ts.map +1 -0
  55. package/dist/src/executor/websocket-executor.js +205 -0
  56. package/dist/src/executor/websocket-executor.js.map +1 -0
  57. package/dist/src/interceptor/index.d.ts +2 -0
  58. package/dist/src/interceptor/index.d.ts.map +1 -0
  59. package/dist/src/interceptor/index.js +6 -0
  60. package/dist/src/interceptor/index.js.map +1 -0
  61. package/dist/src/interceptor/provider-interceptor.d.ts +36 -0
  62. package/dist/src/interceptor/provider-interceptor.d.ts.map +1 -0
  63. package/dist/src/interceptor/provider-interceptor.js +302 -0
  64. package/dist/src/interceptor/provider-interceptor.js.map +1 -0
  65. package/dist/src/mcp/auth-verifier.d.ts.map +1 -1
  66. package/dist/src/mcp/auth-verifier.js +3 -2
  67. package/dist/src/mcp/auth-verifier.js.map +1 -1
  68. package/dist/src/mcp/bridge.d.ts +14 -10
  69. package/dist/src/mcp/bridge.d.ts.map +1 -1
  70. package/dist/src/mcp/bridge.js +51 -227
  71. package/dist/src/mcp/bridge.js.map +1 -1
  72. package/dist/src/mcp/http-transport.d.ts +2 -0
  73. package/dist/src/mcp/http-transport.d.ts.map +1 -1
  74. package/dist/src/mcp/http-transport.js +117 -66
  75. package/dist/src/mcp/http-transport.js.map +1 -1
  76. package/dist/src/mcp/internal-auth.d.ts +13 -0
  77. package/dist/src/mcp/internal-auth.d.ts.map +1 -0
  78. package/dist/src/mcp/internal-auth.js +12 -0
  79. package/dist/src/mcp/internal-auth.js.map +1 -0
  80. package/dist/src/mcp/tool-definitions.d.ts +41 -0
  81. package/dist/src/mcp/tool-definitions.d.ts.map +1 -0
  82. package/dist/src/mcp/tool-definitions.js +491 -0
  83. package/dist/src/mcp/tool-definitions.js.map +1 -0
  84. package/dist/src/middleware/auth.js.map +1 -1
  85. package/dist/src/middleware/session.js.map +1 -1
  86. package/dist/src/middleware/validate.d.ts +8 -0
  87. package/dist/src/middleware/validate.d.ts.map +1 -1
  88. package/dist/src/middleware/validate.js +45 -0
  89. package/dist/src/middleware/validate.js.map +1 -1
  90. package/dist/src/policy/engine.d.ts +4 -0
  91. package/dist/src/policy/engine.d.ts.map +1 -1
  92. package/dist/src/policy/engine.js +117 -0
  93. package/dist/src/policy/engine.js.map +1 -1
  94. package/dist/src/saas/routes.d.ts.map +1 -1
  95. package/dist/src/saas/routes.js +355 -22
  96. package/dist/src/saas/routes.js.map +1 -1
  97. package/dist/src/server/app.d.ts.map +1 -1
  98. package/dist/src/server/app.js +24 -3
  99. package/dist/src/server/app.js.map +1 -1
  100. package/dist/src/server/gateway.d.ts.map +1 -1
  101. package/dist/src/server/gateway.js +17 -0
  102. package/dist/src/server/gateway.js.map +1 -1
  103. package/dist/src/server/index.d.ts.map +1 -1
  104. package/dist/src/server/index.js +18 -0
  105. package/dist/src/server/index.js.map +1 -1
  106. package/dist/src/storage/interfaces.d.ts +14 -3
  107. package/dist/src/storage/interfaces.d.ts.map +1 -1
  108. package/dist/src/storage/memory.d.ts +2 -0
  109. package/dist/src/storage/memory.d.ts.map +1 -1
  110. package/dist/src/storage/memory.js +6 -0
  111. package/dist/src/storage/memory.js.map +1 -1
  112. package/dist/src/storage/postgres.d.ts +5 -0
  113. package/dist/src/storage/postgres.d.ts.map +1 -1
  114. package/dist/src/storage/postgres.js +16 -0
  115. package/dist/src/storage/postgres.js.map +1 -1
  116. package/dist/src/storage/redis.d.ts +10 -0
  117. package/dist/src/storage/redis.d.ts.map +1 -1
  118. package/dist/src/storage/redis.js +65 -0
  119. package/dist/src/storage/redis.js.map +1 -1
  120. package/dist/src/types/budget.d.ts +4 -0
  121. package/dist/src/types/budget.d.ts.map +1 -1
  122. package/dist/src/types/config.d.ts +58 -0
  123. package/dist/src/types/config.d.ts.map +1 -1
  124. package/dist/src/types/events.d.ts +1 -0
  125. package/dist/src/types/events.d.ts.map +1 -1
  126. package/dist/src/types/policy.d.ts +11 -1
  127. package/dist/src/types/policy.d.ts.map +1 -1
  128. package/dist/src/types/tool-result.d.ts +11 -0
  129. package/dist/src/types/tool-result.d.ts.map +1 -1
  130. package/dist/tests/unit/app-routes.test.d.ts +2 -0
  131. package/dist/tests/unit/app-routes.test.d.ts.map +1 -0
  132. package/dist/tests/unit/app-routes.test.js +715 -0
  133. package/dist/tests/unit/app-routes.test.js.map +1 -0
  134. package/dist/tests/unit/audit-logger.test.js +105 -0
  135. package/dist/tests/unit/audit-logger.test.js.map +1 -1
  136. package/dist/tests/unit/auth-providers.test.d.ts +2 -0
  137. package/dist/tests/unit/auth-providers.test.d.ts.map +1 -0
  138. package/dist/tests/unit/auth-providers.test.js +279 -0
  139. package/dist/tests/unit/auth-providers.test.js.map +1 -0
  140. package/dist/tests/unit/auth-routes-extended.test.d.ts +2 -0
  141. package/dist/tests/unit/auth-routes-extended.test.d.ts.map +1 -0
  142. package/dist/tests/unit/auth-routes-extended.test.js +993 -0
  143. package/dist/tests/unit/auth-routes-extended.test.js.map +1 -0
  144. package/dist/tests/unit/auth-verifier.test.d.ts +2 -0
  145. package/dist/tests/unit/auth-verifier.test.d.ts.map +1 -0
  146. package/dist/tests/unit/auth-verifier.test.js +505 -0
  147. package/dist/tests/unit/auth-verifier.test.js.map +1 -0
  148. package/dist/tests/unit/billing-routes.test.d.ts +2 -0
  149. package/dist/tests/unit/billing-routes.test.d.ts.map +1 -0
  150. package/dist/tests/unit/billing-routes.test.js +432 -0
  151. package/dist/tests/unit/billing-routes.test.js.map +1 -0
  152. package/dist/tests/unit/config-defaults.test.d.ts +2 -0
  153. package/dist/tests/unit/config-defaults.test.d.ts.map +1 -0
  154. package/dist/tests/unit/config-defaults.test.js +119 -0
  155. package/dist/tests/unit/config-defaults.test.js.map +1 -0
  156. package/dist/tests/unit/defaults.test.js +0 -10
  157. package/dist/tests/unit/defaults.test.js.map +1 -1
  158. package/dist/tests/unit/filesystem-executor.test.d.ts +2 -0
  159. package/dist/tests/unit/filesystem-executor.test.d.ts.map +1 -0
  160. package/dist/tests/unit/filesystem-executor.test.js +280 -0
  161. package/dist/tests/unit/filesystem-executor.test.js.map +1 -0
  162. package/dist/tests/unit/gateway-branches.test.d.ts +2 -0
  163. package/dist/tests/unit/gateway-branches.test.d.ts.map +1 -0
  164. package/dist/tests/unit/gateway-branches.test.js +1039 -0
  165. package/dist/tests/unit/gateway-branches.test.js.map +1 -0
  166. package/dist/tests/unit/http-executor-branches.test.d.ts +2 -0
  167. package/dist/tests/unit/http-executor-branches.test.d.ts.map +1 -0
  168. package/dist/tests/unit/http-executor-branches.test.js +495 -0
  169. package/dist/tests/unit/http-executor-branches.test.js.map +1 -0
  170. package/dist/tests/unit/logger.test.d.ts +2 -0
  171. package/dist/tests/unit/logger.test.d.ts.map +1 -0
  172. package/dist/tests/unit/logger.test.js +97 -0
  173. package/dist/tests/unit/logger.test.js.map +1 -0
  174. package/dist/tests/unit/mcp-internal-auth.test.d.ts +2 -0
  175. package/dist/tests/unit/mcp-internal-auth.test.d.ts.map +1 -0
  176. package/dist/tests/unit/mcp-internal-auth.test.js +445 -0
  177. package/dist/tests/unit/mcp-internal-auth.test.js.map +1 -0
  178. package/dist/tests/unit/metrics.test.js +102 -0
  179. package/dist/tests/unit/metrics.test.js.map +1 -1
  180. package/dist/tests/unit/model-pricing.test.d.ts +2 -0
  181. package/dist/tests/unit/model-pricing.test.d.ts.map +1 -0
  182. package/dist/tests/unit/model-pricing.test.js +87 -0
  183. package/dist/tests/unit/model-pricing.test.js.map +1 -0
  184. package/dist/tests/unit/oauth-stores.test.d.ts +2 -0
  185. package/dist/tests/unit/oauth-stores.test.d.ts.map +1 -0
  186. package/dist/tests/unit/oauth-stores.test.js +260 -0
  187. package/dist/tests/unit/oauth-stores.test.js.map +1 -0
  188. package/dist/tests/unit/policy-engine.test.js +466 -0
  189. package/dist/tests/unit/policy-engine.test.js.map +1 -1
  190. package/dist/tests/unit/provider-interceptor.test.d.ts +2 -0
  191. package/dist/tests/unit/provider-interceptor.test.d.ts.map +1 -0
  192. package/dist/tests/unit/provider-interceptor.test.js +472 -0
  193. package/dist/tests/unit/provider-interceptor.test.js.map +1 -0
  194. package/dist/tests/unit/saas-routes-branches.test.d.ts +2 -0
  195. package/dist/tests/unit/saas-routes-branches.test.d.ts.map +1 -0
  196. package/dist/tests/unit/saas-routes-branches.test.js +2165 -0
  197. package/dist/tests/unit/saas-routes-branches.test.js.map +1 -0
  198. package/dist/tests/unit/saas-routes-crud.test.d.ts +2 -0
  199. package/dist/tests/unit/saas-routes-crud.test.d.ts.map +1 -0
  200. package/dist/tests/unit/saas-routes-crud.test.js +332 -0
  201. package/dist/tests/unit/saas-routes-crud.test.js.map +1 -0
  202. package/dist/tests/unit/saas-routes-data.test.d.ts +2 -0
  203. package/dist/tests/unit/saas-routes-data.test.d.ts.map +1 -0
  204. package/dist/tests/unit/saas-routes-data.test.js +405 -0
  205. package/dist/tests/unit/saas-routes-data.test.js.map +1 -0
  206. package/dist/tests/unit/saas-routes.test.js +3 -3
  207. package/dist/tests/unit/saas-routes.test.js.map +1 -1
  208. package/dist/tests/unit/shell-executor.test.d.ts +2 -0
  209. package/dist/tests/unit/shell-executor.test.d.ts.map +1 -0
  210. package/dist/tests/unit/shell-executor.test.js +145 -0
  211. package/dist/tests/unit/shell-executor.test.js.map +1 -0
  212. package/dist/tests/unit/sql-executor.test.d.ts +2 -0
  213. package/dist/tests/unit/sql-executor.test.d.ts.map +1 -0
  214. package/dist/tests/unit/sql-executor.test.js +177 -0
  215. package/dist/tests/unit/sql-executor.test.js.map +1 -0
  216. package/dist/tests/unit/stream-proxy.test.d.ts +2 -0
  217. package/dist/tests/unit/stream-proxy.test.d.ts.map +1 -0
  218. package/dist/tests/unit/stream-proxy.test.js +147 -0
  219. package/dist/tests/unit/stream-proxy.test.js.map +1 -0
  220. package/dist/tests/unit/tool-definitions.test.d.ts +2 -0
  221. package/dist/tests/unit/tool-definitions.test.d.ts.map +1 -0
  222. package/dist/tests/unit/tool-definitions.test.js +184 -0
  223. package/dist/tests/unit/tool-definitions.test.js.map +1 -0
  224. package/dist/tests/unit/usage-extractor.test.js +140 -0
  225. package/dist/tests/unit/usage-extractor.test.js.map +1 -1
  226. package/dist/tests/unit/webhook-handler.test.d.ts +2 -0
  227. package/dist/tests/unit/webhook-handler.test.d.ts.map +1 -0
  228. package/dist/tests/unit/webhook-handler.test.js +453 -0
  229. package/dist/tests/unit/webhook-handler.test.js.map +1 -0
  230. package/dist/tests/unit/webhook-routes.test.d.ts +2 -0
  231. package/dist/tests/unit/webhook-routes.test.d.ts.map +1 -0
  232. package/dist/tests/unit/webhook-routes.test.js +69 -0
  233. package/dist/tests/unit/webhook-routes.test.js.map +1 -0
  234. package/dist/tests/unit/websocket-executor.test.d.ts +2 -0
  235. package/dist/tests/unit/websocket-executor.test.d.ts.map +1 -0
  236. package/dist/tests/unit/websocket-executor.test.js +121 -0
  237. package/dist/tests/unit/websocket-executor.test.js.map +1 -0
  238. package/package.json +8 -2
  239. package/policy-packs/demo_fail.yaml +41 -0
  240. package/policy-packs/full_tools.yaml +136 -0
  241. package/src/admin/index.ts +1 -0
  242. package/src/admin/routes.ts +509 -0
  243. package/src/admin/templates.ts +572 -0
  244. package/src/anomaly/detector.ts +730 -0
  245. package/src/anomaly/index.ts +1 -0
  246. package/src/approval/manager.ts +569 -0
  247. package/src/approval/webhook.ts +133 -0
  248. package/src/audit/logger.ts +490 -0
  249. package/src/auth/index.ts +5 -0
  250. package/src/auth/password.ts +21 -0
  251. package/src/auth/pkce.ts +22 -0
  252. package/src/auth/providers.ts +208 -0
  253. package/src/auth/routes.ts +561 -0
  254. package/src/auth/session.ts +84 -0
  255. package/src/billing/index.ts +6 -0
  256. package/src/billing/plan-enforcer.ts +135 -0
  257. package/src/billing/routes.ts +229 -0
  258. package/src/billing/stripe-client.ts +58 -0
  259. package/src/billing/webhook-handler.ts +182 -0
  260. package/src/billing/webhook-routes.ts +28 -0
  261. package/src/budget/manager.ts +679 -0
  262. package/src/budget/model-pricing.ts +119 -0
  263. package/src/budget/usage-extractor.ts +214 -0
  264. package/src/cli.ts +91 -0
  265. package/src/config/defaults.ts +261 -0
  266. package/src/config/validate.ts +88 -0
  267. package/src/dlp/composite-scanner.ts +213 -0
  268. package/src/dlp/index.ts +9 -0
  269. package/src/dlp/interfaces.ts +34 -0
  270. package/src/dlp/patterns.ts +30 -0
  271. package/src/dlp/prompt-injection-backend.ts +181 -0
  272. package/src/dlp/prompt-injection-patterns.ts +302 -0
  273. package/src/dlp/regex-backend.ts +181 -0
  274. package/src/dlp/scanner.ts +502 -0
  275. package/src/dlp/text-normalizer.ts +225 -0
  276. package/src/dlp/tool-patterns.ts +35 -0
  277. package/src/dlp/trufflehog-backend.ts +190 -0
  278. package/src/executor/filesystem-executor.ts +196 -0
  279. package/src/executor/http-executor.ts +349 -0
  280. package/src/executor/index.ts +9 -0
  281. package/src/executor/interfaces.ts +11 -0
  282. package/src/executor/noop-executor.ts +23 -0
  283. package/src/executor/registry.ts +64 -0
  284. package/src/executor/shell-executor.ts +148 -0
  285. package/src/executor/slack-executor.ts +176 -0
  286. package/src/executor/sql-executor.ts +146 -0
  287. package/src/executor/websocket-executor.ts +211 -0
  288. package/src/index.ts +24 -0
  289. package/src/interceptor/index.ts +1 -0
  290. package/src/interceptor/provider-interceptor.ts +315 -0
  291. package/src/mcp/auth-verifier.ts +152 -0
  292. package/src/mcp/bridge.ts +703 -0
  293. package/src/mcp/http-transport.ts +698 -0
  294. package/src/mcp/index.ts +9 -0
  295. package/src/mcp/internal-auth.ts +14 -0
  296. package/src/mcp/oauth-pages.ts +139 -0
  297. package/src/mcp/oauth-postgres-stores.ts +278 -0
  298. package/src/mcp/oauth-provider.ts +536 -0
  299. package/src/mcp/oauth-stores.ts +202 -0
  300. package/src/mcp/server.ts +55 -0
  301. package/src/mcp/tool-definitions.ts +562 -0
  302. package/src/metrics/collector.ts +357 -0
  303. package/src/metrics/index.ts +1 -0
  304. package/src/middleware/auth.ts +814 -0
  305. package/src/middleware/session.ts +85 -0
  306. package/src/middleware/validate.ts +130 -0
  307. package/src/policy/engine.ts +815 -0
  308. package/src/policy/index.ts +2 -0
  309. package/src/policy/opa-engine.ts +829 -0
  310. package/src/proxy/forward-proxy.ts +649 -0
  311. package/src/proxy/index.ts +1 -0
  312. package/src/ratelimit/limiter.ts +196 -0
  313. package/src/replay/engine.ts +142 -0
  314. package/src/replay/index.ts +1 -0
  315. package/src/saas/index.ts +1 -0
  316. package/src/saas/routes.ts +2178 -0
  317. package/src/server/app.ts +985 -0
  318. package/src/server/errors.ts +49 -0
  319. package/src/server/gateway.ts +1130 -0
  320. package/src/server/index.ts +307 -0
  321. package/src/server/logger.ts +255 -0
  322. package/src/server/stream-proxy.ts +202 -0
  323. package/src/storage/file-persistence.ts +315 -0
  324. package/src/storage/index.ts +4 -0
  325. package/src/storage/interfaces.ts +287 -0
  326. package/src/storage/memory.ts +686 -0
  327. package/src/storage/postgres.ts +1831 -0
  328. package/src/storage/redis.ts +835 -0
  329. package/src/tracing/index.ts +1 -0
  330. package/src/tracing/provider.ts +100 -0
  331. package/src/trust/calculator.ts +141 -0
  332. package/src/trust/index.ts +7 -0
  333. package/src/types/budget.ts +36 -0
  334. package/src/types/config.ts +278 -0
  335. package/src/types/events.ts +41 -0
  336. package/src/types/express.d.ts +14 -0
  337. package/src/types/index.ts +7 -0
  338. package/src/types/policy.ts +83 -0
  339. package/src/types/stripe-config.ts +11 -0
  340. package/src/types/subscription.ts +59 -0
  341. package/src/types/tool-call.ts +47 -0
  342. package/src/types/tool-result.ts +82 -0
  343. package/src/types/user.ts +125 -0
  344. package/tsconfig.json +24 -0
@@ -0,0 +1 @@
1
+ export { AnomalyDetector, AnomalyConfig, AnomalyAlert, AnomalyType, AnomalyState, RollingWindow } from './detector';
@@ -0,0 +1,569 @@
1
+ import * as crypto from 'crypto';
2
+ import * as jwt from 'jsonwebtoken';
3
+ import { randomUUID } from 'crypto';
4
+ import { ToolCall } from '../types/tool-call';
5
+ import { ApprovalConfig } from '../types/config';
6
+ import { ApprovalWebhook } from './webhook';
7
+ import { ApprovalStore } from '../storage/interfaces';
8
+ import { InMemoryApprovalStore } from '../storage/memory';
9
+
10
+ /** SHA-256 hash a string and return the hex digest */
11
+ function hashToken(token: string): string {
12
+ return crypto.createHash('sha256').update(token).digest('hex');
13
+ }
14
+
15
+ /** Maximum number of used token hashes to track (prevents unbounded memory growth) */
16
+ const MAX_USED_TOKENS = 100_000;
17
+
18
+ export interface PendingApproval {
19
+ approval_id: string;
20
+ tool_call_id: string;
21
+ task_id: string;
22
+ workspace_id: string;
23
+ actor_id: string;
24
+ requesting_api_key_id?: string; // Hash of the API key that created this approval (for self-approval prevention)
25
+ tool_name: string;
26
+ tool_capability: string;
27
+ args_summary: string; // Sanitized summary of args (no secrets)
28
+ scope: string; // Who can approve (e.g., 'team_lead', 'security_lead', 'admin')
29
+ reason: string; // Why approval is needed
30
+ token_hash: string; // SHA-256 hash of the JWT approval token (never store raw token)
31
+ status: 'pending' | 'approved' | 'denied' | 'expired';
32
+ created_at: string;
33
+ expires_at: string;
34
+ resolved_at?: string;
35
+ resolved_by?: string;
36
+ denial_reason?: string;
37
+ }
38
+
39
+ export interface CreateApprovalResult {
40
+ approval: PendingApproval;
41
+ token: string; // Raw JWT token (returned to caller, never stored)
42
+ }
43
+
44
+ export interface ApprovalResult {
45
+ approved: boolean;
46
+ approval_id: string;
47
+ resolved_by?: string;
48
+ denial_reason?: string;
49
+ }
50
+
51
+ export class ApprovalManager {
52
+ private config: ApprovalConfig;
53
+ private store: ApprovalStore;
54
+ private webhook: ApprovalWebhook | null;
55
+ private requireDifferentIdentity: boolean;
56
+ /** Track approval IDs currently being processed to prevent concurrent resolution */
57
+ private inFlightApprovals = new Set<string>();
58
+ /**
59
+ * Track used approval tokens (token hash -> usage timestamp) to prevent replay attacks.
60
+ * This is defense-in-depth; the primary defense against replay is the approval status
61
+ * check in the store (which persists across restarts).
62
+ */
63
+ private usedTokens = new Map<string, number>();
64
+
65
+ constructor(config: ApprovalConfig, store?: ApprovalStore, options?: { require_different_identity?: boolean }) {
66
+ this.config = config;
67
+ this.store = store ?? new InMemoryApprovalStore();
68
+ this.webhook = config.webhook_url
69
+ ? new ApprovalWebhook(config.webhook_url, config.webhook_headers)
70
+ : null;
71
+ this.requireDifferentIdentity = options?.require_different_identity ?? true;
72
+ }
73
+
74
+ /**
75
+ * Create a pending approval for a tool call that received a REQUIRE_APPROVAL
76
+ * decision from the policy engine. Generates a signed JWT token that must be
77
+ * presented when approving or denying the request.
78
+ */
79
+ createApproval(toolCall: ToolCall, scope: string, reason: string, ttlSeconds?: number, requestingApiKeyId?: string): CreateApprovalResult {
80
+ const approvalId = randomUUID();
81
+ const ttl = ttlSeconds || this.config.default_ttl_seconds;
82
+ const now = new Date();
83
+ const expiresAt = new Date(now.getTime() + ttl * 1000);
84
+
85
+ // Create JWT token containing approval metadata
86
+ const token = jwt.sign(
87
+ {
88
+ approval_id: approvalId,
89
+ tool_call_id: toolCall.tool_call_id,
90
+ task_id: toolCall.task_id,
91
+ workspace_id: toolCall.workspace_id,
92
+ scope,
93
+ },
94
+ this.config.token_secret,
95
+ { expiresIn: ttl }
96
+ );
97
+
98
+ // Hash the token before storing (never persist the raw JWT)
99
+ const tokenHash = hashToken(token);
100
+
101
+ // Create sanitized args summary (strip sensitive fields)
102
+ const argsSummary = this.sanitizeArgs(toolCall);
103
+
104
+ // Hash the requesting API key ID to prevent storing raw keys
105
+ const requestingKeyHash = requestingApiKeyId
106
+ ? hashToken(requestingApiKeyId)
107
+ : undefined;
108
+
109
+ const approval: PendingApproval = {
110
+ approval_id: approvalId,
111
+ tool_call_id: toolCall.tool_call_id,
112
+ task_id: toolCall.task_id,
113
+ workspace_id: toolCall.workspace_id,
114
+ actor_id: toolCall.actor.id,
115
+ requesting_api_key_id: requestingKeyHash,
116
+ tool_name: toolCall.tool.name,
117
+ tool_capability: toolCall.tool.capability,
118
+ args_summary: argsSummary,
119
+ scope,
120
+ reason,
121
+ token_hash: tokenHash,
122
+ status: 'pending',
123
+ created_at: now.toISOString(),
124
+ expires_at: expiresAt.toISOString(),
125
+ };
126
+
127
+ this.store.save(approvalId, approval);
128
+ this.store.indexToken(tokenHash, approvalId);
129
+
130
+ if (this.webhook) {
131
+ this.webhook.notify('approval_requested', approval);
132
+ }
133
+
134
+ return { approval, token };
135
+ }
136
+
137
+ /**
138
+ * Approve a pending request by presenting the signed JWT token.
139
+ *
140
+ * Verifies the token signature and expiry, locates the corresponding
141
+ * approval record, confirms it is still in 'pending' status, and
142
+ * transitions it to 'approved'.
143
+ *
144
+ * Throws an Error for invalid tokens, expired approvals, or approvals
145
+ * that have already been resolved.
146
+ */
147
+ async approve(token: string, approverId: string, approverApiKeyId?: string): Promise<ApprovalResult> {
148
+ const tokenHash = hashToken(token);
149
+
150
+ // Token replay prevention: check if this token was already used
151
+ if (this.usedTokens.has(tokenHash)) {
152
+ throw new Error('Approval token has already been used');
153
+ }
154
+
155
+ const approval = this.resolveTokenToApproval(token);
156
+
157
+ // In-process lock: prevent concurrent resolution of the same approval
158
+ if (this.inFlightApprovals.has(approval.approval_id)) {
159
+ throw new Error(`Approval "${approval.approval_id}" is already being processed by another request`);
160
+ }
161
+ this.inFlightApprovals.add(approval.approval_id);
162
+
163
+ try {
164
+ // Prevent self-approval via API key comparison (cryptographic identity)
165
+ if (approval.requesting_api_key_id && approverApiKeyId) {
166
+ const approverKeyHash = hashToken(approverApiKeyId);
167
+ if (approverKeyHash === approval.requesting_api_key_id) {
168
+ console.warn(`[ApprovalManager] Self-approval attempt blocked: approver=${approverId}, approval=${approval.approval_id}, workspace=${approval.workspace_id}`);
169
+ throw new Error('Self-approval is not allowed. A different API key must approve this request.');
170
+ }
171
+ } else if (this.requireDifferentIdentity && (!approval.requesting_api_key_id || !approverApiKeyId)) {
172
+ // Without cryptographic identity on both sides, deny self-approval entirely
173
+ throw new Error('Self-approval not allowed without cryptographic identity (API key required)');
174
+ }
175
+
176
+ // Actor-level self-approval check: prevent same actor from approving their own request
177
+ if (approval.actor_id && approverId && approval.actor_id === approverId) {
178
+ console.warn(`[ApprovalManager] Self-approval attempt blocked (actor match): actor=${approverId}, approval=${approval.approval_id}, workspace=${approval.workspace_id}`);
179
+ throw new Error('Self-approval is not allowed. A different user must approve this request.');
180
+ }
181
+
182
+ // Build updated approval object (do NOT mutate the original before CAS)
183
+ const updated = {
184
+ ...approval,
185
+ status: 'approved' as const,
186
+ resolved_at: new Date().toISOString(),
187
+ resolved_by: approverId,
188
+ };
189
+
190
+ // Atomic CAS: use compareAndSetStatus if available to prevent race conditions
191
+ // where two concurrent approve() calls both resolve the same pending approval
192
+ if (this.store.compareAndSetStatus) {
193
+ const swapped = await this.store.compareAndSetStatus(approval.approval_id, 'pending', updated);
194
+ if (!swapped) {
195
+ throw new Error(`Approval "${approval.approval_id}" was already resolved by another request`);
196
+ }
197
+ } else {
198
+ this.store.save(approval.approval_id, updated);
199
+ }
200
+
201
+ // Mark token as used to prevent replay
202
+ this.usedTokens.set(tokenHash, Date.now());
203
+ if (this.usedTokens.size > MAX_USED_TOKENS) {
204
+ const evictCount = Math.floor(MAX_USED_TOKENS * 0.1);
205
+ let deleted = 0;
206
+ for (const key of this.usedTokens.keys()) {
207
+ if (deleted >= evictCount) break;
208
+ this.usedTokens.delete(key);
209
+ deleted++;
210
+ }
211
+ }
212
+
213
+ if (this.webhook) {
214
+ this.webhook.notify('approval_approved', updated);
215
+ }
216
+
217
+ return {
218
+ approved: true,
219
+ approval_id: approval.approval_id,
220
+ resolved_by: approverId,
221
+ };
222
+ } finally {
223
+ this.inFlightApprovals.delete(approval.approval_id);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Deny a pending request by presenting the signed JWT token along with a
229
+ * reason for denial.
230
+ *
231
+ * Verifies the token signature and expiry, locates the corresponding
232
+ * approval record, confirms it is still in 'pending' status, and
233
+ * transitions it to 'denied'.
234
+ *
235
+ * Throws an Error for invalid tokens, expired approvals, or approvals
236
+ * that have already been resolved.
237
+ */
238
+ async deny(token: string, approverId: string, reason: string, _approverApiKeyId?: string): Promise<ApprovalResult> {
239
+ const tokenHash = hashToken(token);
240
+
241
+ // Token replay prevention: check if this token was already used
242
+ if (this.usedTokens.has(tokenHash)) {
243
+ throw new Error('Approval token has already been used');
244
+ }
245
+
246
+ const approval = this.resolveTokenToApproval(token);
247
+
248
+ // In-process lock: prevent concurrent resolution of the same approval
249
+ if (this.inFlightApprovals.has(approval.approval_id)) {
250
+ throw new Error(`Approval "${approval.approval_id}" is already being processed by another request`);
251
+ }
252
+ this.inFlightApprovals.add(approval.approval_id);
253
+
254
+ try {
255
+ // Build updated approval object (do NOT mutate the original before CAS)
256
+ const updated = {
257
+ ...approval,
258
+ status: 'denied' as const,
259
+ resolved_at: new Date().toISOString(),
260
+ resolved_by: approverId,
261
+ denial_reason: reason,
262
+ };
263
+
264
+ // Atomic CAS: use compareAndSetStatus if available to prevent race conditions
265
+ if (this.store.compareAndSetStatus) {
266
+ const swapped = await this.store.compareAndSetStatus(approval.approval_id, 'pending', updated);
267
+ if (!swapped) {
268
+ throw new Error(`Approval "${approval.approval_id}" was already resolved by another request`);
269
+ }
270
+ } else {
271
+ this.store.save(approval.approval_id, updated);
272
+ }
273
+
274
+ // Mark token as used to prevent replay
275
+ this.usedTokens.set(tokenHash, Date.now());
276
+ if (this.usedTokens.size > MAX_USED_TOKENS) {
277
+ const evictCount = Math.floor(MAX_USED_TOKENS * 0.1);
278
+ let deleted = 0;
279
+ for (const key of this.usedTokens.keys()) {
280
+ if (deleted >= evictCount) break;
281
+ this.usedTokens.delete(key);
282
+ deleted++;
283
+ }
284
+ }
285
+
286
+ if (this.webhook) {
287
+ this.webhook.notify('approval_denied', updated);
288
+ }
289
+
290
+ return {
291
+ approved: false,
292
+ approval_id: approval.approval_id,
293
+ resolved_by: approverId,
294
+ denial_reason: reason,
295
+ };
296
+ } finally {
297
+ this.inFlightApprovals.delete(approval.approval_id);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Resolve an approval by its ID (for admin panel use where the raw token is
303
+ * not available). Validates the approval is still pending and not expired.
304
+ */
305
+ async resolveById(approvalId: string, approverId: string, approved: boolean, reason?: string, approverApiKeyId?: string): Promise<ApprovalResult> {
306
+ // In-process lock: prevent concurrent resolution of the same approval
307
+ if (this.inFlightApprovals.has(approvalId)) {
308
+ throw new Error(`Approval "${approvalId}" is already being processed by another request`);
309
+ }
310
+ this.inFlightApprovals.add(approvalId);
311
+
312
+ try {
313
+ const approval = this.store.getById(approvalId);
314
+ if (!approval) {
315
+ throw new Error('Approval not found');
316
+ }
317
+
318
+ if (approval.status === 'approved') {
319
+ throw new Error(`Approval "${approvalId}" has already been approved`);
320
+ }
321
+ if (approval.status === 'denied') {
322
+ throw new Error(`Approval "${approvalId}" has already been denied`);
323
+ }
324
+ if (approval.status === 'expired' || new Date() > new Date(approval.expires_at)) {
325
+ approval.status = 'expired';
326
+ throw new Error(`Approval "${approvalId}" has expired`);
327
+ }
328
+
329
+ // Prevent self-approval via API key comparison (cryptographic identity)
330
+ if (approved && approval.requesting_api_key_id && approverApiKeyId) {
331
+ const approverKeyHash = hashToken(approverApiKeyId);
332
+ if (approverKeyHash === approval.requesting_api_key_id) {
333
+ console.warn(`[ApprovalManager] Self-approval attempt blocked: approver=${approverId}, approval=${approvalId}, workspace=${approval.workspace_id}`);
334
+ throw new Error('Self-approval is not allowed. A different API key must approve this request.');
335
+ }
336
+ } else if (approved && !approval.requesting_api_key_id && !approverApiKeyId) {
337
+ // Fallback: compare actor IDs when no API key is available (admin panel use)
338
+ if (approval.actor_id === approverId) {
339
+ console.warn(`[ApprovalManager] Self-approval attempt blocked (actor match): actor=${approverId}, approval=${approvalId}, workspace=${approval.workspace_id}`);
340
+ throw new Error('Self-approval is not allowed. A different user must approve this request.');
341
+ }
342
+ }
343
+
344
+ // Actor-level self-approval check for API key path too
345
+ if (approved && approval.actor_id && approverId && approval.actor_id === approverId) {
346
+ console.warn(`[ApprovalManager] Self-approval attempt blocked (actor match): actor=${approverId}, approval=${approvalId}, workspace=${approval.workspace_id}`);
347
+ throw new Error('Self-approval is not allowed. A different user must approve this request.');
348
+ }
349
+
350
+ // Build updated approval object (do NOT mutate the original before CAS)
351
+ const updated = approved
352
+ ? { ...approval, status: 'approved' as const, resolved_at: new Date().toISOString(), resolved_by: approverId }
353
+ : { ...approval, status: 'denied' as const, resolved_at: new Date().toISOString(), resolved_by: approverId, denial_reason: reason || 'Denied' };
354
+
355
+ // Atomic CAS: use compareAndSetStatus if available
356
+ if (this.store.compareAndSetStatus) {
357
+ const swapped = await this.store.compareAndSetStatus(approvalId, 'pending', updated);
358
+ if (!swapped) {
359
+ throw new Error(`Approval "${approvalId}" was already resolved by another request`);
360
+ }
361
+ } else {
362
+ this.store.save(approvalId, updated);
363
+ }
364
+
365
+ if (this.webhook) {
366
+ this.webhook.notify(approved ? 'approval_approved' : 'approval_denied', updated);
367
+ }
368
+
369
+ return {
370
+ approved,
371
+ approval_id: approvalId,
372
+ resolved_by: approverId,
373
+ denial_reason: approved ? undefined : reason,
374
+ };
375
+ } finally {
376
+ this.inFlightApprovals.delete(approvalId);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Check if an approval exists and return its current status.
382
+ * Automatically marks the approval as 'expired' if it has passed its TTL.
383
+ */
384
+ getApproval(approvalId: string): PendingApproval | null {
385
+ const approval = this.store.getById(approvalId);
386
+ if (!approval) return null;
387
+
388
+ // Check if expired
389
+ if (approval.status === 'pending' && new Date() > new Date(approval.expires_at)) {
390
+ approval.status = 'expired';
391
+ }
392
+
393
+ return approval;
394
+ }
395
+
396
+ /**
397
+ * Look up an approval by the original tool_call_id. This is useful when an
398
+ * agent retries a tool call and needs to find the approval that was created
399
+ * for the original attempt.
400
+ *
401
+ * Automatically marks the approval as 'expired' if it has passed its TTL.
402
+ */
403
+ getApprovalByToolCallId(toolCallId: string): PendingApproval | null {
404
+ const approval = this.store.getByToolCallId(toolCallId);
405
+ if (!approval) return null;
406
+
407
+ // Check expiry
408
+ if (approval.status === 'pending' && new Date() > new Date(approval.expires_at)) {
409
+ approval.status = 'expired';
410
+ }
411
+ return approval;
412
+ }
413
+
414
+ /**
415
+ * Find an existing approved approval for the same task+actor+tool combination.
416
+ * Used to bypass re-approval when an agent retries after a previous approval.
417
+ */
418
+ findApprovedForTask(taskId: string, actorId: string, toolName: string, capability: string): PendingApproval | null {
419
+ if (this.store.findApproved) {
420
+ return this.store.findApproved(taskId, actorId, toolName, capability) || null;
421
+ }
422
+ return null;
423
+ }
424
+
425
+ /**
426
+ * Get all currently pending approvals, optionally filtered by workspace.
427
+ * Expires any approvals that are past their TTL before returning results.
428
+ */
429
+ getPendingApprovals(workspaceId?: string): PendingApproval[] {
430
+ const now = new Date();
431
+ const candidates = this.store.findPending(workspaceId);
432
+ const results: PendingApproval[] = [];
433
+
434
+ for (const approval of candidates) {
435
+ // Expire any that are past TTL
436
+ if (approval.status === 'pending' && now > new Date(approval.expires_at)) {
437
+ approval.status = 'expired';
438
+ continue;
439
+ }
440
+
441
+ results.push(approval);
442
+ }
443
+
444
+ return results;
445
+ }
446
+
447
+ /**
448
+ * Verify and decode a JWT approval token.
449
+ * Returns the decoded payload on success, or an error message on failure.
450
+ */
451
+ private verifyToken(token: string): { valid: boolean; payload?: any; error?: string } {
452
+ try {
453
+ const payload = jwt.verify(token, this.config.token_secret);
454
+ return { valid: true, payload };
455
+ } catch (err) {
456
+ const message = err instanceof Error ? err.message : 'Invalid token';
457
+ return { valid: false, error: message };
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Common logic shared by approve() and deny(): verify the token, look up
463
+ * the approval record, and validate that it can still be resolved.
464
+ *
465
+ * Returns the mutable PendingApproval record so the caller can update its
466
+ * status fields directly.
467
+ *
468
+ * Throws an Error if:
469
+ * - The token is invalid or has an expired signature
470
+ * - No approval record is found for the token
471
+ * - The approval has already been resolved (approved/denied)
472
+ * - The approval has expired (past TTL)
473
+ */
474
+ private resolveTokenToApproval(token: string): PendingApproval {
475
+ // Step 1: Verify the JWT signature and expiry
476
+ const verification = this.verifyToken(token);
477
+ if (!verification.valid) {
478
+ throw new Error(`Invalid approval token: ${verification.error}`);
479
+ }
480
+
481
+ // Step 2: Look up the approval record by token hash
482
+ const tokenHash = hashToken(token);
483
+ const approval = this.store.getByToken(tokenHash);
484
+ if (!approval) {
485
+ throw new Error('Approval not found for the provided token');
486
+ }
487
+
488
+ const approvalId = approval.approval_id;
489
+
490
+ // Step 3: Check if the approval has already been resolved
491
+ if (approval.status === 'approved') {
492
+ throw new Error(`Approval "${approvalId}" has already been approved`);
493
+ }
494
+ if (approval.status === 'denied') {
495
+ throw new Error(`Approval "${approvalId}" has already been denied`);
496
+ }
497
+
498
+ // Step 4: Check if the approval has expired (belt-and-suspenders with JWT expiry)
499
+ if (approval.status === 'expired' || new Date() > new Date(approval.expires_at)) {
500
+ approval.status = 'expired';
501
+ throw new Error(`Approval "${approvalId}" has expired`);
502
+ }
503
+
504
+ return approval;
505
+ }
506
+
507
+ /**
508
+ * Create a sanitized summary of the tool call arguments that is safe to
509
+ * display to an approver. Strips query parameters (may contain tokens or
510
+ * secrets), omits headers entirely, and only indicates whether a body is
511
+ * present without revealing its contents.
512
+ */
513
+ private sanitizeArgs(toolCall: ToolCall): string {
514
+ const { method, url, body } = toolCall.args;
515
+ const parts: string[] = [];
516
+
517
+ if (method) parts.push(`method=${method}`);
518
+
519
+ if (url) {
520
+ // Only include domain and path, not full URL (might contain sensitive query params)
521
+ try {
522
+ const parsed = new URL(url as string);
523
+ parts.push(`domain=${parsed.hostname}`);
524
+ parts.push(`path=${parsed.pathname}`);
525
+ } catch {
526
+ parts.push(`url=[invalid]`);
527
+ }
528
+ }
529
+
530
+ if (body) parts.push(`body=[present]`);
531
+
532
+ return parts.join(', ') || 'no args';
533
+ }
534
+
535
+ /**
536
+ * Scan all pending approvals and mark any that have passed their TTL as
537
+ * 'expired'. Returns the number of approvals that were expired.
538
+ */
539
+ cleanup(): number {
540
+ let cleaned = 0;
541
+ const now = new Date();
542
+ const pending = this.store.findPending();
543
+
544
+ for (const approval of pending) {
545
+ if (now > new Date(approval.expires_at)) {
546
+ approval.status = 'expired';
547
+ cleaned++;
548
+ }
549
+ }
550
+
551
+ return cleaned;
552
+ }
553
+
554
+ /**
555
+ * Wait for all pending store writes to complete.
556
+ */
557
+ async flush(): Promise<void> {
558
+ if (this.store.flush) await this.store.flush();
559
+ }
560
+
561
+ /**
562
+ * Clear all approval state. Intended for use in tests only.
563
+ */
564
+ clear(): void {
565
+ this.store.clear();
566
+ this.inFlightApprovals.clear();
567
+ this.usedTokens.clear();
568
+ }
569
+ }
@@ -0,0 +1,133 @@
1
+ import * as http from 'http';
2
+ import * as https from 'https';
3
+ import { URL } from 'url';
4
+ import { PendingApproval } from './manager';
5
+
6
+ export interface ApprovalWebhookPayload {
7
+ event: 'approval_requested' | 'approval_approved' | 'approval_denied' | 'approval_expired';
8
+ approval: {
9
+ approval_id: string;
10
+ tool_call_id: string;
11
+ task_id: string;
12
+ workspace_id: string;
13
+ actor_id: string;
14
+ tool_name: string;
15
+ tool_capability: string;
16
+ args_summary: string;
17
+ scope: string;
18
+ reason: string;
19
+ status: string;
20
+ created_at: string;
21
+ expires_at: string;
22
+ resolved_by?: string;
23
+ denial_reason?: string;
24
+ };
25
+ timestamp: string;
26
+ }
27
+
28
+ /**
29
+ * Fires webhook notifications for approval lifecycle events.
30
+ * Non-blocking: errors are logged but never thrown to the caller.
31
+ */
32
+ export class ApprovalWebhook {
33
+ private webhookUrl: string;
34
+ private headers: Record<string, string>;
35
+
36
+ constructor(webhookUrl: string, headers?: Record<string, string>) {
37
+ this.webhookUrl = webhookUrl;
38
+ this.headers = headers || {};
39
+ }
40
+
41
+ /** Send a webhook notification (fire-and-forget with retry, non-blocking) */
42
+ notify(event: ApprovalWebhookPayload['event'], approval: PendingApproval): void {
43
+ const payload: ApprovalWebhookPayload = {
44
+ event,
45
+ approval: {
46
+ approval_id: approval.approval_id,
47
+ tool_call_id: approval.tool_call_id,
48
+ task_id: approval.task_id,
49
+ workspace_id: approval.workspace_id,
50
+ actor_id: approval.actor_id,
51
+ tool_name: approval.tool_name,
52
+ tool_capability: approval.tool_capability,
53
+ args_summary: approval.args_summary,
54
+ scope: approval.scope,
55
+ reason: approval.reason,
56
+ status: approval.status,
57
+ created_at: approval.created_at,
58
+ expires_at: approval.expires_at,
59
+ resolved_by: approval.resolved_by,
60
+ denial_reason: approval.denial_reason,
61
+ },
62
+ timestamp: new Date().toISOString(),
63
+ };
64
+
65
+ // Fire and forget with retry - don't block the caller
66
+ this.sendWithRetry(payload, event).catch((err) => {
67
+ console.error(`[ApprovalWebhook] All retry attempts failed for ${event} notification (approval=${approval.approval_id}): ${err.message}`);
68
+ });
69
+ }
70
+
71
+ private async sendWithRetry(payload: ApprovalWebhookPayload, event: string, maxAttempts = 3): Promise<void> {
72
+ const backoffMs = [1000, 2000, 4000];
73
+ let lastError: Error | undefined;
74
+
75
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
76
+ try {
77
+ await this.send(payload);
78
+ return; // success
79
+ } catch (err) {
80
+ lastError = err instanceof Error ? err : new Error(String(err));
81
+ if (attempt < maxAttempts - 1) {
82
+ console.warn(`[ApprovalWebhook] Retry ${attempt + 1}/${maxAttempts} for ${event} notification failed: ${lastError.message}. Retrying in ${backoffMs[attempt]}ms...`);
83
+ await new Promise(resolve => setTimeout(resolve, backoffMs[attempt]));
84
+ }
85
+ }
86
+ }
87
+
88
+ throw lastError;
89
+ }
90
+
91
+ private send(payload: ApprovalWebhookPayload): Promise<void> {
92
+ return new Promise((resolve, reject) => {
93
+ const url = new URL(this.webhookUrl);
94
+ const isHttps = url.protocol === 'https:';
95
+ const transport = isHttps ? https : http;
96
+ const bodyStr = JSON.stringify(payload);
97
+
98
+ const headers: Record<string, string> = {
99
+ 'Content-Type': 'application/json',
100
+ 'Content-Length': Buffer.byteLength(bodyStr).toString(),
101
+ 'User-Agent': 'Palaryn-Approval-Webhook/0.1',
102
+ ...this.headers,
103
+ };
104
+
105
+ const options = {
106
+ hostname: url.hostname,
107
+ port: url.port || (isHttps ? 443 : 80),
108
+ path: url.pathname + url.search,
109
+ method: 'POST',
110
+ headers,
111
+ timeout: 5000,
112
+ };
113
+
114
+ const req = transport.request(options, (res) => {
115
+ res.resume(); // drain
116
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
117
+ resolve();
118
+ } else {
119
+ reject(new Error(`Webhook returned HTTP ${res.statusCode}`));
120
+ }
121
+ });
122
+
123
+ req.on('error', reject);
124
+ req.on('timeout', () => {
125
+ req.destroy();
126
+ reject(new Error('Webhook request timeout'));
127
+ });
128
+
129
+ req.write(bodyStr);
130
+ req.end();
131
+ });
132
+ }
133
+ }