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,985 @@
1
+ import express from 'express';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import cors from 'cors';
5
+ import helmet from 'helmet';
6
+ import cookieParser from 'cookie-parser';
7
+ import { Gateway } from './gateway';
8
+ import { streamProxy } from './stream-proxy';
9
+ import { NoopExecutor } from '../executor/noop-executor';
10
+ import { createForwardProxy, ForwardProxyServer } from '../proxy';
11
+ import { GatewayConfig } from '../types/config';
12
+ import { createAuthMiddleware, createRBACMiddleware } from '../middleware/auth';
13
+ import { createSessionMiddleware, SessionMiddlewareDeps } from '../middleware/session';
14
+ import { normalizeToolCall, validateToolCall } from '../middleware/validate';
15
+ import { GatewayMetrics } from '../metrics';
16
+ import { GatewayTracer } from '../tracing';
17
+ import { createAdminRouter } from '../admin';
18
+ import { sendError, ErrorCode } from './errors';
19
+ import { createAuthRouter, AuthRouteDeps } from '../auth/routes';
20
+ import { createSaaSRouter, SaaSRouteDeps } from '../saas/routes';
21
+ import { createMCPHttpHandler } from '../mcp/http-transport';
22
+ import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
23
+ import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
24
+ import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
25
+ import { OAuthClientsStore, AuthCodeStore, OAuthTokenStore } from '../mcp/oauth-stores';
26
+ import { PostgresOAuthClientsStore, PostgresOAuthTokenStore } from '../mcp/oauth-postgres-stores';
27
+ import { PalarynOAuthProvider } from '../mcp/oauth-provider';
28
+ import { HybridTokenVerifier } from '../mcp/auth-verifier';
29
+ import { SESSION_COOKIE_NAME } from '../auth/session';
30
+ import { ToolCall } from '../types/tool-call';
31
+ import {
32
+ UserStore, OAuthAccountStore, WorkspaceStore,
33
+ WorkspaceMemberStore, SessionStore, UserApiKeyStore,
34
+ SubscriptionStore, PolicyStore, RateLimitConfigStore, BudgetConfigStore,
35
+ } from '../storage/interfaces';
36
+ import {
37
+ InMemoryUserStore, InMemoryOAuthAccountStore, InMemoryWorkspaceStore,
38
+ InMemoryWorkspaceMemberStore, InMemorySessionStore, InMemoryUserApiKeyStore,
39
+ InMemorySubscriptionStore,
40
+ } from '../storage/memory';
41
+ import { StripeClient, WebhookHandler, createWebhookRouter, createBillingRouter, PlanEnforcer } from '../billing';
42
+ import { createPlanEnforcerMiddleware } from '../billing/plan-enforcer';
43
+ import { FilePersistedStores } from '../storage/file-persistence';
44
+ import { hashPassword } from '../auth/password';
45
+ import { log as devLog } from './logger';
46
+
47
+ const MAX_TRACKED_IPS = 10000;
48
+
49
+ /**
50
+ * Simple IP-based rate limiter for unauthenticated endpoints.
51
+ * Uses a sliding window with per-IP tracking.
52
+ */
53
+ function createIPRateLimiter(maxPerWindow: number, windowMs: number) {
54
+ const hits = new Map<string, number[]>();
55
+
56
+ // Periodic cleanup to prevent memory leaks
57
+ setInterval(() => {
58
+ const now = Date.now();
59
+ for (const [ip, timestamps] of hits) {
60
+ const valid = timestamps.filter(t => now - t < windowMs);
61
+ if (valid.length === 0) {
62
+ hits.delete(ip);
63
+ } else {
64
+ hits.set(ip, valid);
65
+ }
66
+ }
67
+ }, windowMs).unref();
68
+
69
+ return (req: express.Request, res: express.Response, next: express.NextFunction): void => {
70
+ const ip = req.ip || req.socket.remoteAddress || 'unknown';
71
+ const now = Date.now();
72
+ const timestamps = hits.get(ip) || [];
73
+ const windowStart = now - windowMs;
74
+ const valid = timestamps.filter(t => t > windowStart);
75
+
76
+ if (valid.length >= maxPerWindow) {
77
+ sendError(res, 429, ErrorCode.RATE_LIMIT_EXCEEDED, 'Too many requests', {
78
+ details: { retry_after_ms: windowMs - (now - valid[0]) },
79
+ hint: 'Retry after the reset time or increase rate_limit config',
80
+ });
81
+ return;
82
+ }
83
+
84
+ // Reject new IPs when the map is at capacity to prevent unbounded growth
85
+ if (!hits.has(ip) && hits.size >= MAX_TRACKED_IPS) {
86
+ sendError(res, 429, ErrorCode.RATE_LIMIT_EXCEEDED, 'Too many requests', {
87
+ details: { retry_after_ms: windowMs },
88
+ hint: 'Retry after the reset time or increase rate_limit config',
89
+ });
90
+ return;
91
+ }
92
+
93
+ valid.push(now);
94
+ hits.set(ip, valid);
95
+ next();
96
+ };
97
+ }
98
+
99
+ export interface HealthCheck {
100
+ name: string;
101
+ check: () => Promise<{ status: 'ok' | 'degraded' | 'unhealthy'; message?: string }>;
102
+ }
103
+
104
+ export interface HealthCheckResult {
105
+ status: 'ok' | 'degraded' | 'unhealthy';
106
+ message?: string;
107
+ }
108
+
109
+ export interface SaaSStores {
110
+ userStore: UserStore;
111
+ oauthAccountStore: OAuthAccountStore;
112
+ workspaceStore: WorkspaceStore;
113
+ workspaceMemberStore: WorkspaceMemberStore;
114
+ sessionStore: SessionStore;
115
+ userApiKeyStore: UserApiKeyStore;
116
+ subscriptionStore: SubscriptionStore;
117
+ mcpOAuthClientsStore?: PostgresOAuthClientsStore;
118
+ mcpOAuthTokenStore?: PostgresOAuthTokenStore;
119
+ policyStore?: PolicyStore;
120
+ rateLimitConfigStore?: RateLimitConfigStore;
121
+ budgetConfigStore?: BudgetConfigStore;
122
+ }
123
+
124
+ export interface CreateAppResult {
125
+ app: express.Application;
126
+ gateway: Gateway;
127
+ metrics: GatewayMetrics;
128
+ tracer?: GatewayTracer;
129
+ healthChecks: HealthCheck[];
130
+ saasStores: SaaSStores;
131
+ proxyServer?: ForwardProxyServer;
132
+ }
133
+
134
+ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<SaaSStores>): CreateAppResult {
135
+ const app = express();
136
+ const metrics = new GatewayMetrics();
137
+ const startTime = Date.now();
138
+ const healthChecks: HealthCheck[] = [];
139
+
140
+ // Initialize SaaS stores (use provided, file-persisted, or default to in-memory)
141
+ let saasStores: SaaSStores;
142
+ let filePersistence: FilePersistedStores | undefined;
143
+
144
+ if (externalSaaSStores?.userStore) {
145
+ // External stores provided (e.g., PostgreSQL)
146
+ saasStores = {
147
+ userStore: externalSaaSStores.userStore,
148
+ oauthAccountStore: externalSaaSStores.oauthAccountStore || new InMemoryOAuthAccountStore(),
149
+ workspaceStore: externalSaaSStores.workspaceStore || new InMemoryWorkspaceStore(),
150
+ workspaceMemberStore: externalSaaSStores.workspaceMemberStore || new InMemoryWorkspaceMemberStore(),
151
+ sessionStore: externalSaaSStores.sessionStore || new InMemorySessionStore(),
152
+ userApiKeyStore: externalSaaSStores.userApiKeyStore || new InMemoryUserApiKeyStore(),
153
+ subscriptionStore: externalSaaSStores.subscriptionStore || new InMemorySubscriptionStore(),
154
+ policyStore: externalSaaSStores.policyStore,
155
+ rateLimitConfigStore: externalSaaSStores.rateLimitConfigStore,
156
+ budgetConfigStore: externalSaaSStores.budgetConfigStore,
157
+ };
158
+ } else {
159
+ // File-persisted in-memory stores (survives server restarts)
160
+ const dataDir = process.env.PALARYN_DATA_DIR || './data';
161
+ filePersistence = new FilePersistedStores(dataDir);
162
+ saasStores = {
163
+ userStore: filePersistence.userStore,
164
+ oauthAccountStore: filePersistence.oauthAccountStore,
165
+ workspaceStore: filePersistence.workspaceStore,
166
+ workspaceMemberStore: filePersistence.workspaceMemberStore,
167
+ sessionStore: filePersistence.sessionStore,
168
+ userApiKeyStore: filePersistence.userApiKeyStore,
169
+ subscriptionStore: new InMemorySubscriptionStore(),
170
+ policyStore: filePersistence.policyStore,
171
+ };
172
+ }
173
+
174
+ // Default OAuth config for session/auth routes (email+password always works)
175
+ const oauthConfig = config.oauth || {
176
+ enabled: false,
177
+ session_secret: 'palaryn-dev-session-secret',
178
+ session_ttl_seconds: 604800,
179
+ };
180
+
181
+ // Seed test account in non-production (async due to hashPassword)
182
+ if (process.env.NODE_ENV !== 'production') {
183
+ (async () => {
184
+ const existing = saasStores.userStore.getByEmail('test@palaryn.dev');
185
+ if (!existing) {
186
+ const now = new Date().toISOString();
187
+ saasStores.userStore.create({
188
+ id: 'usr_test_000',
189
+ email: 'test@palaryn.dev',
190
+ display_name: 'Test User',
191
+ password_hash: await hashPassword('test1234'),
192
+ status: 'active',
193
+ onboarding_completed: false,
194
+ created_at: now,
195
+ updated_at: now,
196
+ });
197
+ }
198
+ })();
199
+ }
200
+
201
+ // Seed admin account from env vars (for production with in-memory storage)
202
+ if (process.env.SEED_ADMIN_EMAIL && process.env.SEED_ADMIN_PASSWORD) {
203
+ (async () => {
204
+ const existing = saasStores.userStore.getByEmail(process.env.SEED_ADMIN_EMAIL!);
205
+ if (!existing) {
206
+ const now = new Date().toISOString();
207
+ const userId = 'usr_admin_001';
208
+ const workspaceId = process.env.SEED_ADMIN_WORKSPACE || 'ws_default';
209
+ saasStores.userStore.create({
210
+ id: userId,
211
+ email: process.env.SEED_ADMIN_EMAIL!,
212
+ display_name: process.env.SEED_ADMIN_NAME || 'Admin',
213
+ avatar_url: process.env.SEED_ADMIN_AVATAR_URL,
214
+ password_hash: await hashPassword(process.env.SEED_ADMIN_PASSWORD!),
215
+ status: 'active',
216
+ onboarding_completed: true,
217
+ created_at: now,
218
+ updated_at: now,
219
+ });
220
+ // Create workspace if store supports it
221
+ if (saasStores.workspaceStore && typeof saasStores.workspaceStore.create === 'function') {
222
+ const existingWs = saasStores.workspaceStore.getById(workspaceId);
223
+ if (!existingWs) {
224
+ saasStores.workspaceStore.create({
225
+ id: workspaceId,
226
+ name: process.env.SEED_ADMIN_WORKSPACE_NAME || 'Default',
227
+ slug: 'default',
228
+ owner_user_id: userId,
229
+ plan: 'pro',
230
+ settings: {},
231
+ created_at: now,
232
+ updated_at: now,
233
+ });
234
+ }
235
+ }
236
+ // Add user as workspace owner
237
+ if (saasStores.workspaceMemberStore && typeof saasStores.workspaceMemberStore.create === 'function') {
238
+ saasStores.workspaceMemberStore.create({
239
+ id: `wm_${userId}_${workspaceId}`,
240
+ workspace_id: workspaceId,
241
+ user_id: userId,
242
+ role: 'owner',
243
+ joined_at: now,
244
+ });
245
+ }
246
+ }
247
+ })();
248
+ }
249
+
250
+ // Initialize OpenTelemetry tracer if tracing is configured
251
+ let tracer: GatewayTracer | undefined;
252
+ if (config.tracing?.enabled) {
253
+ tracer = new GatewayTracer(config.tracing);
254
+ tracer.setup();
255
+ }
256
+
257
+ const gateway = new Gateway(config, metrics, tracer);
258
+
259
+ // Inject workspace-level policy store so per-workspace policies are evaluated
260
+ if (saasStores.policyStore) {
261
+ gateway.setStores({ policyStore: saasStores.policyStore });
262
+ }
263
+
264
+ // Register noop executors for non-HTTP tools (e.g. pre-flight validation from Android)
265
+ gateway.registerExecutor('web_search', new NoopExecutor({ body: { validated: true, tool: 'web_search' } }));
266
+ gateway.registerExecutor('chat.completion', new NoopExecutor({ body: { validated: true, tool: 'chat.completion' } }));
267
+
268
+ // Rate limiter for unauthenticated endpoints (60 requests per minute per IP)
269
+ const publicLimitConfig = config.public_rate_limit || { max_per_window: 60, window_ms: 60000 };
270
+ const publicRateLimiter = createIPRateLimiter(publicLimitConfig.max_per_window, publicLimitConfig.window_ms);
271
+
272
+ // Middleware
273
+ const isProduction = process.env.NODE_ENV === 'production';
274
+ app.use(helmet({
275
+ contentSecurityPolicy: {
276
+ directives: {
277
+ defaultSrc: ["'self'"],
278
+ scriptSrc: ["'self'", "'unsafe-inline'"],
279
+ styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
280
+ fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
281
+ imgSrc: ["'self'", "data:", "https:"],
282
+ connectSrc: ["'self'"],
283
+ frameAncestors: ["'none'"],
284
+ upgradeInsecureRequests: isProduction ? [] : null,
285
+ },
286
+ },
287
+ hsts: isProduction ? { maxAge: 31536000, includeSubDomains: true } : false,
288
+ xFrameOptions: { action: 'deny' },
289
+ }));
290
+ app.use(cors(
291
+ config.cors_origins
292
+ ? { origin: config.cors_origins, credentials: true }
293
+ : isProduction
294
+ ? { origin: false } // Block all cross-origin in production when not configured
295
+ : { origin: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'], credentials: true }
296
+ ));
297
+ // Stripe webhook route (must be before express.json() for raw body signature verification)
298
+ if (config.stripe?.secret_key && config.stripe?.webhook_secret) {
299
+ const stripeClient = new StripeClient(config.stripe);
300
+ const webhookHandler = new WebhookHandler(saasStores.subscriptionStore, saasStores.workspaceStore, config.stripe);
301
+ app.use(createWebhookRouter(stripeClient, webhookHandler));
302
+ }
303
+
304
+ app.use(express.json({ limit: '10mb' }));
305
+ app.use(cookieParser());
306
+
307
+ // Server-side request timeout (30 seconds default, prevents hung connections)
308
+ app.use((req, res, next) => {
309
+ const timeoutMs = 30000;
310
+ res.setTimeout(timeoutMs, () => {
311
+ if (!res.headersSent) {
312
+ sendError(res, 408, ErrorCode.REQUEST_TIMEOUT, 'Request timeout', {
313
+ hint: 'The server did not complete the request within 30 seconds',
314
+ });
315
+ }
316
+ });
317
+ next();
318
+ });
319
+
320
+ // Dev request/response logging middleware
321
+ app.use((req, res, next) => {
322
+ const reqStart = Date.now();
323
+ // Skip noisy health/metrics polling
324
+ if (req.path === '/health' || req.path === '/ready' || req.path === '/metrics') {
325
+ return next();
326
+ }
327
+ devLog.request(req.method, req.path, req.ip || req.socket.remoteAddress || 'unknown', req.body);
328
+
329
+ const origJson = res.json.bind(res);
330
+ res.json = (body: unknown) => {
331
+ devLog.response(res.statusCode, Date.now() - reqStart, body as Record<string, unknown>);
332
+ return origJson(body);
333
+ };
334
+
335
+ const origSend = res.send.bind(res);
336
+ res.send = (body?: any) => {
337
+ // Log HTML responses (from admin routes) without the full body
338
+ if (typeof body === 'string' && body.startsWith('<!DOCTYPE html>')) {
339
+ devLog.response(res.statusCode, Date.now() - reqStart, { html: true, length: body.length });
340
+ }
341
+ return origSend(body);
342
+ };
343
+ next();
344
+ });
345
+
346
+ // Health check (no auth) - minimal response to avoid information disclosure
347
+ app.get('/health', publicRateLimiter, async (req, res) => {
348
+ // Internal dependency checks
349
+ const checks: { name: string; status: string }[] = [];
350
+ let overall: 'ok' | 'degraded' | 'unhealthy' = 'ok';
351
+
352
+ for (const hc of healthChecks) {
353
+ try {
354
+ const result = await hc.check();
355
+ checks.push({ name: hc.name, status: result.status });
356
+ if (result.status === 'unhealthy') overall = 'unhealthy';
357
+ else if (result.status === 'degraded' && overall !== 'unhealthy') overall = 'degraded';
358
+ } catch {
359
+ checks.push({ name: hc.name, status: 'unhealthy' });
360
+ overall = 'unhealthy';
361
+ }
362
+ }
363
+
364
+ // Return 503 when any critical dependency is unhealthy
365
+ const httpStatus = overall === 'unhealthy' ? 503 : 200;
366
+
367
+ // Unauthenticated: minimal response (no version, uptime, or internal component names)
368
+ const authCtx = (req as any).auth;
369
+ if (authCtx && authCtx.auth_method !== 'none') {
370
+ const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
371
+ res.status(httpStatus).json({
372
+ status: overall,
373
+ version: '0.1.0',
374
+ timestamp: new Date().toISOString(),
375
+ uptime_seconds: uptimeSeconds,
376
+ checks,
377
+ });
378
+ } else {
379
+ res.status(httpStatus).json({ status: overall });
380
+ }
381
+ });
382
+
383
+ // Readiness probe (no auth) - for K8s readiness checks
384
+ // Unlike /health which always returns 200, /ready returns 503 when unhealthy
385
+ app.get('/ready', publicRateLimiter, async (req, res) => {
386
+ const checks: { name: string; status: string; message?: string }[] = [];
387
+ let ready = true;
388
+
389
+ // Check if gateway is shutting down
390
+ if (gateway.isShuttingDown) {
391
+ checks.push({ name: 'gateway', status: 'unhealthy', message: 'shutting down' });
392
+ ready = false;
393
+ } else {
394
+ checks.push({ name: 'gateway', status: 'ok' });
395
+ }
396
+
397
+ // Check registered external dependencies (Redis, Postgres, etc.)
398
+ for (const hc of healthChecks) {
399
+ try {
400
+ const result = await hc.check();
401
+ checks.push({ name: hc.name, status: result.status, message: result.message });
402
+ if (result.status === 'unhealthy') {
403
+ ready = false;
404
+ }
405
+ } catch (err) {
406
+ checks.push({ name: hc.name, status: 'unhealthy', message: err instanceof Error ? err.message : 'check failed' });
407
+ ready = false;
408
+ }
409
+ }
410
+
411
+ // Always check core components
412
+ checks.push({ name: 'policy_engine', status: 'ok' });
413
+
414
+ if (ready) {
415
+ res.json({ ready: true, checks });
416
+ } else {
417
+ res.status(503).json({ ready: false, checks });
418
+ }
419
+ });
420
+
421
+ // Prometheus metrics endpoint (auth required in production, open in dev for scraping)
422
+ app.get('/metrics', publicRateLimiter, async (req, res) => {
423
+ // In production, require auth to prevent information disclosure
424
+ if (isProduction && config.auth.enabled) {
425
+ const apiKey = req.headers['x-api-key'] as string | undefined;
426
+ const bearer = (req.headers['authorization'] as string | undefined)?.startsWith('Bearer ') ? (req.headers['authorization'] as string).slice(7) : undefined;
427
+ if (!apiKey && !bearer) {
428
+ sendError(res, 401, ErrorCode.AUTH_REQUIRED, 'Authentication required for /metrics in production');
429
+ return;
430
+ }
431
+ }
432
+ try {
433
+ const metricsOutput = await metrics.getMetrics();
434
+ res.set('Content-Type', metrics.getContentType());
435
+ res.end(metricsOutput);
436
+ } catch (err) {
437
+ res.status(500).end();
438
+ }
439
+ });
440
+
441
+ // Serve install script: curl -fsSL https://app.palaryn.com/install.sh | sh
442
+ app.get('/install.sh', publicRateLimiter, (req, res) => {
443
+ // __dirname is dist/src/server — go up 3 levels to repo root
444
+ const scriptPath = path.resolve(__dirname, '..', '..', '..', 'install.sh');
445
+ if (fs.existsSync(scriptPath)) {
446
+ res.set('Content-Type', 'text/plain; charset=utf-8');
447
+ res.send(fs.readFileSync(scriptPath, 'utf-8'));
448
+ } else {
449
+ res.status(404).send('# install.sh not found\n');
450
+ }
451
+ });
452
+
453
+ // Rate limiter for auth endpoints (10 requests per minute per IP — tighter than public)
454
+ const authRateLimiter = createIPRateLimiter(10, 60000);
455
+
456
+ // Stricter rate limiter for registration (3 per 15 minutes per IP)
457
+ const registerRateLimiter = createIPRateLimiter(3, 15 * 60 * 1000);
458
+
459
+ // Session middleware + auth routes (always mounted for email/password auth)
460
+ const sessionMiddleware = createSessionMiddleware({
461
+ oauthConfig: oauthConfig,
462
+ authConfig: config.auth,
463
+ sessionStore: saasStores.sessionStore,
464
+ userStore: saasStores.userStore,
465
+ workspaceMemberStore: saasStores.workspaceMemberStore,
466
+ });
467
+ app.use(sessionMiddleware);
468
+
469
+ const authRouter = createAuthRouter({
470
+ config: oauthConfig,
471
+ userStore: saasStores.userStore,
472
+ oauthAccountStore: saasStores.oauthAccountStore,
473
+ sessionStore: saasStores.sessionStore,
474
+ workspaceStore: saasStores.workspaceStore,
475
+ workspaceMemberStore: saasStores.workspaceMemberStore,
476
+ userApiKeyStore: saasStores.userApiKeyStore,
477
+ });
478
+ app.use('/auth/register', registerRateLimiter);
479
+ // Rate limit sensitive auth endpoints (login, OAuth) but not /auth/me (called on every page load)
480
+ app.use('/auth/login', authRateLimiter);
481
+ app.use('/auth/google', authRateLimiter);
482
+ app.use('/auth/github', authRateLimiter);
483
+ app.use('/auth', authRouter);
484
+
485
+ // Auth middleware for API routes (bridges config keys + SaaS-generated keys)
486
+ const authMiddleware = createAuthMiddleware(config.auth, saasStores.userApiKeyStore);
487
+
488
+ // RBAC middleware factories
489
+ const rbacToolExecute = createRBACMiddleware(config.auth, 'tool:execute');
490
+ const rbacApprovalManage = createRBACMiddleware(config.auth, 'approval:manage');
491
+ const rbacTraceRead = createRBACMiddleware(config.auth, 'trace:read');
492
+ const rbacPolicyRead = createRBACMiddleware(config.auth, 'policy:read');
493
+ const rbacPolicyWrite = createRBACMiddleware(config.auth, 'policy:write');
494
+
495
+ // Admin routes (require auth + admin:full permission)
496
+ // Auth + RBAC middleware mounted directly with the router to prevent route decoupling
497
+ const adminRouter = createAdminRouter(gateway, config);
498
+ const rbacAdmin = createRBACMiddleware(config.auth, 'admin:full');
499
+ app.use('/admin', authMiddleware, rbacAdmin, adminRouter);
500
+
501
+ // Plan enforcer middleware — blocks calls when workspace exceeds plan limits
502
+ const planEnforcerMiddleware = createPlanEnforcerMiddleware({
503
+ workspaceStore: saasStores.workspaceStore,
504
+ getMonthlyCallCount: (workspaceId: string) => {
505
+ const now = new Date();
506
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
507
+ const events = gateway.getAuditLogger().getAllEvents();
508
+ return events.filter(
509
+ e => e.workspace_id === workspaceId
510
+ && e.event_type === 'TOOL_CALL_RECEIVED'
511
+ && e.timestamp >= monthStart
512
+ ).length;
513
+ },
514
+ });
515
+
516
+ // POST /v1/tool/execute - Execute a tool call through the gateway
517
+ app.post('/v1/tool/execute', authMiddleware, rbacToolExecute, planEnforcerMiddleware, normalizeToolCall, validateToolCall, async (req, res) => {
518
+ try {
519
+ // Capability-specific RBAC check
520
+ const capability = req.body.tool?.capability;
521
+ if (capability && config.auth.rbac?.enabled) {
522
+ const authCtx = (req as any).auth;
523
+ if (authCtx && authCtx.auth_method !== 'none') {
524
+ const { hasPermission } = require('../middleware/auth');
525
+ const capPerm = `tool:execute:${capability}`;
526
+ // Only enforce capability check if user does NOT have the broad tool:execute permission
527
+ // and does NOT have admin:full
528
+ if (!hasPermission(authCtx.permissions, capPerm)) {
529
+ sendError(res, 403, ErrorCode.AUTH_INSUFFICIENT_PERMS, `Insufficient permissions. Required: ${capPerm}`, {
530
+ details: { required_permission: capPerm, roles: authCtx.roles },
531
+ hint: 'Assign a role with the required permission or use an admin key',
532
+ });
533
+ return;
534
+ }
535
+ }
536
+ }
537
+
538
+ const toolCall = {
539
+ ...req.body,
540
+ workspace_id: (req as any).auth?.workspace_id || req.body.workspace_id || (req as any).workspace_id,
541
+ };
542
+ // Merge API key tags into context.labels
543
+ const apiKeyTags: string[] | undefined = (req as any).auth?.api_key_tags;
544
+ if (apiKeyTags && apiKeyTags.length > 0) {
545
+ if (!toolCall.context) toolCall.context = {};
546
+ const existing: string[] = toolCall.context.labels || [];
547
+ toolCall.context.labels = [...new Set([...existing, ...apiKeyTags])];
548
+ }
549
+ const requestingApiKeyId = (req as any).auth?.api_key_id;
550
+ const result = await gateway.execute(toolCall, requestingApiKeyId);
551
+ // Use 429 for rate limit blocks, 403 for policy blocks
552
+ let httpStatus: number;
553
+ if (result.status === 'ok') {
554
+ httpStatus = 200;
555
+ } else if (result.status === 'blocked') {
556
+ httpStatus = result.policy?.rule_id === 'rate_limit' ? 429 : 403;
557
+ } else if (result.status === 'needs_approval') {
558
+ httpStatus = 202;
559
+ } else {
560
+ httpStatus = 500;
561
+ }
562
+ // S3: Never leak raw error messages to clients (may contain internal paths, IPs, secrets)
563
+ if (result.error) {
564
+ console.error('[tool-execute] Gateway error:', result.error);
565
+ result.error = 'Tool execution failed';
566
+ }
567
+ res.status(httpStatus).json(result);
568
+ } catch (err) {
569
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
570
+ console.error('[tool-execute] Execution error:', errorMessage);
571
+ sendError(res, 500, ErrorCode.TOOL_EXECUTION_ERROR, 'Tool execution failed');
572
+ }
573
+ });
574
+
575
+ // POST /v1/proxy/stream - SSE streaming proxy (dormant, feature-flagged)
576
+ // Kept for future use when gateway controls the upstream directly.
577
+ // Enable with PROXY_STREAM_ENABLED=true environment variable.
578
+ if (process.env.PROXY_STREAM_ENABLED === 'true') {
579
+ app.post('/v1/proxy/stream', authMiddleware, rbacToolExecute, async (req, res) => {
580
+ try {
581
+ const { tool_call_id, task_id, actor, source, tool, target, context, constraints } = req.body;
582
+
583
+ if (!tool_call_id || !task_id || !actor || !tool || !target?.url) {
584
+ sendError(res, 400, ErrorCode.VALIDATION_FAILED, 'Missing required fields: tool_call_id, task_id, actor, tool, target.url', {
585
+ hint: 'Check the stream proxy request schema',
586
+ });
587
+ return;
588
+ }
589
+
590
+ const toolCall: ToolCall = {
591
+ tool_call_id,
592
+ task_id,
593
+ workspace_id: (req as any).auth?.workspace_id || req.body.workspace_id || 'unknown',
594
+ actor: { type: actor.type || 'agent', id: actor.id, display: actor.display },
595
+ source: { platform: source?.platform || 'unknown', session_id: source?.session_id },
596
+ tool: { name: tool.name, version: tool.version, capability: tool.capability || 'write' },
597
+ args: {
598
+ method: target.method || 'POST',
599
+ url: target.url,
600
+ headers: target.headers,
601
+ body: target.body,
602
+ },
603
+ constraints,
604
+ context,
605
+ };
606
+
607
+ const pre = await gateway.preExecute(toolCall);
608
+ if (!pre.allowed) {
609
+ const httpStatus = pre.result!.status === 'blocked' ? 403 : pre.result!.status === 'needs_approval' ? 202 : 500;
610
+ res.status(httpStatus).json(pre.result);
611
+ return;
612
+ }
613
+
614
+ res.setTimeout(0);
615
+
616
+ const proxyResult = await streamProxy({
617
+ url: target.url,
618
+ method: target.method || 'POST',
619
+ headers: target.headers || {},
620
+ body: target.body,
621
+ timeoutMs: constraints?.timeout_ms || 120000,
622
+ }, res);
623
+
624
+ try {
625
+ await gateway.postExecute(toolCall, {
626
+ http_status: proxyResult.httpStatus,
627
+ body: proxyResult.accumulatedBody,
628
+ headers: proxyResult.headers,
629
+ }, pre);
630
+ } catch (postErr) {
631
+ console.error('[post-execute] Failed:', postErr instanceof Error ? postErr.message : postErr);
632
+ // Don't fail the response — it's already sent for streaming
633
+ }
634
+
635
+ } catch (err) {
636
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
637
+ console.error('[stream-proxy] Execution error:', errorMessage);
638
+ if (!res.headersSent) {
639
+ sendError(res, 502, ErrorCode.TOOL_EXECUTION_ERROR, 'Stream proxy failed');
640
+ } else {
641
+ try {
642
+ res.write(`event: error\ndata: ${JSON.stringify({ error: 'Stream proxy failed' })}\n\n`);
643
+ res.end();
644
+ } catch {
645
+ // Client already disconnected
646
+ }
647
+ }
648
+ }
649
+ });
650
+ }
651
+
652
+ // POST /v1/tool/approve - Approve or deny a pending action
653
+ app.post('/v1/tool/approve', authMiddleware, rbacApprovalManage, async (req, res) => {
654
+ try {
655
+ const { approval_token, approved, approver_id, reason } = req.body;
656
+ if (!approval_token) {
657
+ sendError(res, 400, ErrorCode.VALIDATION_FAILED, 'approval_token is required', {
658
+ hint: 'Include the approval_token from the needs_approval response',
659
+ });
660
+ return;
661
+ }
662
+ // Use auth context for approver identity (not user-supplied approver_id)
663
+ const authCtx = (req as any).auth;
664
+ const approverApiKeyId = authCtx?.api_key_id;
665
+ const effectiveApproverId = authCtx?.actor_id || approver_id || 'unknown';
666
+ const result = await gateway.processApproval(
667
+ approval_token,
668
+ effectiveApproverId,
669
+ approved !== false,
670
+ reason,
671
+ approverApiKeyId,
672
+ );
673
+ if (result.success) {
674
+ res.json({ status: 'ok', ...result });
675
+ } else {
676
+ sendError(res, 400, ErrorCode.VALIDATION_FAILED, result.error || 'Approval processing failed');
677
+ }
678
+ } catch (err) {
679
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
680
+ console.error('[tool-approve] Error:', errorMessage);
681
+ sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Approval processing failed');
682
+ }
683
+ });
684
+
685
+ // GET /v1/tasks/:task_id/trace - Get full trace for a task
686
+ // Workspace isolation: non-admin callers only see events for their own workspace
687
+ app.get('/v1/tasks/:task_id/trace', authMiddleware, rbacTraceRead, (req, res) => {
688
+ const taskId = Array.isArray(req.params.task_id) ? req.params.task_id[0] : req.params.task_id;
689
+ const authCtx = (req as any).auth;
690
+ let events = gateway.getTaskTrace(taskId);
691
+
692
+ // Scope to workspace unless caller has admin:full permission
693
+ if (authCtx?.workspace_id && authCtx.auth_method !== 'none') {
694
+ const { hasPermission } = require('../middleware/auth');
695
+ if (!hasPermission(authCtx.permissions, 'admin:full')) {
696
+ events = events.filter(e => !e.workspace_id || e.workspace_id === authCtx.workspace_id);
697
+ }
698
+ }
699
+ res.json({ task_id: taskId, events });
700
+ });
701
+
702
+ // GET /v1/policies/current - Get active policy configuration
703
+ app.get('/v1/policies/current', authMiddleware, rbacPolicyRead, (req, res) => {
704
+ res.json(gateway.getCurrentPolicy());
705
+ });
706
+
707
+ // POST /v1/policies/validate - Validate a policy configuration
708
+ app.post('/v1/policies/validate', authMiddleware, rbacPolicyWrite, (req, res) => {
709
+ const result = gateway.validatePolicy(req.body);
710
+ res.json(result);
711
+ });
712
+
713
+ // POST /v1/policies/reload - Hot-reload policy from disk
714
+ app.post('/v1/policies/reload', authMiddleware, rbacPolicyWrite, (req, res) => {
715
+ const result = gateway.reloadPolicy();
716
+ if (result.success) {
717
+ res.json({ status: 'ok', rule_count: result.ruleCount });
718
+ } else {
719
+ sendError(res, 500, ErrorCode.INTERNAL_ERROR, result.error || 'Policy reload failed');
720
+ }
721
+ });
722
+
723
+ // POST /v1/usage/report - Report actual usage/cost from clients
724
+ app.post('/v1/usage/report', authMiddleware, rbacToolExecute, (req, res) => {
725
+ try {
726
+ const { tool_call_id, task_id, actual_cost_usd, usage, workspace_id, actor_id } = req.body;
727
+
728
+ if (!tool_call_id || !task_id) {
729
+ sendError(res, 400, ErrorCode.VALIDATION_FAILED, 'tool_call_id and task_id are required', {
730
+ hint: 'Both tool_call_id and task_id must be provided in the request body',
731
+ });
732
+ return;
733
+ }
734
+
735
+ gateway.reportUsage({
736
+ tool_call_id,
737
+ task_id,
738
+ workspace_id: workspace_id || (req as any).workspace_id,
739
+ actor_id: actor_id || (req as any).auth?.actor_id,
740
+ actual_cost_usd,
741
+ usage,
742
+ });
743
+
744
+ res.json({ status: 'ok' });
745
+ } catch (err) {
746
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
747
+ console.error('[usage-report] Error:', errorMessage);
748
+ sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Usage reporting failed');
749
+ }
750
+ });
751
+
752
+ // GET /v1/approvals/pending - List pending approvals
753
+ // Workspace isolation: non-admin callers are forced to their own workspace
754
+ app.get('/v1/approvals/pending', authMiddleware, rbacApprovalManage, (req, res) => {
755
+ const authCtx = (req as any).auth;
756
+ let workspaceId = typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined;
757
+
758
+ // Enforce workspace scope for non-admin callers
759
+ if (authCtx?.workspace_id && authCtx.auth_method !== 'none') {
760
+ const { hasPermission } = require('../middleware/auth');
761
+ if (!hasPermission(authCtx.permissions, 'admin:full')) {
762
+ workspaceId = authCtx.workspace_id;
763
+ }
764
+ }
765
+
766
+ const approvals = gateway.getPendingApprovals(workspaceId);
767
+ res.json({ approvals });
768
+ });
769
+
770
+ // GET /v1/config/active - View active configuration (admin-only, secrets redacted)
771
+ app.get('/v1/config/active', authMiddleware, rbacAdmin, (req, res) => {
772
+ const redacted = JSON.parse(JSON.stringify(config));
773
+
774
+ // Deep redaction: recursively redact any key containing 'secret', 'password', or 'token'
775
+ function deepRedact(obj: any): void {
776
+ if (!obj || typeof obj !== 'object') return;
777
+ for (const key of Object.keys(obj)) {
778
+ const lowerKey = key.toLowerCase();
779
+ if (lowerKey.includes('secret') || lowerKey.includes('password') || lowerKey.includes('token')) {
780
+ obj[key] = '[REDACTED]';
781
+ } else if (lowerKey === 'last_used_at' || lowerKey === 'created_at') {
782
+ obj[key] = null;
783
+ } else if (typeof obj[key] === 'object' && obj[key] !== null) {
784
+ deepRedact(obj[key]);
785
+ }
786
+ }
787
+ }
788
+ deepRedact(redacted);
789
+
790
+ // Redact API key values (show only key prefix, strip metadata timestamps)
791
+ if (redacted.auth?.api_keys) {
792
+ const redactedKeys: Record<string, unknown> = {};
793
+ for (const key of Object.keys(redacted.auth.api_keys)) {
794
+ const entry = redacted.auth.api_keys[key];
795
+ if (typeof entry === 'object' && entry !== null) {
796
+ delete entry.last_used_at;
797
+ delete entry.created_at;
798
+ }
799
+ redactedKeys[key.slice(0, 8) + '...'] = entry;
800
+ }
801
+ redacted.auth.api_keys = redactedKeys;
802
+ }
803
+ res.json(redacted);
804
+ });
805
+
806
+ // Inject per-workspace config stores into gateway if provided externally
807
+ if (saasStores.rateLimitConfigStore || saasStores.budgetConfigStore) {
808
+ gateway.setStores({
809
+ rateLimitConfigStore: saasStores.rateLimitConfigStore,
810
+ budgetConfigStore: saasStores.budgetConfigStore,
811
+ });
812
+ }
813
+
814
+ // SaaS API routes (require session auth)
815
+ const saasRouter = createSaaSRouter({
816
+ config,
817
+ userStore: saasStores.userStore,
818
+ workspaceStore: saasStores.workspaceStore,
819
+ workspaceMemberStore: saasStores.workspaceMemberStore,
820
+ userApiKeyStore: saasStores.userApiKeyStore,
821
+ sessionStore: saasStores.sessionStore,
822
+ gateway,
823
+ policyStore: saasStores.policyStore,
824
+ rateLimitConfigStore: saasStores.rateLimitConfigStore,
825
+ budgetConfigStore: saasStores.budgetConfigStore,
826
+ });
827
+ app.use('/api/v1', authMiddleware, saasRouter);
828
+
829
+ // Billing API routes (require session auth, only when Stripe is configured)
830
+ if (config.stripe?.secret_key) {
831
+ const billingStripeClient = new StripeClient(config.stripe);
832
+ const billingRouter = createBillingRouter({
833
+ stripeClient: billingStripeClient,
834
+ stripeConfig: config.stripe,
835
+ subscriptionStore: saasStores.subscriptionStore,
836
+ workspaceStore: saasStores.workspaceStore,
837
+ workspaceMemberStore: saasStores.workspaceMemberStore,
838
+ gateway,
839
+ });
840
+ app.use('/api/v1', billingRouter);
841
+ }
842
+
843
+ // MCP HTTP transport with OAuth 2.0 support.
844
+ // When mcp_oauth is enabled, uses the MCP SDK's OAuth router + bearer auth.
845
+ // When disabled, falls back to existing auth middleware + RBAC.
846
+ const mcpBaseUrl = config.mcp_oauth?.base_url
847
+ || (isProduction ? 'https://app.palaryn.com' : `http://localhost:${config.port}`);
848
+ const mcpHandler = createMCPHttpHandler(gateway, {
849
+ gateway_base_url: config.mcp_oauth?.enabled ? mcpBaseUrl : undefined,
850
+ });
851
+
852
+ if (config.mcp_oauth?.enabled) {
853
+ // Create OAuth stores — prefer Postgres-backed stores when available
854
+ const oauthClientsStore = saasStores.mcpOAuthClientsStore
855
+ || filePersistence?.oauthClientsStore
856
+ || new OAuthClientsStore();
857
+ const authCodeStore = new AuthCodeStore();
858
+ const tokenStore = saasStores.mcpOAuthTokenStore
859
+ || new OAuthTokenStore(
860
+ config.mcp_oauth.access_token_ttl || 3600,
861
+ config.mcp_oauth.refresh_token_ttl || 30 * 24 * 3600,
862
+ );
863
+
864
+ // Create OAuth provider
865
+ const oauthProvider = new PalarynOAuthProvider({
866
+ clientsStore: oauthClientsStore,
867
+ authCodeStore,
868
+ tokenStore,
869
+ userStore: saasStores.userStore,
870
+ workspaceStore: saasStores.workspaceStore,
871
+ workspaceMemberStore: saasStores.workspaceMemberStore,
872
+ sessionStore: saasStores.sessionStore,
873
+ rbacConfig: config.auth.rbac,
874
+ });
875
+
876
+ // Determine base URL for OAuth metadata
877
+ const baseUrl = config.mcp_oauth.base_url
878
+ || (isProduction ? 'https://app.palaryn.com' : `http://localhost:${config.port}`);
879
+ const issuerUrl = new URL(baseUrl);
880
+ const resourceServerUrl = new URL('/mcp', baseUrl);
881
+ const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
882
+
883
+ // Mount OAuth routes at app root (/.well-known/*, /authorize, /token, /register, /revoke)
884
+ app.use(mcpAuthRouter({
885
+ provider: oauthProvider,
886
+ issuerUrl,
887
+ resourceServerUrl,
888
+ scopesSupported: ['mcp:tools'],
889
+ }));
890
+
891
+ // Consent decision endpoint (POST /authorize/decision from the consent form)
892
+ app.post('/authorize/decision', express.urlencoded({ extended: false }), async (req, res) => {
893
+ // Verify the user is logged in via session cookie
894
+ const sessionId = req.cookies?.[SESSION_COOKIE_NAME];
895
+ if (!sessionId) {
896
+ res.status(401).send('Not authenticated');
897
+ return;
898
+ }
899
+ const session = saasStores.sessionStore.getById(sessionId);
900
+ if (!session) {
901
+ res.status(401).send('Session expired');
902
+ return;
903
+ }
904
+
905
+ const result = await oauthProvider.handleConsentDecision(req.body, session.user_id);
906
+ if ('error' in result) {
907
+ res.status(result.status).send(result.error);
908
+ return;
909
+ }
910
+ res.redirect(result.redirectUrl);
911
+ });
912
+
913
+ // Create hybrid verifier (OAuth + API keys)
914
+ const hybridVerifier = new HybridTokenVerifier({
915
+ oauthProvider,
916
+ authConfig: config.auth,
917
+ userApiKeyStore: saasStores.userApiKeyStore,
918
+ });
919
+
920
+ // MCP with SDK's bearer auth (returns proper WWW-Authenticate on 401)
921
+ app.use('/mcp', requireBearerAuth({
922
+ verifier: hybridVerifier,
923
+ resourceMetadataUrl,
924
+ }), mcpHandler.router);
925
+ } else {
926
+ // Legacy: use existing auth middleware + RBAC
927
+ app.use('/mcp', authMiddleware, rbacToolExecute, mcpHandler.router);
928
+ }
929
+
930
+ // Frontend SPA serving (must be last, serves index.html for all unmatched routes)
931
+ if (config.frontend?.enabled) {
932
+ const frontendPath = path.resolve(config.frontend.build_path);
933
+ app.use(express.static(frontendPath));
934
+ app.get('/{*splat}', (req, res) => {
935
+ // Don't serve SPA for API routes or known backend routes
936
+ if (req.path.startsWith('/v1/') || req.path.startsWith('/api/') ||
937
+ req.path.startsWith('/auth/') || req.path.startsWith('/admin/') ||
938
+ req.path === '/health' || req.path === '/metrics') {
939
+ sendError(res, 404, ErrorCode.NOT_FOUND, 'Not found');
940
+ return;
941
+ }
942
+ res.sendFile(path.join(frontendPath, 'index.html'));
943
+ });
944
+ }
945
+
946
+ // Global error handler — catches JSON parse errors and other unhandled errors
947
+ // Must be registered after all routes (Express identifies error handlers by 4 params)
948
+ app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
949
+ // JSON parse errors from body-parser
950
+ if (err.type === 'entity.parse.failed' || (err instanceof SyntaxError && 'body' in err)) {
951
+ sendError(res, 400, ErrorCode.VALIDATION_FAILED, 'Invalid JSON in request body', {
952
+ hint: 'Ensure the request body is valid JSON',
953
+ });
954
+ return;
955
+ }
956
+ // Payload too large
957
+ if (err.type === 'entity.too.large') {
958
+ sendError(res, 413, ErrorCode.VALIDATION_FAILED, 'Request body too large', {
959
+ hint: 'Maximum request body size is 10MB',
960
+ });
961
+ return;
962
+ }
963
+ // All other errors — never expose stack traces
964
+ const message = isProduction ? 'Internal server error' : (err.message || 'Internal server error');
965
+ sendError(res, err.status || 500, ErrorCode.INTERNAL_ERROR, message);
966
+ });
967
+
968
+ // Session cleanup job: purge expired sessions every 5 minutes
969
+ const sessionCleanupInterval = setInterval(() => {
970
+ try {
971
+ saasStores.sessionStore.deleteExpired();
972
+ } catch {
973
+ // Ignore cleanup errors
974
+ }
975
+ }, 300_000);
976
+ sessionCleanupInterval.unref();
977
+
978
+ // Forward proxy server (optional, started alongside Express)
979
+ let proxyServer: ForwardProxyServer | undefined;
980
+ if (config.proxy?.enabled) {
981
+ proxyServer = createForwardProxy(gateway, config);
982
+ }
983
+
984
+ return { app, gateway, metrics, tracer, healthChecks, saasStores, proxyServer };
985
+ }