@langwatch/mcp-server 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.python-version +1 -0
  2. package/CHANGELOG.md +110 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +86 -4
  5. package/dist/{archive-project-MK3673A7.js → archive-project-W3IKI7UL.js} +5 -5
  6. package/dist/archive-run-plan-474OKHHU.js +16 -0
  7. package/dist/{archive-scenario-KATSXRIN.js → archive-scenario-V5UNYK26.js} +5 -5
  8. package/dist/archive-test-suite-O7FLDMXK.js +16 -0
  9. package/dist/{assign-prompt-tag-E3C5W6PI.js → assign-prompt-tag-Q46WRB6P.js} +4 -4
  10. package/dist/chunk-56OGTS24.js +44 -0
  11. package/dist/chunk-6DJDEXET.js +63 -0
  12. package/dist/chunk-7E4FZIYJ.js +15 -0
  13. package/dist/{chunk-J45SVWPR.js → chunk-AN4OUA4D.js} +2 -2
  14. package/dist/chunk-CKNAH5TG.js +74 -0
  15. package/dist/{chunk-CE6QMZOP.js → chunk-EYXMLE5X.js} +86 -11
  16. package/dist/{chunk-XY4STOBE.js → chunk-G4ZAZTZW.js} +2 -2
  17. package/dist/{chunk-5UWY24RP.js → chunk-GELJ2MQR.js} +12 -3
  18. package/dist/chunk-HV4DWKBI.js +46 -0
  19. package/dist/chunk-JR6DJZOU.js +49 -0
  20. package/dist/{chunk-DAIQKS27.js → chunk-JTQJRNFJ.js} +2 -2
  21. package/dist/{chunk-WWMDHZZV.js → chunk-K37ZNPXK.js} +2 -2
  22. package/dist/{chunk-EU22L52K.js → chunk-OJLYYJWI.js} +2 -2
  23. package/dist/{chunk-Y3HDZ6OW.js → chunk-QAMP3CGR.js} +2 -2
  24. package/dist/{chunk-VYPSKRPN.js → chunk-QRO35SOM.js} +347 -146
  25. package/dist/{chunk-O3LWVN67.js → chunk-RVAE2MEA.js} +2 -2
  26. package/dist/{chunk-UXXA4D6K.js → chunk-S4GVX36W.js} +2 -2
  27. package/dist/{chunk-BJ7B4R26.js → chunk-SAS5OB7F.js} +18 -2
  28. package/dist/{chunk-RYRB2YVJ.js → chunk-SR6QPRIE.js} +2 -2
  29. package/dist/chunk-UF6XIGYR.js +58 -0
  30. package/dist/{chunk-T7WJVS2N.js → chunk-UFOYFYI7.js} +2 -2
  31. package/dist/chunk-UJPXFA26.js +267 -0
  32. package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
  33. package/dist/config.js +2 -2
  34. package/dist/{create-agent-7W57J7MT.js → create-agent-JFJ2K6X3.js} +5 -5
  35. package/dist/{create-annotation-W2XELBCQ.js → create-annotation-EUXXQ6NS.js} +5 -5
  36. package/dist/{create-api-key-MKRXKFJM.js → create-api-key-BGBB76JX.js} +5 -5
  37. package/dist/{create-dashboard-QGL7IODC.js → create-dashboard-Z76AIPLS.js} +5 -5
  38. package/dist/{create-dataset-3PH7A4HG.js → create-dataset-FD5VAXU4.js} +5 -5
  39. package/dist/{create-dataset-records-4DZS3VRL.js → create-dataset-records-WQUXXOHI.js} +5 -5
  40. package/dist/{create-evaluator-YWH6NC4M.js → create-evaluator-6NV33Z52.js} +5 -5
  41. package/dist/create-mcp-server.js +9 -4
  42. package/dist/{create-project-BOBQ32C5.js → create-project-ZQ7TFIO2.js} +5 -5
  43. package/dist/{create-prompt-FLCFGITE.js → create-prompt-BS6QRCUU.js} +4 -4
  44. package/dist/{create-prompt-tag-EMPNIKEA.js → create-prompt-tag-FCQ5ELSQ.js} +4 -4
  45. package/dist/{create-scenario-BSBIHUL6.js → create-scenario-DRPTPXU3.js} +12 -5
  46. package/dist/create-test-suite-ZIZCCFJ4.js +37 -0
  47. package/dist/{delete-agent-7MSYNY5E.js → delete-agent-SA4JMUZF.js} +5 -5
  48. package/dist/{delete-annotation-ZT5IGN24.js → delete-annotation-X3KKK7QY.js} +5 -5
  49. package/dist/{delete-dashboard-BTIY5S3L.js → delete-dashboard-LYMRPF27.js} +5 -5
  50. package/dist/{delete-dataset-U45HQHPR.js → delete-dataset-W6EZ44HY.js} +5 -5
  51. package/dist/{delete-dataset-records-R43IMAWB.js → delete-dataset-records-7CNOTALB.js} +5 -5
  52. package/dist/{delete-evaluator-U6JMQ6C7.js → delete-evaluator-RKYC4TNB.js} +5 -5
  53. package/dist/{delete-prompt-tag-4EV4TDHB.js → delete-prompt-tag-QB3OCZUV.js} +4 -4
  54. package/dist/{delete-workflow-5QYALXEL.js → delete-workflow-NG2VXYYM.js} +5 -5
  55. package/dist/{discover-evaluator-schema-MRGHVNTT.js → discover-evaluator-schema-ESSHQGPY.js} +58 -321
  56. package/dist/{discover-scenario-schema-UPBTCWPG.js → discover-scenario-schema-W7KBXA4J.js} +54 -2
  57. package/dist/{discover-schema-ICGXPHUW.js → discover-schema-L6QDPON3.js} +2 -2
  58. package/dist/get-agent-7THF7HCU.js +66 -0
  59. package/dist/{get-analytics-PVERWOA5.js → get-analytics-R6AZD3AW.js} +4 -4
  60. package/dist/{get-dashboard-ZZPC3WMD.js → get-dashboard-P4WWVHFU.js} +5 -5
  61. package/dist/{get-dataset-25TB2LQH.js → get-dataset-AKTYSQDI.js} +5 -5
  62. package/dist/{get-evaluator-3FTRQKEC.js → get-evaluator-RZ44UIN6.js} +5 -5
  63. package/dist/{get-project-5OAXYC57.js → get-project-QSQEAB33.js} +5 -5
  64. package/dist/get-prompt-GVKXF5NL.js +100 -0
  65. package/dist/get-run-plan-NY7J5GRE.js +61 -0
  66. package/dist/{get-scenario-DSSTR6FI.js → get-scenario-Q3LLQE4T.js} +9 -5
  67. package/dist/{get-simulation-run-RQPMS2MC.js → get-simulation-run-WINEYDMK.js} +14 -5
  68. package/dist/get-test-suite-4HKA7OKS.js +49 -0
  69. package/dist/{get-trace-O5VT3NAC.js → get-trace-RCUV7N4O.js} +4 -4
  70. package/dist/{get-workflow-3EZDOEIF.js → get-workflow-EEOC7Y45.js} +5 -5
  71. package/dist/http-security-EU3CHTY7.js +26 -0
  72. package/dist/{http-server-2ACJ3CXW.js → http-server-WCDUARTH.js} +1038 -693
  73. package/dist/index.js +6437 -4020
  74. package/dist/{langwatch-api-annotations-E65MI3WZ.js → langwatch-api-annotations-6DHVZWUK.js} +5 -5
  75. package/dist/{langwatch-api-dashboards-QCDECRCY.js → langwatch-api-dashboards-2KJL2M42.js} +5 -5
  76. package/dist/{langwatch-api-monitors-UIO2KTDV.js → langwatch-api-monitors-6ISFJ7WX.js} +4 -4
  77. package/dist/{langwatch-api-secrets-4T2EKSJ7.js → langwatch-api-secrets-OEBD6GBU.js} +4 -4
  78. package/dist/{langwatch-api-triggers-MPOEKPUY.js → langwatch-api-triggers-5E6DDGX6.js} +4 -4
  79. package/dist/langwatch-api-workflows-6UJL5U4Z.js +16 -0
  80. package/dist/list-agents-AMVDQPQL.js +43 -0
  81. package/dist/{list-annotations-CGX5BT7E.js → list-annotations-6PR5NLIC.js} +5 -5
  82. package/dist/{list-api-keys-JLWJPFRX.js → list-api-keys-GVPNBIHQ.js} +5 -5
  83. package/dist/{list-dashboards-RNIWX3TX.js → list-dashboards-Z3BZKDGF.js} +5 -5
  84. package/dist/{list-dataset-records-B5MHZR66.js → list-dataset-records-O67HZ35Y.js} +5 -5
  85. package/dist/{list-datasets-BVCAMQCP.js → list-datasets-WXZL6UMP.js} +5 -5
  86. package/dist/{list-evaluators-65FBTL2U.js → list-evaluators-PAX72WCN.js} +5 -5
  87. package/dist/{list-model-providers-GRK3I3VV.js → list-model-providers-VVLZMLCX.js} +5 -5
  88. package/dist/{list-projects-JLMBFMW3.js → list-projects-AQVI6SUK.js} +5 -5
  89. package/dist/{list-prompt-tags-UHNWDGQI.js → list-prompt-tags-WZFBJOHG.js} +4 -4
  90. package/dist/{list-prompts-7254B3U7.js → list-prompts-BQOHRHYT.js} +4 -4
  91. package/dist/list-run-plans-UBAU2DGH.js +46 -0
  92. package/dist/{list-scenarios-46YNICKM.js → list-scenarios-MS4IUIVM.js} +10 -7
  93. package/dist/{list-simulation-runs-LGNEFNPN.js → list-simulation-runs-D6YF4ZOU.js} +13 -7
  94. package/dist/list-test-suites-L7R2O6DR.js +35 -0
  95. package/dist/{list-workflows-MLQDM6CP.js → list-workflows-6MV4XJHF.js} +5 -5
  96. package/dist/{rename-prompt-tag-RHQMNEQL.js → rename-prompt-tag-K5LQ4CY6.js} +4 -4
  97. package/dist/rename-test-suite-NTYHAO5X.js +21 -0
  98. package/dist/report-issue-RHZZ33IS.js +885 -0
  99. package/dist/rerun-run-plan-MUDWJBE4.js +22 -0
  100. package/dist/{revoke-api-key-3Y35EMBT.js → revoke-api-key-6LAZ3QZZ.js} +5 -5
  101. package/dist/run-agent-CAGWGTYT.js +72 -0
  102. package/dist/run-plan-T4ZYOZGA.js +39 -0
  103. package/dist/run-test-suite-L4MZ7G4D.js +26 -0
  104. package/dist/{run-workflow-5LVBZYLS.js → run-workflow-DFIS23AP.js} +5 -5
  105. package/dist/{search-traces-U4HLOCZ2.js → search-traces-4XTJYXIK.js} +4 -4
  106. package/dist/{set-model-provider-V3M5CDIY.js → set-model-provider-XRNMFOVR.js} +5 -5
  107. package/dist/{update-agent-47RZIHS7.js → update-agent-DUWNYBNA.js} +5 -5
  108. package/dist/{update-dataset-G6QJJGS5.js → update-dataset-YSOMDCOB.js} +5 -5
  109. package/dist/{update-dataset-record-RMWLSWCS.js → update-dataset-record-DEA2ITYR.js} +5 -5
  110. package/dist/{update-evaluator-SFRRI5Q3.js → update-evaluator-NH7Q5GWV.js} +5 -5
  111. package/dist/{update-project-PI5DWBVK.js → update-project-Q26FBSYH.js} +5 -5
  112. package/dist/update-prompt-ZQQ2C3KU.js +143 -0
  113. package/dist/{update-scenario-G3IU2UIB.js → update-scenario-WUX34JAR.js} +12 -5
  114. package/package.json +8 -9
  115. package/pyproject.toml +5 -5
  116. package/src/__tests__/agent-tools.unit.test.ts +257 -0
  117. package/src/__tests__/all-tools.integration.test.ts +321 -75
  118. package/src/__tests__/config.unit.test.ts +33 -0
  119. package/src/__tests__/docker.integration.test.ts +91 -7
  120. package/src/__tests__/documentation-fetch-security.integration.test.ts +8 -2
  121. package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
  122. package/src/__tests__/http-security-origin.unit.test.ts +97 -0
  123. package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
  124. package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
  125. package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
  126. package/src/__tests__/http-server-origin.integration.test.ts +169 -0
  127. package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
  128. package/src/__tests__/http-transport.integration.test.ts +85 -87
  129. package/src/__tests__/integration.integration.test.ts +7 -11
  130. package/src/__tests__/prompt-response-types.unit.test.ts +51 -0
  131. package/src/__tests__/prompts.integration.test.ts +736 -0
  132. package/src/__tests__/run-plan-tools.unit.test.ts +565 -0
  133. package/src/__tests__/scenario-tools.unit.test.ts +19 -0
  134. package/src/__tests__/simulation-run-tools.unit.test.ts +116 -0
  135. package/src/__tests__/support/http-server-harness.ts +138 -0
  136. package/src/__tests__/test-suite-tools.unit.test.ts +615 -0
  137. package/src/__tests__/{tools.unit.test.ts → tools.integration.test.ts} +31 -59
  138. package/src/config.ts +17 -2
  139. package/src/create-mcp-server.ts +334 -55
  140. package/src/http-security.ts +492 -0
  141. package/src/http-server.ts +536 -152
  142. package/src/index.ts +49 -2
  143. package/src/langwatch-api-agents.ts +196 -12
  144. package/src/langwatch-api-run-plans.ts +157 -0
  145. package/src/langwatch-api-scenarios.ts +9 -0
  146. package/src/langwatch-api-simulation-runs.ts +5 -0
  147. package/src/langwatch-api-test-suites.ts +135 -0
  148. package/src/langwatch-api.ts +62 -12
  149. package/src/schemas/index.ts +5 -0
  150. package/src/schemas/run-plan.ts +92 -0
  151. package/src/schemas/suite-fields.ts +149 -0
  152. package/src/tools/archive-run-plan.ts +12 -0
  153. package/src/tools/archive-test-suite.ts +12 -0
  154. package/src/tools/create-scenario.ts +8 -0
  155. package/src/tools/create-test-suite.ts +43 -0
  156. package/src/tools/discover-evaluator-schema.ts +2 -2
  157. package/src/tools/discover-scenario-schema.ts +54 -0
  158. package/src/tools/format-run-plan.ts +68 -0
  159. package/src/tools/format-scenario.ts +14 -0
  160. package/src/tools/format-suite-details.ts +96 -0
  161. package/src/tools/get-agent.ts +45 -3
  162. package/src/tools/get-prompt.ts +108 -24
  163. package/src/tools/get-run-plan.ts +62 -0
  164. package/src/tools/get-scenario.ts +3 -0
  165. package/src/tools/get-simulation-run.ts +9 -0
  166. package/src/tools/get-test-suite.ts +49 -0
  167. package/src/tools/list-agents.ts +20 -3
  168. package/src/tools/list-run-plans.ts +47 -0
  169. package/src/tools/list-scenarios.ts +12 -2
  170. package/src/tools/list-simulation-runs.ts +6 -2
  171. package/src/tools/list-test-suites.ts +35 -0
  172. package/src/tools/rename-test-suite.ts +18 -0
  173. package/src/tools/rerun-run-plan.ts +21 -0
  174. package/src/tools/run-agent.ts +54 -6
  175. package/src/tools/run-plan.ts +49 -0
  176. package/src/tools/run-test-suite.ts +30 -0
  177. package/src/tools/test-agent.ts +18 -0
  178. package/src/tools/update-prompt.ts +195 -10
  179. package/src/tools/update-scenario.ts +8 -0
  180. package/src/tools/update-test-suite.ts +45 -0
  181. package/tsconfig.json +7 -2
  182. package/uv.lock +418 -298
  183. package/dist/archive-suite-C3KX6IIZ.js +0 -16
  184. package/dist/chunk-WDH5P5RF.js +0 -48
  185. package/dist/create-suite-TWF6BBR4.js +0 -39
  186. package/dist/get-agent-7QJVPH72.js +0 -29
  187. package/dist/get-prompt-I753LZKZ.js +0 -46
  188. package/dist/get-suite-GTOCY5PL.js +0 -45
  189. package/dist/langwatch-api-workflows-5HRZ7HR4.js +0 -16
  190. package/dist/list-agents-TIJTP566.js +0 -33
  191. package/dist/list-suites-5VK4HXEK.js +0 -43
  192. package/dist/report-issue-DXJ6TPTC.js +0 -412
  193. package/dist/run-agent-RBSIFKRT.js +0 -37
  194. package/dist/run-suite-K3DNQ3SE.js +0 -30
  195. package/dist/update-prompt-RQFTEO7Y.js +0 -25
  196. package/dist/update-suite-TRGGLNR7.js +0 -40
  197. package/pnpm-workspace.yaml +0 -34
  198. package/src/langwatch-api-suites.ts +0 -118
  199. package/src/tools/archive-suite.ts +0 -12
  200. package/src/tools/create-suite.ts +0 -42
  201. package/src/tools/get-suite.ts +0 -47
  202. package/src/tools/list-suites.ts +0 -43
  203. package/src/tools/run-suite.ts +0 -28
  204. package/src/tools/update-suite.ts +0 -44
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Security primitives for the standalone MCP HTTP server.
3
+ *
4
+ * The standalone server runs on developer machines and inside clusters with no
5
+ * database of its own, so it cannot look an API key up locally the way the
6
+ * in-app handler does. These helpers give it the same guarantees over HTTP:
7
+ * an origin allowlist, API key verification against the LangWatch API with a
8
+ * short-lived cache, per-IP rate limiting of failed authentication, and a
9
+ * session store that expires idle sessions and caps concurrency per key.
10
+ */
11
+
12
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Bind host
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Default listen address. The MCP transport specification says a local server
20
+ * should bind only to loopback, because binding every interface exposes it to
21
+ * anything that can reach the machine.
22
+ */
23
+ export const DEFAULT_BIND_HOST = "127.0.0.1";
24
+
25
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
26
+
27
+ function stripBrackets(host: string): string {
28
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
29
+ }
30
+
31
+ /** True when binding to this host only accepts connections from the machine itself. */
32
+ export function isLoopbackHost(host: string): boolean {
33
+ return LOOPBACK_HOSTS.has(stripBrackets(host.trim().toLowerCase()));
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Origin allowlist
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Origins accepted without configuration.
42
+ *
43
+ * A DNS rebinding attack reaches the server through an attacker-controlled
44
+ * hostname that resolves to a loopback address, so the browser sends that
45
+ * attacker hostname in the Origin header. It can never send a loopback origin
46
+ * for a page it did not actually load from this machine, which is the case the
47
+ * server exists to serve.
48
+ */
49
+ const ALWAYS_ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
50
+
51
+ /**
52
+ * Reduces an origin to `scheme://host[:port]` so comparisons ignore trailing
53
+ * slashes, paths, and case differences. Returns null for anything that is not
54
+ * a usable origin, including the opaque `null` origin sent by sandboxed frames
55
+ * and `file://` pages.
56
+ */
57
+ function normalizeOrigin(
58
+ origin: string
59
+ ): { origin: string; hostname: string } | null {
60
+ let parsed: URL;
61
+ try {
62
+ parsed = new URL(origin.trim());
63
+ } catch {
64
+ return null;
65
+ }
66
+ if (parsed.origin === "null") return null;
67
+ return {
68
+ origin: parsed.origin,
69
+ hostname: stripBrackets(parsed.hostname.toLowerCase()),
70
+ };
71
+ }
72
+
73
+ /** Parses a comma separated origin list into normalized origins. */
74
+ export function parseAllowedOrigins(raw: string | undefined | null): string[] {
75
+ if (!raw) return [];
76
+ const parsed = raw
77
+ .split(",")
78
+ .map((entry) => normalizeOrigin(entry)?.origin)
79
+ .filter((entry): entry is string => entry !== undefined);
80
+ return [...new Set(parsed)];
81
+ }
82
+
83
+ /**
84
+ * True when a browser sending this Origin should be allowed through. Loopback
85
+ * origins always pass; everything else has to be configured explicitly. There
86
+ * is deliberately no wildcard: an operator who needs a remote origin lists it.
87
+ */
88
+ export function isOriginAllowed({
89
+ origin,
90
+ allowedOrigins,
91
+ }: {
92
+ origin: string;
93
+ allowedOrigins: readonly string[];
94
+ }): boolean {
95
+ const normalized = normalizeOrigin(origin);
96
+ if (!normalized) return false;
97
+
98
+ if (ALWAYS_ALLOWED_ORIGIN_HOSTS.has(normalized.hostname)) return true;
99
+
100
+ return allowedOrigins.includes(normalized.origin);
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // API key hashing and comparison
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Per-process key for deriving identifiers from API keys.
109
+ *
110
+ * Regenerated on every start. The derived values only index in-memory maps and
111
+ * are only compared within one process, so they never need to be reproducible
112
+ * across restarts. Keying the digest means that anything which exposes those
113
+ * maps, a heap dump or debug output, still does not let a list of candidate
114
+ * keys be confirmed offline the way a bare digest would.
115
+ */
116
+ const KEY_DERIVATION_SECRET = randomBytes(32);
117
+
118
+ /** Derives an opaque identifier from an API key, for use as a map key. */
119
+ export function hashApiKey(apiKey: string): string {
120
+ return createHmac("sha256", KEY_DERIVATION_SECRET)
121
+ .update(apiKey)
122
+ .digest("hex");
123
+ }
124
+
125
+ /** Constant-time API key comparison over fixed-length digests. */
126
+ export function apiKeysMatch({
127
+ presentedKey,
128
+ expectedKey,
129
+ }: {
130
+ presentedKey: string;
131
+ expectedKey: string;
132
+ }): boolean {
133
+ const presented = createHmac("sha256", KEY_DERIVATION_SECRET)
134
+ .update(presentedKey)
135
+ .digest();
136
+ const expected = createHmac("sha256", KEY_DERIVATION_SECRET)
137
+ .update(expectedKey)
138
+ .digest();
139
+ return timingSafeEqual(presented, expected);
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Rate limiter (fixed window per client address)
144
+ //
145
+ // Counts reset in whole windows rather than sliding, so a burst straddling a
146
+ // window boundary can reach twice maxRequests. That is acceptable for the
147
+ // failed-authentication and OAuth limits this backs.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export interface RateLimiter {
151
+ /** True when this IP is over the limit. Does not increment. */
152
+ isBlocked(ip: string): boolean;
153
+ /** Records one request for this IP. */
154
+ track(ip: string): void;
155
+ /** Drops entries whose window has passed. */
156
+ sweep(): void;
157
+ /** Drops every entry. */
158
+ clear(): void;
159
+ }
160
+
161
+ export function createRateLimiter({
162
+ windowMs,
163
+ maxRequests,
164
+ }: {
165
+ windowMs: number;
166
+ maxRequests: number;
167
+ }): RateLimiter {
168
+ const entries = new Map<string, { count: number; windowStart: number }>();
169
+
170
+ return {
171
+ isBlocked(ip) {
172
+ const entry = entries.get(ip);
173
+ if (!entry || Date.now() - entry.windowStart > windowMs) return false;
174
+ return entry.count >= maxRequests;
175
+ },
176
+ track(ip) {
177
+ const now = Date.now();
178
+ const entry = entries.get(ip);
179
+ if (!entry || now - entry.windowStart > windowMs) {
180
+ entries.set(ip, { count: 1, windowStart: now });
181
+ } else {
182
+ entry.count++;
183
+ }
184
+ },
185
+ sweep() {
186
+ const now = Date.now();
187
+ for (const [ip, entry] of entries) {
188
+ if (now - entry.windowStart > windowMs) entries.delete(ip);
189
+ }
190
+ },
191
+ clear() {
192
+ entries.clear();
193
+ },
194
+ };
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // API key verifier
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * The lightest authenticated read the LangWatch API exposes. It accepts any
203
+ * project API key and returns only the identity of the project behind it, so
204
+ * it answers "is this key real" without granting or costing anything else.
205
+ */
206
+ const VERIFY_PATH = "/api/me/project";
207
+
208
+ export interface ApiKeyVerifier {
209
+ /** True when the LangWatch API recognises this key. */
210
+ verify(apiKey: string): Promise<boolean>;
211
+ /** Drops expired cache entries. */
212
+ sweep(): void;
213
+ /** Drops every cache entry. */
214
+ clear(): void;
215
+ }
216
+
217
+ export function createApiKeyVerifier({
218
+ endpoint,
219
+ positiveTtlMs = 60_000,
220
+ negativeTtlMs = 30_000,
221
+ maxEntries = 10_000,
222
+ requestTimeoutMs = 5_000,
223
+ fetchImpl = fetch,
224
+ }: {
225
+ endpoint: string;
226
+ positiveTtlMs?: number;
227
+ negativeTtlMs?: number;
228
+ maxEntries?: number;
229
+ requestTimeoutMs?: number;
230
+ fetchImpl?: typeof fetch;
231
+ }): ApiKeyVerifier {
232
+ const cache = new Map<string, { valid: boolean; expiresAt: number }>();
233
+ const inFlight = new Map<string, Promise<boolean>>();
234
+
235
+ function sweep(): void {
236
+ const now = Date.now();
237
+ for (const [key, entry] of cache) {
238
+ if (now >= entry.expiresAt) cache.delete(key);
239
+ }
240
+ }
241
+
242
+ function remember(hashed: string, valid: boolean): void {
243
+ if (cache.size >= maxEntries) {
244
+ sweep();
245
+ // Map iteration is insertion ordered, so the first key is the oldest.
246
+ while (cache.size >= maxEntries) {
247
+ const oldest = cache.keys().next();
248
+ if (oldest.done) break;
249
+ cache.delete(oldest.value);
250
+ }
251
+ }
252
+ cache.set(hashed, {
253
+ valid,
254
+ expiresAt: Date.now() + (valid ? positiveTtlMs : negativeTtlMs),
255
+ });
256
+ }
257
+
258
+ async function askApi(apiKey: string): Promise<boolean | null> {
259
+ let response: Response;
260
+ try {
261
+ response = await fetchImpl(`${endpoint}${VERIFY_PATH}`, {
262
+ method: "GET",
263
+ headers: { "X-Auth-Token": apiKey },
264
+ // An upstream that accepts the connection and never answers would
265
+ // otherwise leave the in-flight promise unsettled, parking every
266
+ // request for this key behind it until the socket dies.
267
+ signal: AbortSignal.timeout(requestTimeoutMs),
268
+ });
269
+ } catch {
270
+ return null;
271
+ }
272
+
273
+ // Only the status matters. Releasing the body returns the socket to the
274
+ // pool instead of holding it until garbage collection.
275
+ await response.body?.cancel().catch(() => undefined);
276
+
277
+ if (response.ok) return true;
278
+ if (response.status === 401 || response.status === 403) return false;
279
+ return null;
280
+ }
281
+
282
+ return {
283
+ async verify(apiKey) {
284
+ const hashed = hashApiKey(apiKey);
285
+
286
+ const cached = cache.get(hashed);
287
+ if (cached && Date.now() < cached.expiresAt) return cached.valid;
288
+
289
+ // Collapse concurrent checks of the same key into one upstream request,
290
+ // so a flood of initialize calls cannot be amplified into a flood of
291
+ // LangWatch API calls.
292
+ const pending = inFlight.get(hashed);
293
+ if (pending) return pending;
294
+
295
+ const request = askApi(apiKey)
296
+ .then((result) => {
297
+ // A null result means the API could not answer. Reject the request
298
+ // but do not cache, so a transient upstream failure does not pin the
299
+ // key into the negative cache.
300
+ if (result !== null) remember(hashed, result);
301
+ return result === true;
302
+ })
303
+ .finally(() => {
304
+ inFlight.delete(hashed);
305
+ });
306
+
307
+ inFlight.set(hashed, request);
308
+ return request;
309
+ },
310
+ sweep,
311
+ clear() {
312
+ cache.clear();
313
+ inFlight.clear();
314
+ },
315
+ };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Session store
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export interface SessionRecord<TTransport> {
323
+ transport: TTransport;
324
+ apiKey: string;
325
+ lastActivityAt: number;
326
+ }
327
+
328
+ export interface SessionReservation<TTransport> {
329
+ /** Hands the reserved slot to the session that was just created. */
330
+ commit(args: { sessionId: string; transport: TTransport }): void;
331
+ /** Returns the slot when the session never came up. */
332
+ release(): void;
333
+ }
334
+
335
+ export interface SessionStore<TTransport> {
336
+ get(sessionId: string): SessionRecord<TTransport> | undefined;
337
+ /**
338
+ * Claims a slot against the per-key cap before a session id exists. Session
339
+ * setup is asynchronous, so without a reservation concurrent requests for
340
+ * one key all pass the cap check while the count is still zero.
341
+ */
342
+ reserve(apiKey: string): SessionReservation<TTransport>;
343
+ /** Reserves and commits in one step, for callers with a session id in hand. */
344
+ add(args: { sessionId: string; transport: TTransport; apiKey: string }): void;
345
+ /** Marks a session as active so the reaper leaves it alone. */
346
+ touch(sessionId: string): void;
347
+ /** Removes a session without closing its transport. */
348
+ remove(sessionId: string): void;
349
+ /** Number of live sessions held for this key. */
350
+ countForKey(apiKey: string): number;
351
+ /** Closes and removes sessions idle for longer than the max age. */
352
+ sweep(): void;
353
+ /** Closes and removes every session. */
354
+ closeAll(): void;
355
+ readonly size: number;
356
+ }
357
+
358
+ export function createSessionStore<TTransport>({
359
+ maxAgeMs,
360
+ closeTransport,
361
+ }: {
362
+ maxAgeMs: number;
363
+ closeTransport: (transport: TTransport) => void;
364
+ }): SessionStore<TTransport> {
365
+ // A Map rather than a plain object: session ids come from request headers,
366
+ // and an object would let `__proto__` reach the prototype chain.
367
+ const sessions = new Map<string, SessionRecord<TTransport>>();
368
+ const countByKey = new Map<string, number>();
369
+
370
+ function decrement(apiKey: string): void {
371
+ const hashed = hashApiKey(apiKey);
372
+ const next = (countByKey.get(hashed) ?? 1) - 1;
373
+ if (next <= 0) countByKey.delete(hashed);
374
+ else countByKey.set(hashed, next);
375
+ }
376
+
377
+ function drop(sessionId: string, record: SessionRecord<TTransport>): void {
378
+ sessions.delete(sessionId);
379
+ decrement(record.apiKey);
380
+ }
381
+
382
+ function reserve(apiKey: string): SessionReservation<TTransport> {
383
+ const hashed = hashApiKey(apiKey);
384
+ countByKey.set(hashed, (countByKey.get(hashed) ?? 0) + 1);
385
+ let settled = false;
386
+
387
+ return {
388
+ commit({ sessionId, transport }) {
389
+ if (settled) return;
390
+ settled = true;
391
+
392
+ const existing = sessions.get(sessionId);
393
+ if (existing) drop(sessionId, existing);
394
+
395
+ // The count already carries this slot from the reservation.
396
+ sessions.set(sessionId, {
397
+ transport,
398
+ apiKey,
399
+ lastActivityAt: Date.now(),
400
+ });
401
+ },
402
+ release() {
403
+ if (settled) return;
404
+ settled = true;
405
+ decrement(apiKey);
406
+ },
407
+ };
408
+ }
409
+
410
+ return {
411
+ get(sessionId) {
412
+ return sessions.get(sessionId);
413
+ },
414
+ reserve,
415
+ add({ sessionId, transport, apiKey }) {
416
+ reserve(apiKey).commit({ sessionId, transport });
417
+ },
418
+ touch(sessionId) {
419
+ const record = sessions.get(sessionId);
420
+ if (record) record.lastActivityAt = Date.now();
421
+ },
422
+ remove(sessionId) {
423
+ const record = sessions.get(sessionId);
424
+ if (record) drop(sessionId, record);
425
+ },
426
+ countForKey(apiKey) {
427
+ return countByKey.get(hashApiKey(apiKey)) ?? 0;
428
+ },
429
+ sweep() {
430
+ const now = Date.now();
431
+ for (const [sessionId, record] of sessions) {
432
+ if (now - record.lastActivityAt > maxAgeMs) {
433
+ drop(sessionId, record);
434
+ closeTransport(record.transport);
435
+ }
436
+ }
437
+ },
438
+ closeAll() {
439
+ for (const [sessionId, record] of sessions) {
440
+ drop(sessionId, record);
441
+ closeTransport(record.transport);
442
+ }
443
+ },
444
+ get size() {
445
+ return sessions.size;
446
+ },
447
+ };
448
+ }
449
+
450
+ /** An OAuth access token and the API key it was minted from. */
451
+ export interface OAuthTokenEntry {
452
+ apiKey: string;
453
+ expiresAt: number;
454
+ }
455
+
456
+ /**
457
+ * Makes room for one more token before it is issued.
458
+ *
459
+ * Tokens outlive the requests that created them, so a key asking for them in a
460
+ * loop would otherwise retain entries until each expired. The per-address rate
461
+ * limit does not bound this on its own, because one key can be presented from
462
+ * many addresses. Expired entries anywhere in the map are dropped on the way
463
+ * past.
464
+ */
465
+ export function admitOAuthToken({
466
+ apiKey,
467
+ tokens,
468
+ maxPerKey,
469
+ }: {
470
+ apiKey: string;
471
+ tokens: Map<string, OAuthTokenEntry>;
472
+ maxPerKey: number;
473
+ }): void {
474
+ const now = Date.now();
475
+ const held: { token: string; expiresAt: number }[] = [];
476
+
477
+ for (const [token, entry] of tokens) {
478
+ if (now >= entry.expiresAt) {
479
+ tokens.delete(token);
480
+ continue;
481
+ }
482
+ if (entry.apiKey === apiKey) {
483
+ held.push({ token, expiresAt: entry.expiresAt });
484
+ }
485
+ }
486
+
487
+ // Oldest first, so the longest-standing tokens give way to the new one.
488
+ held.sort((a, b) => a.expiresAt - b.expiresAt);
489
+ for (let i = 0; i <= held.length - maxPerKey; i += 1) {
490
+ tokens.delete(held[i]!.token);
491
+ }
492
+ }