@langwatch/mcp-server 1.0.0 → 2.0.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 +21 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +55 -1
- package/dist/{archive-project-MK3673A7.js → archive-project-4EQGLBJJ.js} +5 -5
- package/dist/{archive-scenario-KATSXRIN.js → archive-scenario-W6BXYDZ3.js} +5 -5
- package/dist/{archive-suite-C3KX6IIZ.js → archive-suite-4T6BSRS7.js} +5 -5
- package/dist/{assign-prompt-tag-E3C5W6PI.js → assign-prompt-tag-VLXUWEKU.js} +4 -4
- package/dist/{chunk-VYPSKRPN.js → chunk-4QC7SA2L.js} +91 -91
- package/dist/{chunk-T7WJVS2N.js → chunk-5CCZFUXS.js} +2 -2
- package/dist/{chunk-J45SVWPR.js → chunk-77BNWQ3U.js} +2 -2
- package/dist/{chunk-XY4STOBE.js → chunk-7RHG56OH.js} +2 -2
- package/dist/{chunk-5UWY24RP.js → chunk-A2LEVYQ5.js} +2 -2
- package/dist/{chunk-UXXA4D6K.js → chunk-AQJO2EI5.js} +2 -2
- package/dist/{chunk-EU22L52K.js → chunk-CDYQOY5U.js} +2 -2
- package/dist/{chunk-O3LWVN67.js → chunk-J42FWRJ2.js} +2 -2
- package/dist/{chunk-BJ7B4R26.js → chunk-SAS5OB7F.js} +18 -2
- package/dist/{chunk-DAIQKS27.js → chunk-SFB4MU6H.js} +2 -2
- package/dist/{chunk-WWMDHZZV.js → chunk-TJCXFNDT.js} +2 -2
- package/dist/{chunk-CE6QMZOP.js → chunk-TOXJQDIZ.js} +4 -4
- package/dist/chunk-UJPXFA26.js +267 -0
- package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
- package/dist/{chunk-RYRB2YVJ.js → chunk-YX3A3CFM.js} +2 -2
- package/dist/{chunk-WDH5P5RF.js → chunk-YZLYHLLI.js} +2 -2
- package/dist/{chunk-Y3HDZ6OW.js → chunk-ZWIL4EFL.js} +2 -2
- package/dist/config.js +2 -2
- package/dist/{create-agent-7W57J7MT.js → create-agent-7XHO6UIS.js} +5 -5
- package/dist/{create-annotation-W2XELBCQ.js → create-annotation-YQXRQC6A.js} +5 -5
- package/dist/{create-api-key-MKRXKFJM.js → create-api-key-YUS36KBU.js} +5 -5
- package/dist/{create-dashboard-QGL7IODC.js → create-dashboard-4Z5JO5LB.js} +5 -5
- package/dist/{create-dataset-3PH7A4HG.js → create-dataset-E6NMSVX7.js} +5 -5
- package/dist/{create-dataset-records-4DZS3VRL.js → create-dataset-records-SKHPRD2K.js} +5 -5
- package/dist/{create-evaluator-YWH6NC4M.js → create-evaluator-JXF5PF33.js} +5 -5
- package/dist/create-mcp-server.js +4 -4
- package/dist/{create-project-BOBQ32C5.js → create-project-CS2CB57Y.js} +5 -5
- package/dist/{create-prompt-FLCFGITE.js → create-prompt-F5RMX3L3.js} +4 -4
- package/dist/{create-prompt-tag-EMPNIKEA.js → create-prompt-tag-GMRWTEFR.js} +4 -4
- package/dist/{create-scenario-BSBIHUL6.js → create-scenario-JDR2VB2H.js} +5 -5
- package/dist/{create-suite-TWF6BBR4.js → create-suite-LZS6QIAF.js} +5 -5
- package/dist/{delete-agent-7MSYNY5E.js → delete-agent-O3EEH4S5.js} +5 -5
- package/dist/{delete-annotation-ZT5IGN24.js → delete-annotation-XP6FQM7A.js} +5 -5
- package/dist/{delete-dashboard-BTIY5S3L.js → delete-dashboard-RFUNYCNP.js} +5 -5
- package/dist/{delete-dataset-U45HQHPR.js → delete-dataset-3MJFRDLP.js} +5 -5
- package/dist/{delete-dataset-records-R43IMAWB.js → delete-dataset-records-WPMU5GL5.js} +5 -5
- package/dist/{delete-evaluator-U6JMQ6C7.js → delete-evaluator-33SP2JFX.js} +5 -5
- package/dist/{delete-prompt-tag-4EV4TDHB.js → delete-prompt-tag-KNMZVSPB.js} +4 -4
- package/dist/{delete-workflow-5QYALXEL.js → delete-workflow-3NUXO46U.js} +5 -5
- package/dist/{discover-evaluator-schema-MRGHVNTT.js → discover-evaluator-schema-XTAD4X7D.js} +52 -36
- package/dist/{discover-scenario-schema-UPBTCWPG.js → discover-scenario-schema-FE64QK2D.js} +2 -2
- package/dist/{discover-schema-ICGXPHUW.js → discover-schema-L6QDPON3.js} +2 -2
- package/dist/{get-agent-7QJVPH72.js → get-agent-Y7MWZ65F.js} +5 -5
- package/dist/{get-analytics-PVERWOA5.js → get-analytics-TETCSOK6.js} +4 -4
- package/dist/{get-dashboard-ZZPC3WMD.js → get-dashboard-KHBEGZGL.js} +5 -5
- package/dist/{get-dataset-25TB2LQH.js → get-dataset-RCMU5VAH.js} +5 -5
- package/dist/{get-evaluator-3FTRQKEC.js → get-evaluator-ADMKVWYZ.js} +5 -5
- package/dist/{get-project-5OAXYC57.js → get-project-RAK7URQJ.js} +5 -5
- package/dist/{get-prompt-I753LZKZ.js → get-prompt-OY5B7TRI.js} +4 -4
- package/dist/{get-scenario-DSSTR6FI.js → get-scenario-ZTAJJL5T.js} +5 -5
- package/dist/{get-simulation-run-RQPMS2MC.js → get-simulation-run-T2ZQWYH2.js} +5 -5
- package/dist/{get-suite-GTOCY5PL.js → get-suite-J7JVWO6O.js} +5 -5
- package/dist/{get-trace-O5VT3NAC.js → get-trace-K7U234Q4.js} +4 -4
- package/dist/{get-workflow-3EZDOEIF.js → get-workflow-L5L2PSK3.js} +5 -5
- package/dist/http-security-EU3CHTY7.js +26 -0
- package/dist/{http-server-2ACJ3CXW.js → http-server-YXHFPPGA.js} +1033 -693
- package/dist/index.js +3066 -2105
- package/dist/{langwatch-api-annotations-E65MI3WZ.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -5
- package/dist/{langwatch-api-dashboards-QCDECRCY.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -5
- package/dist/{langwatch-api-monitors-UIO2KTDV.js → langwatch-api-monitors-3J7UIQME.js} +4 -4
- package/dist/{langwatch-api-secrets-4T2EKSJ7.js → langwatch-api-secrets-BUBSNCNO.js} +4 -4
- package/dist/{langwatch-api-triggers-MPOEKPUY.js → langwatch-api-triggers-EDFUCJN6.js} +4 -4
- package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
- package/dist/{list-agents-TIJTP566.js → list-agents-KFHKGPXV.js} +5 -5
- package/dist/{list-annotations-CGX5BT7E.js → list-annotations-ZTVP4EEL.js} +5 -5
- package/dist/{list-api-keys-JLWJPFRX.js → list-api-keys-CWD4UQT5.js} +5 -5
- package/dist/{list-dashboards-RNIWX3TX.js → list-dashboards-PWG6P6KR.js} +5 -5
- package/dist/{list-dataset-records-B5MHZR66.js → list-dataset-records-IOE7LQXS.js} +5 -5
- package/dist/{list-datasets-BVCAMQCP.js → list-datasets-ZT4JYUDT.js} +5 -5
- package/dist/{list-evaluators-65FBTL2U.js → list-evaluators-SJHC7VBH.js} +5 -5
- package/dist/{list-model-providers-GRK3I3VV.js → list-model-providers-PPD2WPD2.js} +5 -5
- package/dist/{list-projects-JLMBFMW3.js → list-projects-K7QAIX2I.js} +5 -5
- package/dist/{list-prompt-tags-UHNWDGQI.js → list-prompt-tags-FOO6TNHB.js} +4 -4
- package/dist/{list-prompts-7254B3U7.js → list-prompts-SC2HAUFG.js} +4 -4
- package/dist/{list-scenarios-46YNICKM.js → list-scenarios-F2WYNRIE.js} +5 -5
- package/dist/{list-simulation-runs-LGNEFNPN.js → list-simulation-runs-KF5THG5H.js} +5 -5
- package/dist/{list-suites-5VK4HXEK.js → list-suites-CRY4VWW6.js} +5 -5
- package/dist/{list-workflows-MLQDM6CP.js → list-workflows-QSJWURCK.js} +5 -5
- package/dist/{rename-prompt-tag-RHQMNEQL.js → rename-prompt-tag-EKWU5QF3.js} +4 -4
- package/dist/{report-issue-DXJ6TPTC.js → report-issue-XYVAPAYJ.js} +7 -7
- package/dist/{revoke-api-key-3Y35EMBT.js → revoke-api-key-FTRTZMOF.js} +5 -5
- package/dist/{run-agent-RBSIFKRT.js → run-agent-6SM4XNA7.js} +5 -5
- package/dist/{run-suite-K3DNQ3SE.js → run-suite-NKZCJ2IN.js} +5 -5
- package/dist/{run-workflow-5LVBZYLS.js → run-workflow-QPXC627O.js} +5 -5
- package/dist/{search-traces-U4HLOCZ2.js → search-traces-A6HFKPLJ.js} +4 -4
- package/dist/{set-model-provider-V3M5CDIY.js → set-model-provider-3WKT63ZN.js} +5 -5
- package/dist/{update-agent-47RZIHS7.js → update-agent-SMAEHLXH.js} +5 -5
- package/dist/{update-dataset-G6QJJGS5.js → update-dataset-BDUK7TUO.js} +5 -5
- package/dist/{update-dataset-record-RMWLSWCS.js → update-dataset-record-4ZSDRBSW.js} +5 -5
- package/dist/{update-evaluator-SFRRI5Q3.js → update-evaluator-LS6TIBY7.js} +5 -5
- package/dist/{update-project-PI5DWBVK.js → update-project-LKVYXENH.js} +5 -5
- package/dist/{update-prompt-RQFTEO7Y.js → update-prompt-ODCRQRN6.js} +4 -4
- package/dist/{update-scenario-G3IU2UIB.js → update-scenario-NEXCAQI7.js} +5 -5
- package/dist/{update-suite-TRGGLNR7.js → update-suite-SNO3GS7T.js} +5 -5
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- 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__/support/http-server-harness.ts +138 -0
- package/src/config.ts +17 -2
- package/src/http-security.ts +492 -0
- package/src/http-server.ts +536 -152
- package/src/index.ts +49 -2
- package/src/tools/discover-evaluator-schema.ts +2 -2
- package/tsconfig.json +2 -2
- package/uv.lock +2 -2
- package/dist/langwatch-api-workflows-5HRZ7HR4.js +0 -16
- package/pnpm-workspace.yaml +0 -34
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { admitOAuthToken, createSessionStore } from "../http-security.js";
|
|
4
|
+
|
|
5
|
+
describe("createSessionStore", () => {
|
|
6
|
+
function makeStore(maxAgeMs = 60_000) {
|
|
7
|
+
const closed: string[] = [];
|
|
8
|
+
const store = createSessionStore<{ id: string }>({
|
|
9
|
+
maxAgeMs,
|
|
10
|
+
closeTransport: (transport) => closed.push(transport.id),
|
|
11
|
+
});
|
|
12
|
+
return { store, closed };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
it("stores and returns sessions bound to their key", () => {
|
|
16
|
+
const { store } = makeStore();
|
|
17
|
+
store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
|
|
18
|
+
|
|
19
|
+
expect(store.get("s1")?.apiKey).toBe("sk-a");
|
|
20
|
+
expect(store.size).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("counts live sessions per key without holding the raw key", () => {
|
|
24
|
+
const { store } = makeStore();
|
|
25
|
+
store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
|
|
26
|
+
store.add({ sessionId: "s2", transport: { id: "s2" }, apiKey: "sk-a" });
|
|
27
|
+
store.add({ sessionId: "s3", transport: { id: "s3" }, apiKey: "sk-b" });
|
|
28
|
+
|
|
29
|
+
expect(store.countForKey("sk-a")).toBe(2);
|
|
30
|
+
expect(store.countForKey("sk-b")).toBe(1);
|
|
31
|
+
expect(store.countForKey("sk-unknown")).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("decrements the per-key count when a session is removed", () => {
|
|
35
|
+
const { store } = makeStore();
|
|
36
|
+
store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
|
|
37
|
+
store.add({ sessionId: "s2", transport: { id: "s2" }, apiKey: "sk-a" });
|
|
38
|
+
|
|
39
|
+
store.remove("s1");
|
|
40
|
+
expect(store.countForKey("sk-a")).toBe(1);
|
|
41
|
+
|
|
42
|
+
store.remove("s2");
|
|
43
|
+
expect(store.countForKey("sk-a")).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("ignores removal of a session it does not hold", () => {
|
|
47
|
+
const { store } = makeStore();
|
|
48
|
+
store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
|
|
49
|
+
|
|
50
|
+
store.remove("never-existed");
|
|
51
|
+
store.remove("never-existed");
|
|
52
|
+
|
|
53
|
+
expect(store.countForKey("sk-a")).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("closes and forgets sessions that have gone idle", () => {
|
|
57
|
+
vi.useFakeTimers();
|
|
58
|
+
try {
|
|
59
|
+
const { store, closed } = makeStore(1_000);
|
|
60
|
+
store.add({ sessionId: "idle", transport: { id: "idle" }, apiKey: "sk-a" });
|
|
61
|
+
store.add({ sessionId: "busy", transport: { id: "busy" }, apiKey: "sk-a" });
|
|
62
|
+
|
|
63
|
+
vi.advanceTimersByTime(900);
|
|
64
|
+
store.touch("busy");
|
|
65
|
+
vi.advanceTimersByTime(900);
|
|
66
|
+
store.sweep();
|
|
67
|
+
|
|
68
|
+
expect(closed).toEqual(["idle"]);
|
|
69
|
+
expect(store.get("idle")).toBeUndefined();
|
|
70
|
+
expect(store.get("busy")).toBeDefined();
|
|
71
|
+
expect(store.countForKey("sk-a")).toBe(1);
|
|
72
|
+
} finally {
|
|
73
|
+
vi.useRealTimers();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("closes everything on shutdown", () => {
|
|
78
|
+
const { store, closed } = makeStore();
|
|
79
|
+
store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
|
|
80
|
+
store.add({ sessionId: "s2", transport: { id: "s2" }, apiKey: "sk-b" });
|
|
81
|
+
|
|
82
|
+
store.closeAll();
|
|
83
|
+
|
|
84
|
+
expect(closed.sort()).toEqual(["s1", "s2"]);
|
|
85
|
+
expect(store.size).toBe(0);
|
|
86
|
+
expect(store.countForKey("sk-a")).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not let a prototype-shaped session id reach the prototype chain", () => {
|
|
90
|
+
const { store } = makeStore();
|
|
91
|
+
|
|
92
|
+
expect(store.get("__proto__")).toBeUndefined();
|
|
93
|
+
expect(store.get("constructor")).toBeUndefined();
|
|
94
|
+
|
|
95
|
+
store.add({ sessionId: "__proto__", transport: { id: "weird" }, apiKey: "sk-a" });
|
|
96
|
+
expect(store.get("__proto__")?.transport.id).toBe("weird");
|
|
97
|
+
expect(store.size).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("session reservations", () => {
|
|
102
|
+
function makeStore() {
|
|
103
|
+
return createSessionStore<{ id: string }>({
|
|
104
|
+
maxAgeMs: 60_000,
|
|
105
|
+
closeTransport: () => undefined,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it("counts a reserved slot before any session exists", () => {
|
|
110
|
+
const store = makeStore();
|
|
111
|
+
store.reserve("sk-a");
|
|
112
|
+
|
|
113
|
+
// The point of the reservation: the cap sees the slot while the session is
|
|
114
|
+
// still being set up.
|
|
115
|
+
expect(store.countForKey("sk-a")).toBe(1);
|
|
116
|
+
expect(store.size).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("hands the reserved slot to the session rather than counting twice", () => {
|
|
120
|
+
const store = makeStore();
|
|
121
|
+
store.reserve("sk-a").commit({ sessionId: "s1", transport: { id: "s1" } });
|
|
122
|
+
|
|
123
|
+
expect(store.countForKey("sk-a")).toBe(1);
|
|
124
|
+
expect(store.get("s1")?.apiKey).toBe("sk-a");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns the slot when the session never came up", () => {
|
|
128
|
+
const store = makeStore();
|
|
129
|
+
const reservation = store.reserve("sk-a");
|
|
130
|
+
reservation.release();
|
|
131
|
+
|
|
132
|
+
expect(store.countForKey("sk-a")).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ignores a release once the slot was committed", () => {
|
|
136
|
+
const store = makeStore();
|
|
137
|
+
const reservation = store.reserve("sk-a");
|
|
138
|
+
reservation.commit({ sessionId: "s1", transport: { id: "s1" } });
|
|
139
|
+
reservation.release();
|
|
140
|
+
|
|
141
|
+
expect(store.countForKey("sk-a")).toBe(1);
|
|
142
|
+
expect(store.get("s1")).toBeDefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("frees the slot when the committed session is removed", () => {
|
|
146
|
+
const store = makeStore();
|
|
147
|
+
store.reserve("sk-a").commit({ sessionId: "s1", transport: { id: "s1" } });
|
|
148
|
+
store.remove("s1");
|
|
149
|
+
|
|
150
|
+
expect(store.countForKey("sk-a")).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("admitOAuthToken", () => {
|
|
155
|
+
function entry(apiKey: string, expiresAt: number) {
|
|
156
|
+
return { apiKey, expiresAt };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it("evicts the oldest token for a key that is at the cap", () => {
|
|
160
|
+
const tokens = new Map([
|
|
161
|
+
["t1", entry("sk-a", Date.now() + 1_000)],
|
|
162
|
+
["t2", entry("sk-a", Date.now() + 2_000)],
|
|
163
|
+
["t3", entry("sk-a", Date.now() + 3_000)],
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 3 });
|
|
167
|
+
|
|
168
|
+
// Room for the caller's new token, and the longest-standing one went.
|
|
169
|
+
expect(tokens.has("t1")).toBe(false);
|
|
170
|
+
expect([...tokens.keys()]).toEqual(["t2", "t3"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("leaves other keys alone", () => {
|
|
174
|
+
const tokens = new Map([
|
|
175
|
+
["t1", entry("sk-a", Date.now() + 1_000)],
|
|
176
|
+
["t2", entry("sk-b", Date.now() + 1_000)],
|
|
177
|
+
["t3", entry("sk-b", Date.now() + 2_000)],
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 1 });
|
|
181
|
+
|
|
182
|
+
expect(tokens.has("t1")).toBe(false);
|
|
183
|
+
expect(tokens.has("t2")).toBe(true);
|
|
184
|
+
expect(tokens.has("t3")).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("keeps a key from growing the map without bound", () => {
|
|
188
|
+
const tokens = new Map<string, { apiKey: string; expiresAt: number }>();
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < 50; i += 1) {
|
|
191
|
+
admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 10 });
|
|
192
|
+
tokens.set(`t${i}`, entry("sk-a", Date.now() + 60_000 + i));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
expect(tokens.size).toBe(10);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("drops expired tokens for any key on the way past", () => {
|
|
199
|
+
const tokens = new Map([
|
|
200
|
+
["stale", entry("sk-b", Date.now() - 1)],
|
|
201
|
+
["live", entry("sk-b", Date.now() + 60_000)],
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 10 });
|
|
205
|
+
|
|
206
|
+
expect(tokens.has("stale")).toBe(false);
|
|
207
|
+
expect(tokens.has("live")).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
apiKeysMatch,
|
|
5
|
+
createApiKeyVerifier,
|
|
6
|
+
createRateLimiter,
|
|
7
|
+
hashApiKey,
|
|
8
|
+
} from "../http-security.js";
|
|
9
|
+
|
|
10
|
+
describe("apiKeysMatch", () => {
|
|
11
|
+
it("matches identical keys", () => {
|
|
12
|
+
expect(apiKeysMatch({ presentedKey: "sk-abc", expectedKey: "sk-abc" })).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects different keys, including different lengths", () => {
|
|
16
|
+
expect(apiKeysMatch({ presentedKey: "sk-abc", expectedKey: "sk-abd" })).toBe(false);
|
|
17
|
+
expect(apiKeysMatch({ presentedKey: "sk-abc", expectedKey: "sk-abc-longer" })).toBe(false);
|
|
18
|
+
expect(apiKeysMatch({ presentedKey: "", expectedKey: "sk-abc" })).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("hashApiKey", () => {
|
|
23
|
+
it("never returns the raw key", () => {
|
|
24
|
+
expect(hashApiKey("sk-secret")).not.toContain("sk-secret");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is stable and distinct per key", () => {
|
|
28
|
+
expect(hashApiKey("a")).toBe(hashApiKey("a"));
|
|
29
|
+
expect(hashApiKey("a")).not.toBe(hashApiKey("b"));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("createRateLimiter", () => {
|
|
34
|
+
it("blocks only after the limit is exceeded", () => {
|
|
35
|
+
const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 3 });
|
|
36
|
+
|
|
37
|
+
expect(limiter.isBlocked("1.2.3.4")).toBe(false);
|
|
38
|
+
limiter.track("1.2.3.4");
|
|
39
|
+
limiter.track("1.2.3.4");
|
|
40
|
+
expect(limiter.isBlocked("1.2.3.4")).toBe(false);
|
|
41
|
+
limiter.track("1.2.3.4");
|
|
42
|
+
expect(limiter.isBlocked("1.2.3.4")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("tracks each IP separately", () => {
|
|
46
|
+
const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 1 });
|
|
47
|
+
|
|
48
|
+
limiter.track("1.1.1.1");
|
|
49
|
+
expect(limiter.isBlocked("1.1.1.1")).toBe(true);
|
|
50
|
+
expect(limiter.isBlocked("2.2.2.2")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("forgets an IP once its window has passed", () => {
|
|
54
|
+
vi.useFakeTimers();
|
|
55
|
+
try {
|
|
56
|
+
const limiter = createRateLimiter({ windowMs: 1_000, maxRequests: 1 });
|
|
57
|
+
limiter.track("1.1.1.1");
|
|
58
|
+
expect(limiter.isBlocked("1.1.1.1")).toBe(true);
|
|
59
|
+
|
|
60
|
+
vi.advanceTimersByTime(1_500);
|
|
61
|
+
expect(limiter.isBlocked("1.1.1.1")).toBe(false);
|
|
62
|
+
} finally {
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function jsonResponse(status: number): Response {
|
|
69
|
+
return new Response(status === 200 ? "{}" : "", { status });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A fetch double that records the URL and init it was called with. */
|
|
73
|
+
function fetchStub(respond: () => Promise<Response>) {
|
|
74
|
+
return vi.fn(async (_url: string, _init?: RequestInit) => respond());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("createApiKeyVerifier", () => {
|
|
78
|
+
it("asks the LangWatch identity endpoint with the key in a header", async () => {
|
|
79
|
+
const fetchImpl = fetchStub(async () => jsonResponse(200));
|
|
80
|
+
const verifier = createApiKeyVerifier({
|
|
81
|
+
endpoint: "https://app.langwatch.ai",
|
|
82
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await expect(verifier.verify("sk-real")).resolves.toBe(true);
|
|
86
|
+
|
|
87
|
+
expect(fetchImpl).toHaveBeenCalledWith(
|
|
88
|
+
"https://app.langwatch.ai/api/me/project",
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
method: "GET",
|
|
91
|
+
headers: { "X-Auth-Token": "sk-real" },
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("never puts the key in the URL", async () => {
|
|
97
|
+
const fetchImpl = fetchStub(async () => jsonResponse(200));
|
|
98
|
+
const verifier = createApiKeyVerifier({
|
|
99
|
+
endpoint: "https://app.langwatch.ai",
|
|
100
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await verifier.verify("sk-real");
|
|
104
|
+
|
|
105
|
+
expect(fetchImpl.mock.calls[0]?.[0]).not.toContain("sk-real");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it.each([401, 403])("rejects a key the API answers %i for", async (status) => {
|
|
109
|
+
const verifier = createApiKeyVerifier({
|
|
110
|
+
endpoint: "https://app.langwatch.ai",
|
|
111
|
+
fetchImpl: (async () => jsonResponse(status)) as unknown as typeof fetch,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(verifier.verify("sk-fake")).resolves.toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("caches a positive answer instead of asking again", async () => {
|
|
118
|
+
const fetchImpl = vi.fn(async () => jsonResponse(200));
|
|
119
|
+
const verifier = createApiKeyVerifier({
|
|
120
|
+
endpoint: "https://app.langwatch.ai",
|
|
121
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await verifier.verify("sk-real");
|
|
125
|
+
await verifier.verify("sk-real");
|
|
126
|
+
await verifier.verify("sk-real");
|
|
127
|
+
|
|
128
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("caches a negative answer so key spraying does not reach the API", async () => {
|
|
132
|
+
const fetchImpl = vi.fn(async () => jsonResponse(401));
|
|
133
|
+
const verifier = createApiKeyVerifier({
|
|
134
|
+
endpoint: "https://app.langwatch.ai",
|
|
135
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await verifier.verify("sk-fake");
|
|
139
|
+
await verifier.verify("sk-fake");
|
|
140
|
+
|
|
141
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("re-asks once the cache entry has expired", async () => {
|
|
145
|
+
vi.useFakeTimers();
|
|
146
|
+
try {
|
|
147
|
+
const fetchImpl = fetchStub(async () => jsonResponse(200));
|
|
148
|
+
const verifier = createApiKeyVerifier({
|
|
149
|
+
endpoint: "https://app.langwatch.ai",
|
|
150
|
+
positiveTtlMs: 1_000,
|
|
151
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await verifier.verify("sk-real");
|
|
155
|
+
vi.advanceTimersByTime(1_500);
|
|
156
|
+
await verifier.verify("sk-real");
|
|
157
|
+
|
|
158
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
159
|
+
} finally {
|
|
160
|
+
vi.useRealTimers();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("collapses concurrent checks of the same key into one request", async () => {
|
|
165
|
+
const fetchImpl = vi.fn(async () => {
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
167
|
+
return jsonResponse(200);
|
|
168
|
+
});
|
|
169
|
+
const verifier = createApiKeyVerifier({
|
|
170
|
+
endpoint: "https://app.langwatch.ai",
|
|
171
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const results = await Promise.all(
|
|
175
|
+
Array.from({ length: 25 }, () => verifier.verify("sk-real"))
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(results.every((result) => result === true)).toBe(true);
|
|
179
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("fails closed when the API cannot answer, without caching the failure", async () => {
|
|
183
|
+
let status = 503;
|
|
184
|
+
const fetchImpl = vi.fn(async () => jsonResponse(status));
|
|
185
|
+
const verifier = createApiKeyVerifier({
|
|
186
|
+
endpoint: "https://app.langwatch.ai",
|
|
187
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await expect(verifier.verify("sk-real")).resolves.toBe(false);
|
|
191
|
+
|
|
192
|
+
status = 200;
|
|
193
|
+
await expect(verifier.verify("sk-real")).resolves.toBe(true);
|
|
194
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("gives up on an upstream that never answers", async () => {
|
|
198
|
+
// Without a timeout the in-flight promise never settles and every request
|
|
199
|
+
// for this key parks behind it until the socket dies.
|
|
200
|
+
const verifier = createApiKeyVerifier({
|
|
201
|
+
endpoint: "https://app.langwatch.ai",
|
|
202
|
+
requestTimeoutMs: 20,
|
|
203
|
+
fetchImpl: ((_url: string, init?: RequestInit) =>
|
|
204
|
+
new Promise((_resolve, reject) => {
|
|
205
|
+
init?.signal?.addEventListener("abort", () =>
|
|
206
|
+
reject(new Error("aborted"))
|
|
207
|
+
);
|
|
208
|
+
})) as unknown as typeof fetch,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await expect(verifier.verify("sk-real")).resolves.toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("fails closed when the request throws", async () => {
|
|
215
|
+
const verifier = createApiKeyVerifier({
|
|
216
|
+
endpoint: "https://app.langwatch.ai",
|
|
217
|
+
fetchImpl: (async () => {
|
|
218
|
+
throw new Error("connection refused");
|
|
219
|
+
}) as unknown as typeof fetch,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await expect(verifier.verify("sk-real")).resolves.toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("evicts old entries rather than growing once maxEntries is reached", async () => {
|
|
226
|
+
const fetchImpl = fetchStub(async () => jsonResponse(401));
|
|
227
|
+
const verifier = createApiKeyVerifier({
|
|
228
|
+
endpoint: "https://app.langwatch.ai",
|
|
229
|
+
maxEntries: 5,
|
|
230
|
+
negativeTtlMs: 60_000,
|
|
231
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < 20; i++) {
|
|
235
|
+
await verifier.verify(`sk-spray-${i}`);
|
|
236
|
+
}
|
|
237
|
+
expect(fetchImpl).toHaveBeenCalledTimes(20);
|
|
238
|
+
|
|
239
|
+
// The newest key is still cached, so it costs nothing to re-check.
|
|
240
|
+
await verifier.verify("sk-spray-19");
|
|
241
|
+
expect(fetchImpl).toHaveBeenCalledTimes(20);
|
|
242
|
+
|
|
243
|
+
// The oldest was evicted to keep the map bounded, so it has to be asked
|
|
244
|
+
// again even though its negative TTL has not passed.
|
|
245
|
+
await verifier.verify("sk-spray-0");
|
|
246
|
+
expect(fetchImpl).toHaveBeenCalledTimes(21);
|
|
247
|
+
});
|
|
248
|
+
});
|