@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.
- package/.python-version +1 -0
- package/CHANGELOG.md +110 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +86 -4
- package/dist/{archive-project-MK3673A7.js → archive-project-W3IKI7UL.js} +5 -5
- package/dist/archive-run-plan-474OKHHU.js +16 -0
- package/dist/{archive-scenario-KATSXRIN.js → archive-scenario-V5UNYK26.js} +5 -5
- package/dist/archive-test-suite-O7FLDMXK.js +16 -0
- package/dist/{assign-prompt-tag-E3C5W6PI.js → assign-prompt-tag-Q46WRB6P.js} +4 -4
- package/dist/chunk-56OGTS24.js +44 -0
- package/dist/chunk-6DJDEXET.js +63 -0
- package/dist/chunk-7E4FZIYJ.js +15 -0
- package/dist/{chunk-J45SVWPR.js → chunk-AN4OUA4D.js} +2 -2
- package/dist/chunk-CKNAH5TG.js +74 -0
- package/dist/{chunk-CE6QMZOP.js → chunk-EYXMLE5X.js} +86 -11
- package/dist/{chunk-XY4STOBE.js → chunk-G4ZAZTZW.js} +2 -2
- package/dist/{chunk-5UWY24RP.js → chunk-GELJ2MQR.js} +12 -3
- package/dist/chunk-HV4DWKBI.js +46 -0
- package/dist/chunk-JR6DJZOU.js +49 -0
- package/dist/{chunk-DAIQKS27.js → chunk-JTQJRNFJ.js} +2 -2
- package/dist/{chunk-WWMDHZZV.js → chunk-K37ZNPXK.js} +2 -2
- package/dist/{chunk-EU22L52K.js → chunk-OJLYYJWI.js} +2 -2
- package/dist/{chunk-Y3HDZ6OW.js → chunk-QAMP3CGR.js} +2 -2
- package/dist/{chunk-VYPSKRPN.js → chunk-QRO35SOM.js} +347 -146
- package/dist/{chunk-O3LWVN67.js → chunk-RVAE2MEA.js} +2 -2
- package/dist/{chunk-UXXA4D6K.js → chunk-S4GVX36W.js} +2 -2
- package/dist/{chunk-BJ7B4R26.js → chunk-SAS5OB7F.js} +18 -2
- package/dist/{chunk-RYRB2YVJ.js → chunk-SR6QPRIE.js} +2 -2
- package/dist/chunk-UF6XIGYR.js +58 -0
- package/dist/{chunk-T7WJVS2N.js → chunk-UFOYFYI7.js} +2 -2
- package/dist/chunk-UJPXFA26.js +267 -0
- package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
- package/dist/config.js +2 -2
- package/dist/{create-agent-7W57J7MT.js → create-agent-JFJ2K6X3.js} +5 -5
- package/dist/{create-annotation-W2XELBCQ.js → create-annotation-EUXXQ6NS.js} +5 -5
- package/dist/{create-api-key-MKRXKFJM.js → create-api-key-BGBB76JX.js} +5 -5
- package/dist/{create-dashboard-QGL7IODC.js → create-dashboard-Z76AIPLS.js} +5 -5
- package/dist/{create-dataset-3PH7A4HG.js → create-dataset-FD5VAXU4.js} +5 -5
- package/dist/{create-dataset-records-4DZS3VRL.js → create-dataset-records-WQUXXOHI.js} +5 -5
- package/dist/{create-evaluator-YWH6NC4M.js → create-evaluator-6NV33Z52.js} +5 -5
- package/dist/create-mcp-server.js +9 -4
- package/dist/{create-project-BOBQ32C5.js → create-project-ZQ7TFIO2.js} +5 -5
- package/dist/{create-prompt-FLCFGITE.js → create-prompt-BS6QRCUU.js} +4 -4
- package/dist/{create-prompt-tag-EMPNIKEA.js → create-prompt-tag-FCQ5ELSQ.js} +4 -4
- package/dist/{create-scenario-BSBIHUL6.js → create-scenario-DRPTPXU3.js} +12 -5
- package/dist/create-test-suite-ZIZCCFJ4.js +37 -0
- package/dist/{delete-agent-7MSYNY5E.js → delete-agent-SA4JMUZF.js} +5 -5
- package/dist/{delete-annotation-ZT5IGN24.js → delete-annotation-X3KKK7QY.js} +5 -5
- package/dist/{delete-dashboard-BTIY5S3L.js → delete-dashboard-LYMRPF27.js} +5 -5
- package/dist/{delete-dataset-U45HQHPR.js → delete-dataset-W6EZ44HY.js} +5 -5
- package/dist/{delete-dataset-records-R43IMAWB.js → delete-dataset-records-7CNOTALB.js} +5 -5
- package/dist/{delete-evaluator-U6JMQ6C7.js → delete-evaluator-RKYC4TNB.js} +5 -5
- package/dist/{delete-prompt-tag-4EV4TDHB.js → delete-prompt-tag-QB3OCZUV.js} +4 -4
- package/dist/{delete-workflow-5QYALXEL.js → delete-workflow-NG2VXYYM.js} +5 -5
- package/dist/{discover-evaluator-schema-MRGHVNTT.js → discover-evaluator-schema-ESSHQGPY.js} +58 -321
- package/dist/{discover-scenario-schema-UPBTCWPG.js → discover-scenario-schema-W7KBXA4J.js} +54 -2
- package/dist/{discover-schema-ICGXPHUW.js → discover-schema-L6QDPON3.js} +2 -2
- package/dist/get-agent-7THF7HCU.js +66 -0
- package/dist/{get-analytics-PVERWOA5.js → get-analytics-R6AZD3AW.js} +4 -4
- package/dist/{get-dashboard-ZZPC3WMD.js → get-dashboard-P4WWVHFU.js} +5 -5
- package/dist/{get-dataset-25TB2LQH.js → get-dataset-AKTYSQDI.js} +5 -5
- package/dist/{get-evaluator-3FTRQKEC.js → get-evaluator-RZ44UIN6.js} +5 -5
- package/dist/{get-project-5OAXYC57.js → get-project-QSQEAB33.js} +5 -5
- package/dist/get-prompt-GVKXF5NL.js +100 -0
- package/dist/get-run-plan-NY7J5GRE.js +61 -0
- package/dist/{get-scenario-DSSTR6FI.js → get-scenario-Q3LLQE4T.js} +9 -5
- package/dist/{get-simulation-run-RQPMS2MC.js → get-simulation-run-WINEYDMK.js} +14 -5
- package/dist/get-test-suite-4HKA7OKS.js +49 -0
- package/dist/{get-trace-O5VT3NAC.js → get-trace-RCUV7N4O.js} +4 -4
- package/dist/{get-workflow-3EZDOEIF.js → get-workflow-EEOC7Y45.js} +5 -5
- package/dist/http-security-EU3CHTY7.js +26 -0
- package/dist/{http-server-2ACJ3CXW.js → http-server-WCDUARTH.js} +1038 -693
- package/dist/index.js +6437 -4020
- package/dist/{langwatch-api-annotations-E65MI3WZ.js → langwatch-api-annotations-6DHVZWUK.js} +5 -5
- package/dist/{langwatch-api-dashboards-QCDECRCY.js → langwatch-api-dashboards-2KJL2M42.js} +5 -5
- package/dist/{langwatch-api-monitors-UIO2KTDV.js → langwatch-api-monitors-6ISFJ7WX.js} +4 -4
- package/dist/{langwatch-api-secrets-4T2EKSJ7.js → langwatch-api-secrets-OEBD6GBU.js} +4 -4
- package/dist/{langwatch-api-triggers-MPOEKPUY.js → langwatch-api-triggers-5E6DDGX6.js} +4 -4
- package/dist/langwatch-api-workflows-6UJL5U4Z.js +16 -0
- package/dist/list-agents-AMVDQPQL.js +43 -0
- package/dist/{list-annotations-CGX5BT7E.js → list-annotations-6PR5NLIC.js} +5 -5
- package/dist/{list-api-keys-JLWJPFRX.js → list-api-keys-GVPNBIHQ.js} +5 -5
- package/dist/{list-dashboards-RNIWX3TX.js → list-dashboards-Z3BZKDGF.js} +5 -5
- package/dist/{list-dataset-records-B5MHZR66.js → list-dataset-records-O67HZ35Y.js} +5 -5
- package/dist/{list-datasets-BVCAMQCP.js → list-datasets-WXZL6UMP.js} +5 -5
- package/dist/{list-evaluators-65FBTL2U.js → list-evaluators-PAX72WCN.js} +5 -5
- package/dist/{list-model-providers-GRK3I3VV.js → list-model-providers-VVLZMLCX.js} +5 -5
- package/dist/{list-projects-JLMBFMW3.js → list-projects-AQVI6SUK.js} +5 -5
- package/dist/{list-prompt-tags-UHNWDGQI.js → list-prompt-tags-WZFBJOHG.js} +4 -4
- package/dist/{list-prompts-7254B3U7.js → list-prompts-BQOHRHYT.js} +4 -4
- package/dist/list-run-plans-UBAU2DGH.js +46 -0
- package/dist/{list-scenarios-46YNICKM.js → list-scenarios-MS4IUIVM.js} +10 -7
- package/dist/{list-simulation-runs-LGNEFNPN.js → list-simulation-runs-D6YF4ZOU.js} +13 -7
- package/dist/list-test-suites-L7R2O6DR.js +35 -0
- package/dist/{list-workflows-MLQDM6CP.js → list-workflows-6MV4XJHF.js} +5 -5
- package/dist/{rename-prompt-tag-RHQMNEQL.js → rename-prompt-tag-K5LQ4CY6.js} +4 -4
- package/dist/rename-test-suite-NTYHAO5X.js +21 -0
- package/dist/report-issue-RHZZ33IS.js +885 -0
- package/dist/rerun-run-plan-MUDWJBE4.js +22 -0
- package/dist/{revoke-api-key-3Y35EMBT.js → revoke-api-key-6LAZ3QZZ.js} +5 -5
- package/dist/run-agent-CAGWGTYT.js +72 -0
- package/dist/run-plan-T4ZYOZGA.js +39 -0
- package/dist/run-test-suite-L4MZ7G4D.js +26 -0
- package/dist/{run-workflow-5LVBZYLS.js → run-workflow-DFIS23AP.js} +5 -5
- package/dist/{search-traces-U4HLOCZ2.js → search-traces-4XTJYXIK.js} +4 -4
- package/dist/{set-model-provider-V3M5CDIY.js → set-model-provider-XRNMFOVR.js} +5 -5
- package/dist/{update-agent-47RZIHS7.js → update-agent-DUWNYBNA.js} +5 -5
- package/dist/{update-dataset-G6QJJGS5.js → update-dataset-YSOMDCOB.js} +5 -5
- package/dist/{update-dataset-record-RMWLSWCS.js → update-dataset-record-DEA2ITYR.js} +5 -5
- package/dist/{update-evaluator-SFRRI5Q3.js → update-evaluator-NH7Q5GWV.js} +5 -5
- package/dist/{update-project-PI5DWBVK.js → update-project-Q26FBSYH.js} +5 -5
- package/dist/update-prompt-ZQQ2C3KU.js +143 -0
- package/dist/{update-scenario-G3IU2UIB.js → update-scenario-WUX34JAR.js} +12 -5
- package/package.json +8 -9
- package/pyproject.toml +5 -5
- package/src/__tests__/agent-tools.unit.test.ts +257 -0
- package/src/__tests__/all-tools.integration.test.ts +321 -75
- package/src/__tests__/config.unit.test.ts +33 -0
- package/src/__tests__/docker.integration.test.ts +91 -7
- package/src/__tests__/documentation-fetch-security.integration.test.ts +8 -2
- package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
- package/src/__tests__/http-security-origin.unit.test.ts +97 -0
- package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
- package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
- package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
- package/src/__tests__/http-server-origin.integration.test.ts +169 -0
- package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
- package/src/__tests__/http-transport.integration.test.ts +85 -87
- package/src/__tests__/integration.integration.test.ts +7 -11
- package/src/__tests__/prompt-response-types.unit.test.ts +51 -0
- package/src/__tests__/prompts.integration.test.ts +736 -0
- package/src/__tests__/run-plan-tools.unit.test.ts +565 -0
- package/src/__tests__/scenario-tools.unit.test.ts +19 -0
- package/src/__tests__/simulation-run-tools.unit.test.ts +116 -0
- package/src/__tests__/support/http-server-harness.ts +138 -0
- package/src/__tests__/test-suite-tools.unit.test.ts +615 -0
- package/src/__tests__/{tools.unit.test.ts → tools.integration.test.ts} +31 -59
- package/src/config.ts +17 -2
- package/src/create-mcp-server.ts +334 -55
- package/src/http-security.ts +492 -0
- package/src/http-server.ts +536 -152
- package/src/index.ts +49 -2
- package/src/langwatch-api-agents.ts +196 -12
- package/src/langwatch-api-run-plans.ts +157 -0
- package/src/langwatch-api-scenarios.ts +9 -0
- package/src/langwatch-api-simulation-runs.ts +5 -0
- package/src/langwatch-api-test-suites.ts +135 -0
- package/src/langwatch-api.ts +62 -12
- package/src/schemas/index.ts +5 -0
- package/src/schemas/run-plan.ts +92 -0
- package/src/schemas/suite-fields.ts +149 -0
- package/src/tools/archive-run-plan.ts +12 -0
- package/src/tools/archive-test-suite.ts +12 -0
- package/src/tools/create-scenario.ts +8 -0
- package/src/tools/create-test-suite.ts +43 -0
- package/src/tools/discover-evaluator-schema.ts +2 -2
- package/src/tools/discover-scenario-schema.ts +54 -0
- package/src/tools/format-run-plan.ts +68 -0
- package/src/tools/format-scenario.ts +14 -0
- package/src/tools/format-suite-details.ts +96 -0
- package/src/tools/get-agent.ts +45 -3
- package/src/tools/get-prompt.ts +108 -24
- package/src/tools/get-run-plan.ts +62 -0
- package/src/tools/get-scenario.ts +3 -0
- package/src/tools/get-simulation-run.ts +9 -0
- package/src/tools/get-test-suite.ts +49 -0
- package/src/tools/list-agents.ts +20 -3
- package/src/tools/list-run-plans.ts +47 -0
- package/src/tools/list-scenarios.ts +12 -2
- package/src/tools/list-simulation-runs.ts +6 -2
- package/src/tools/list-test-suites.ts +35 -0
- package/src/tools/rename-test-suite.ts +18 -0
- package/src/tools/rerun-run-plan.ts +21 -0
- package/src/tools/run-agent.ts +54 -6
- package/src/tools/run-plan.ts +49 -0
- package/src/tools/run-test-suite.ts +30 -0
- package/src/tools/test-agent.ts +18 -0
- package/src/tools/update-prompt.ts +195 -10
- package/src/tools/update-scenario.ts +8 -0
- package/src/tools/update-test-suite.ts +45 -0
- package/tsconfig.json +7 -2
- package/uv.lock +418 -298
- package/dist/archive-suite-C3KX6IIZ.js +0 -16
- package/dist/chunk-WDH5P5RF.js +0 -48
- package/dist/create-suite-TWF6BBR4.js +0 -39
- package/dist/get-agent-7QJVPH72.js +0 -29
- package/dist/get-prompt-I753LZKZ.js +0 -46
- package/dist/get-suite-GTOCY5PL.js +0 -45
- package/dist/langwatch-api-workflows-5HRZ7HR4.js +0 -16
- package/dist/list-agents-TIJTP566.js +0 -33
- package/dist/list-suites-5VK4HXEK.js +0 -43
- package/dist/report-issue-DXJ6TPTC.js +0 -412
- package/dist/run-agent-RBSIFKRT.js +0 -37
- package/dist/run-suite-K3DNQ3SE.js +0 -30
- package/dist/update-prompt-RQFTEO7Y.js +0 -25
- package/dist/update-suite-TRGGLNR7.js +0 -40
- package/pnpm-workspace.yaml +0 -34
- package/src/langwatch-api-suites.ts +0 -118
- package/src/tools/archive-suite.ts +0 -12
- package/src/tools/create-suite.ts +0 -42
- package/src/tools/get-suite.ts +0 -47
- package/src/tools/list-suites.ts +0 -43
- package/src/tools/run-suite.ts +0 -28
- 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
|
+
}
|