@apifuse/connector-sdk 2.0.0-beta.1

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 (67) hide show
  1. package/README.md +44 -0
  2. package/bin/apifuse-check.ts +408 -0
  3. package/bin/apifuse-dev.ts +222 -0
  4. package/bin/apifuse-init.ts +390 -0
  5. package/bin/apifuse-perf.ts +1101 -0
  6. package/bin/apifuse-record.ts +446 -0
  7. package/bin/apifuse-test.ts +688 -0
  8. package/bin/apifuse.ts +51 -0
  9. package/package.json +64 -0
  10. package/src/__tests__/auth.test.ts +396 -0
  11. package/src/__tests__/browser-auth.test.ts +180 -0
  12. package/src/__tests__/browser.test.ts +632 -0
  13. package/src/__tests__/connectors-yaml.test.ts +135 -0
  14. package/src/__tests__/define.test.ts +225 -0
  15. package/src/__tests__/errors.test.ts +69 -0
  16. package/src/__tests__/executor.test.ts +214 -0
  17. package/src/__tests__/http.test.ts +238 -0
  18. package/src/__tests__/insights.test.ts +210 -0
  19. package/src/__tests__/instrumentation.test.ts +290 -0
  20. package/src/__tests__/otlp.test.ts +141 -0
  21. package/src/__tests__/perf.test.ts +60 -0
  22. package/src/__tests__/proxy.test.ts +359 -0
  23. package/src/__tests__/recipes.test.ts +36 -0
  24. package/src/__tests__/serve.test.ts +233 -0
  25. package/src/__tests__/session.test.ts +231 -0
  26. package/src/__tests__/state.test.ts +100 -0
  27. package/src/__tests__/stealth.test.ts +57 -0
  28. package/src/__tests__/testing.test.ts +97 -0
  29. package/src/__tests__/tls.test.ts +345 -0
  30. package/src/__tests__/types.test.ts +142 -0
  31. package/src/__tests__/utils.test.ts +62 -0
  32. package/src/__tests__/waterfall.test.ts +270 -0
  33. package/src/config/connectors-yaml.ts +373 -0
  34. package/src/config/loader.ts +122 -0
  35. package/src/define.ts +137 -0
  36. package/src/dev.ts +38 -0
  37. package/src/errors.ts +68 -0
  38. package/src/index.test.ts +1 -0
  39. package/src/index.ts +100 -0
  40. package/src/protocol.ts +183 -0
  41. package/src/recipes/gov-api.ts +97 -0
  42. package/src/recipes/rest-api.ts +152 -0
  43. package/src/runtime/auth.ts +245 -0
  44. package/src/runtime/browser.ts +724 -0
  45. package/src/runtime/connector.ts +20 -0
  46. package/src/runtime/executor.ts +51 -0
  47. package/src/runtime/http.ts +248 -0
  48. package/src/runtime/insights.ts +456 -0
  49. package/src/runtime/instrumentation.ts +424 -0
  50. package/src/runtime/otlp.ts +171 -0
  51. package/src/runtime/perf.ts +73 -0
  52. package/src/runtime/session.ts +573 -0
  53. package/src/runtime/state.ts +124 -0
  54. package/src/runtime/tls.ts +410 -0
  55. package/src/runtime/trace.ts +261 -0
  56. package/src/runtime/waterfall.ts +245 -0
  57. package/src/serve.ts +665 -0
  58. package/src/stealth/profiles.ts +391 -0
  59. package/src/testing/helpers.ts +144 -0
  60. package/src/testing/index.ts +2 -0
  61. package/src/testing/run.ts +88 -0
  62. package/src/types/playwright-stealth.d.ts +9 -0
  63. package/src/types.ts +243 -0
  64. package/src/utils/date.ts +163 -0
  65. package/src/utils/parse.ts +66 -0
  66. package/src/utils/text.ts +20 -0
  67. package/src/utils/transform.ts +62 -0
@@ -0,0 +1,359 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { loadApiFuseConfig, resolveProxyConfig } from "../config/loader";
7
+ import { createHttpClient } from "../runtime/http";
8
+
9
+ type MockSessionResponse = {
10
+ status: number;
11
+ body: string;
12
+ headers?: Record<string, string>;
13
+ rawHeaders?: Record<string, string>;
14
+ usedProtocol?: string;
15
+ };
16
+
17
+ type MockSessionState = {
18
+ getCalls: Array<{ url: string; options?: Record<string, unknown> }>;
19
+ options: Record<string, unknown>;
20
+ responses: MockSessionResponse[];
21
+ closed: boolean;
22
+ proxyUrl?: string;
23
+ get: (
24
+ url: string,
25
+ options?: Record<string, unknown>,
26
+ ) => Promise<MockSessionResponse>;
27
+ close: () => void;
28
+ destroySession: () => Promise<void>;
29
+ post: (
30
+ url: string,
31
+ body: string | Buffer | null,
32
+ options?: Record<string, unknown>,
33
+ ) => Promise<MockSessionResponse>;
34
+ put: (
35
+ url: string,
36
+ body: string | Buffer | null,
37
+ options?: Record<string, unknown>,
38
+ ) => Promise<MockSessionResponse>;
39
+ patch: (
40
+ url: string,
41
+ body: string | Buffer | null,
42
+ options?: Record<string, unknown>,
43
+ ) => Promise<MockSessionResponse>;
44
+ delete: (
45
+ url: string,
46
+ options?: Record<string, unknown>,
47
+ ) => Promise<MockSessionResponse>;
48
+ head: (
49
+ url: string,
50
+ options?: Record<string, unknown>,
51
+ ) => Promise<MockSessionResponse>;
52
+ optionsMethod: (
53
+ url: string,
54
+ options?: Record<string, unknown>,
55
+ ) => Promise<MockSessionResponse>;
56
+ };
57
+
58
+ const tlsState = {
59
+ sessions: [] as MockSessionState[],
60
+ queuedResponses: [] as MockSessionResponse[],
61
+ moduleClients: 0,
62
+ };
63
+
64
+ class MockModuleClient {
65
+ constructor() {
66
+ tlsState.moduleClients += 1;
67
+ }
68
+ }
69
+
70
+ class MockSessionClient {
71
+ private readonly state: MockSessionState;
72
+
73
+ constructor(
74
+ _moduleClient: MockModuleClient,
75
+ options: Record<string, unknown>,
76
+ ) {
77
+ this.state = {
78
+ closed: false,
79
+ getCalls: [],
80
+ options,
81
+ proxyUrl:
82
+ typeof options.proxyUrl === "string" ? options.proxyUrl : undefined,
83
+ responses: tlsState.queuedResponses.splice(0),
84
+ get: async (url: string, requestOptions?: Record<string, unknown>) => {
85
+ this.state.getCalls.push({ url, options: requestOptions });
86
+ const response = this.state.responses.shift();
87
+ if (!response) {
88
+ throw new Error("No queued response for GET");
89
+ }
90
+ return response;
91
+ },
92
+ post: async () => {
93
+ throw new Error("POST not implemented in proxy test mock");
94
+ },
95
+ put: async () => {
96
+ throw new Error("PUT not implemented in proxy test mock");
97
+ },
98
+ patch: async () => {
99
+ throw new Error("PATCH not implemented in proxy test mock");
100
+ },
101
+ delete: async () => {
102
+ throw new Error("DELETE not implemented in proxy test mock");
103
+ },
104
+ head: async () => {
105
+ throw new Error("HEAD not implemented in proxy test mock");
106
+ },
107
+ optionsMethod: async () => {
108
+ throw new Error("OPTIONS not implemented in proxy test mock");
109
+ },
110
+ close: () => {
111
+ this.state.closed = true;
112
+ },
113
+ destroySession: async () => {
114
+ this.state.closed = true;
115
+ },
116
+ };
117
+
118
+ tlsState.sessions.push(this.state);
119
+ }
120
+
121
+ async get(url: string, options?: Record<string, unknown>) {
122
+ return this.state.get(url, options);
123
+ }
124
+
125
+ async post(
126
+ url: string,
127
+ body: string | Buffer | null,
128
+ options?: Record<string, unknown>,
129
+ ) {
130
+ return this.state.post(url, body, options);
131
+ }
132
+
133
+ async put(
134
+ url: string,
135
+ body: string | Buffer | null,
136
+ options?: Record<string, unknown>,
137
+ ) {
138
+ return this.state.put(url, body, options);
139
+ }
140
+
141
+ async patch(
142
+ url: string,
143
+ body: string | Buffer | null,
144
+ options?: Record<string, unknown>,
145
+ ) {
146
+ return this.state.patch(url, body, options);
147
+ }
148
+
149
+ async delete(url: string, options?: Record<string, unknown>) {
150
+ return this.state.delete(url, options);
151
+ }
152
+
153
+ async head(url: string, options?: Record<string, unknown>) {
154
+ return this.state.head(url, options);
155
+ }
156
+
157
+ async options(url: string, options?: Record<string, unknown>) {
158
+ return this.state.optionsMethod(url, options);
159
+ }
160
+
161
+ close() {
162
+ this.state.close();
163
+ }
164
+
165
+ async destroySession() {
166
+ await this.state.destroySession();
167
+ }
168
+ }
169
+
170
+ mock.module("tlsclientwrapper", () => ({
171
+ ModuleClient: MockModuleClient,
172
+ SessionClient: MockSessionClient,
173
+ }));
174
+
175
+ describe("proxy integration", () => {
176
+ let originalFetch: typeof fetch;
177
+ let originalProxyEnv: string | undefined;
178
+
179
+ beforeEach(() => {
180
+ originalFetch = global.fetch;
181
+ originalProxyEnv = process.env.APIFUSE_PROXY_URL;
182
+ delete process.env.APIFUSE_PROXY_URL;
183
+ tlsState.sessions.length = 0;
184
+ tlsState.queuedResponses.length = 0;
185
+ tlsState.moduleClients = 0;
186
+ });
187
+
188
+ afterEach(() => {
189
+ global.fetch = originalFetch;
190
+ if (originalProxyEnv) {
191
+ process.env.APIFUSE_PROXY_URL = originalProxyEnv;
192
+ } else {
193
+ delete process.env.APIFUSE_PROXY_URL;
194
+ }
195
+ });
196
+
197
+ it("uses apifuse config proxy when upstream proxy routing is enabled", async () => {
198
+ let capturedProxy: string | undefined;
199
+
200
+ global.fetch = (async (_url, init) => {
201
+ capturedProxy = (init as RequestInit & { proxy?: string })?.proxy;
202
+ return new Response(JSON.stringify({ ok: true }), {
203
+ status: 200,
204
+ headers: { "Content-Type": "application/json" },
205
+ });
206
+ }) as typeof fetch;
207
+
208
+ const http = createHttpClient("https://example.com", {
209
+ apifuseConfig: { proxy: { url: "https://config-proxy.example:8443" } },
210
+ upstream: { proxy: true },
211
+ });
212
+
213
+ await http.get("/health");
214
+
215
+ expect(capturedProxy).toBe("https://config-proxy.example:8443");
216
+ });
217
+
218
+ it("uses APIFUSE_PROXY_URL when upstream proxy routing is enabled", async () => {
219
+ let capturedProxy: string | undefined;
220
+ process.env.APIFUSE_PROXY_URL = "https://env-proxy.example:8443";
221
+
222
+ global.fetch = (async (_url, init) => {
223
+ capturedProxy = (init as RequestInit & { proxy?: string })?.proxy;
224
+ return new Response(JSON.stringify({ ok: true }), {
225
+ status: 200,
226
+ headers: { "Content-Type": "application/json" },
227
+ });
228
+ }) as typeof fetch;
229
+
230
+ const http = createHttpClient("https://example.com", {
231
+ upstream: { proxy: true },
232
+ });
233
+
234
+ await http.get("/health");
235
+
236
+ expect(capturedProxy).toBe("https://env-proxy.example:8443");
237
+ });
238
+
239
+ it("passes request-level proxy through ctx.http", async () => {
240
+ let capturedProxy: string | undefined;
241
+
242
+ global.fetch = (async (_url, init) => {
243
+ capturedProxy = (init as RequestInit & { proxy?: string })?.proxy;
244
+ return new Response(JSON.stringify({ ok: true }), {
245
+ status: 200,
246
+ headers: { "Content-Type": "application/json" },
247
+ });
248
+ }) as typeof fetch;
249
+
250
+ const http = createHttpClient("https://example.com");
251
+
252
+ await http.get("/health", { proxy: "https://request-proxy.example:8443" });
253
+
254
+ expect(capturedProxy).toBe("https://request-proxy.example:8443");
255
+ });
256
+
257
+ it("warns once when proxy routing is required but missing", async () => {
258
+ const warnings: string[] = [];
259
+
260
+ global.fetch = (async () =>
261
+ new Response(JSON.stringify({ ok: true }), {
262
+ status: 200,
263
+ headers: { "Content-Type": "application/json" },
264
+ })) as unknown as typeof fetch;
265
+
266
+ const http = createHttpClient("https://example.com", {
267
+ upstream: { proxy: true },
268
+ warn: (message) => {
269
+ warnings.push(message);
270
+ },
271
+ });
272
+
273
+ await http.get("/health");
274
+ await http.get("/health");
275
+
276
+ expect(warnings).toEqual([
277
+ "[connector-sdk] Connector requested proxy routing, but no proxy URL was configured. Continuing without proxy.",
278
+ ]);
279
+ });
280
+
281
+ it("passes resolved proxy through ctx.tls session config and request options", async () => {
282
+ tlsState.queuedResponses.push({
283
+ status: 200,
284
+ body: "ok",
285
+ headers: { "content-type": "text/plain" },
286
+ });
287
+
288
+ const { createTlsClient } = await import("../runtime/tls");
289
+ const client = createTlsClient("https://example.com", {
290
+ apifuseConfig: { proxy: { url: "https://tls-proxy.example:8443" } },
291
+ upstream: { proxy: true },
292
+ });
293
+
294
+ await client.fetch("/health");
295
+
296
+ expect(tlsState.sessions[0]?.options).toMatchObject({
297
+ proxyUrl: "https://tls-proxy.example:8443",
298
+ });
299
+ expect(tlsState.sessions[0]?.getCalls[0]?.options).toMatchObject({
300
+ proxy: "https://tls-proxy.example:8443",
301
+ });
302
+ });
303
+
304
+ it("passes request-level proxy through ctx.tls", async () => {
305
+ tlsState.queuedResponses.push({
306
+ status: 200,
307
+ body: "ok",
308
+ headers: { "content-type": "text/plain" },
309
+ });
310
+
311
+ const { createTlsClient } = await import("../runtime/tls");
312
+ const client = createTlsClient("https://example.com");
313
+
314
+ await client.fetch("/health", {
315
+ proxy: "https://request-tls-proxy.example:8443",
316
+ });
317
+
318
+ expect(tlsState.sessions[0]?.options).toMatchObject({
319
+ proxyUrl: "https://request-tls-proxy.example:8443",
320
+ });
321
+ expect(tlsState.sessions[0]?.getCalls[0]?.options).toMatchObject({
322
+ proxy: "https://request-tls-proxy.example:8443",
323
+ });
324
+ });
325
+
326
+ it("resolves proxy config from env before config", () => {
327
+ process.env.APIFUSE_PROXY_URL = "https://env-proxy.example:8443";
328
+
329
+ expect(
330
+ resolveProxyConfig({
331
+ apifuseConfig: { proxy: { url: "https://config-proxy.example:8443" } },
332
+ upstream: { proxy: true },
333
+ }),
334
+ ).toEqual({
335
+ shouldWarn: false,
336
+ url: "https://env-proxy.example:8443",
337
+ });
338
+ });
339
+
340
+ it("hydrates APIFUSE_PROXY_URL from apifuse.config.ts when env is unset", async () => {
341
+ const directory = await mkdtemp(path.join(tmpdir(), "apifuse-proxy-"));
342
+
343
+ await Bun.write(
344
+ `${directory}/apifuse.config.ts`,
345
+ [
346
+ "export default {",
347
+ " proxy: { url: 'https://file-proxy.example:8443' },",
348
+ "};",
349
+ ].join("\n"),
350
+ );
351
+
352
+ const config = await loadApiFuseConfig(directory);
353
+
354
+ expect(config.proxy?.url).toBe("https://file-proxy.example:8443");
355
+ expect(process.env.APIFUSE_PROXY_URL).toBe(
356
+ "https://file-proxy.example:8443",
357
+ );
358
+ });
359
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { extractPagination, normalizeErrorResponse } from "../recipes/rest-api";
4
+
5
+ describe("rest-api recipe", () => {
6
+ it("extracts pagination from standard shape", () => {
7
+ expect(extractPagination({ page: 1, per_page: 10, total: 100 })).toEqual({
8
+ page: 1,
9
+ perPage: 10,
10
+ total: 100,
11
+ totalPages: 10,
12
+ hasNext: true,
13
+ hasPrev: false,
14
+ });
15
+ });
16
+
17
+ it("returns null for missing pagination shape", () => {
18
+ expect(extractPagination({ foo: "bar" })).toBeNull();
19
+ });
20
+
21
+ it("normalizes error response from string error", () => {
22
+ expect(normalizeErrorResponse({ error: "Not found" })).toEqual({
23
+ message: "Not found",
24
+ });
25
+ });
26
+
27
+ it("normalizes error response from message field", () => {
28
+ expect(normalizeErrorResponse({ message: "Forbidden" })).toEqual({
29
+ message: "Forbidden",
30
+ });
31
+ });
32
+
33
+ it("returns null for empty error object", () => {
34
+ expect(normalizeErrorResponse({})).toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { z } from "zod";
3
+
4
+ import { createConnectorServer } from "../serve";
5
+ import type { ConnectorDefinition } from "../types";
6
+
7
+ process.env.APIFUSE_STATE_SECRET = "test-secret";
8
+
9
+ function createTestConnector() {
10
+ return {
11
+ id: "test-connector",
12
+ version: "1.0.0",
13
+ runtime: "standard",
14
+ meta: {
15
+ displayName: "Test Connector",
16
+ category: "test",
17
+ },
18
+ auth: {
19
+ mode: "credentials",
20
+ fields: [
21
+ { name: "username", label: "Username", type: "text", required: true },
22
+ { name: "otp", label: "One-time code", type: "otp", deferred: true },
23
+ ],
24
+ exchange: async (ctx, credentials) => {
25
+ await ctx.session.set("username", credentials.username ?? "unknown");
26
+ const otp = await ctx.auth.requestField("otp");
27
+ await ctx.session.set("otp", otp);
28
+ await ctx.session.set(
29
+ "accessToken",
30
+ `token:${credentials.username}:${otp}`,
31
+ );
32
+ },
33
+ refresh: async (ctx) => {
34
+ const token = await ctx.session.get("accessToken");
35
+ await ctx.session.set("accessToken", `${token ?? "token"}:refreshed`);
36
+ },
37
+ disconnect: async (ctx) => {
38
+ await ctx.session.delete("accessToken");
39
+ },
40
+ },
41
+ operations: {
42
+ echo: {
43
+ description: "Echo input",
44
+ input: z.object({ value: z.string() }),
45
+ output: z.object({ echoed: z.string() }),
46
+ hints: { rateLimit: "low" },
47
+ handler: async (ctx, input) => {
48
+ const typedInput = input as { value: string };
49
+ const count = Number((await ctx.session.get("count")) ?? "0") + 1;
50
+ await ctx.session.set("count", String(count));
51
+ return { echoed: typedInput.value };
52
+ },
53
+ },
54
+ },
55
+ } satisfies ConnectorDefinition;
56
+ }
57
+
58
+ describe("createConnectorServer", () => {
59
+ const connector = createTestConnector();
60
+ const { app } = createConnectorServer(connector, {
61
+ stateSecret: process.env.APIFUSE_STATE_SECRET,
62
+ });
63
+
64
+ it("executes an operation and returns sessionPatch", async () => {
65
+ const response = await app.request("/execute/echo", {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json" },
68
+ body: JSON.stringify({
69
+ input: { value: "hello" },
70
+ session: { count: "1" },
71
+ }),
72
+ });
73
+
74
+ expect(response.status).toBe(200);
75
+ expect(await response.json()).toEqual({
76
+ data: { echoed: "hello" },
77
+ sessionPatch: { count: "2" },
78
+ trace: {
79
+ spans: [
80
+ expect.objectContaining({
81
+ name: "handler:echo",
82
+ status: "ok",
83
+ }),
84
+ ],
85
+ },
86
+ });
87
+ });
88
+
89
+ it("returns pending_field for deferred auth", async () => {
90
+ const response = await app.request("/connect", {
91
+ method: "POST",
92
+ headers: { "content-type": "application/json" },
93
+ body: JSON.stringify({ credentials: { username: "demo" } }),
94
+ });
95
+
96
+ expect(response.status).toBe(200);
97
+ expect(await response.json()).toEqual({
98
+ status: "pending_field",
99
+ field: "otp",
100
+ fieldLabel: "One-time code",
101
+ resumeToken: expect.any(String),
102
+ sessionPatch: { username: "demo" },
103
+ });
104
+ });
105
+
106
+ it("resumes deferred auth and returns success with sessionPatch", async () => {
107
+ const connectResponse = await app.request("/connect", {
108
+ method: "POST",
109
+ headers: { "content-type": "application/json" },
110
+ body: JSON.stringify({ credentials: { username: "demo" } }),
111
+ });
112
+ const connectBody = (await connectResponse.json()) as {
113
+ status: string;
114
+ resumeToken: string;
115
+ sessionPatch: Record<string, string>;
116
+ };
117
+
118
+ const resumeResponse = await app.request("/connect/resume", {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json" },
121
+ body: JSON.stringify({
122
+ resumeToken: connectBody.resumeToken,
123
+ fieldValue: "123456",
124
+ session: connectBody.sessionPatch,
125
+ }),
126
+ });
127
+
128
+ expect(resumeResponse.status).toBe(200);
129
+ expect(await resumeResponse.json()).toEqual({
130
+ status: "success",
131
+ sessionPatch: {
132
+ username: "demo",
133
+ otp: "123456",
134
+ accessToken: "token:demo:123456",
135
+ __auth__: expect.any(String),
136
+ },
137
+ });
138
+ });
139
+
140
+ it("refreshes auth session", async () => {
141
+ const response = await app.request("/refresh", {
142
+ method: "POST",
143
+ headers: { "content-type": "application/json" },
144
+ body: JSON.stringify({
145
+ session: { accessToken: "token:demo:123456" },
146
+ }),
147
+ });
148
+
149
+ expect(response.status).toBe(200);
150
+ expect(await response.json()).toEqual({
151
+ status: "success",
152
+ sessionPatch: {
153
+ accessToken: "token:demo:123456:refreshed",
154
+ __auth__: expect.any(String),
155
+ },
156
+ });
157
+ });
158
+
159
+ it("disconnects auth session", async () => {
160
+ const response = await app.request("/disconnect", {
161
+ method: "POST",
162
+ headers: { "content-type": "application/json" },
163
+ body: JSON.stringify({
164
+ session: { accessToken: "token:demo:123456" },
165
+ }),
166
+ });
167
+
168
+ expect(response.status).toBe(200);
169
+ expect(await response.json()).toEqual({ status: "success" });
170
+ });
171
+
172
+ it("returns health status", async () => {
173
+ const response = await app.request("/health");
174
+
175
+ expect(response.status).toBe(200);
176
+ expect(await response.json()).toEqual({
177
+ status: "ok",
178
+ connector: "test-connector",
179
+ version: "1.0.0",
180
+ uptime: expect.any(Number),
181
+ });
182
+ });
183
+
184
+ it("returns operation schema", async () => {
185
+ const response = await app.request("/schema/echo");
186
+ const body = (await response.json()) as {
187
+ operationId: string;
188
+ description: string;
189
+ hints: Record<string, string>;
190
+ input: {
191
+ type: string;
192
+ properties: Record<string, unknown>;
193
+ required: string[];
194
+ };
195
+ output: {
196
+ type: string;
197
+ properties: Record<string, unknown>;
198
+ required: string[];
199
+ };
200
+ };
201
+
202
+ expect(response.status).toBe(200);
203
+ expect(body.operationId).toBe("echo");
204
+ expect(body.description).toBe("Echo input");
205
+ expect(body.hints).toEqual({ rateLimit: "low" });
206
+ expect(body.input.type).toBe("object");
207
+ expect(body.input.properties.value).toEqual(
208
+ expect.objectContaining({ type: "string" }),
209
+ );
210
+ expect(body.input.required).toContain("value");
211
+ expect(body.output.type).toBe("object");
212
+ expect(body.output.properties.echoed).toEqual(
213
+ expect.objectContaining({ type: "string" }),
214
+ );
215
+ });
216
+
217
+ it("returns 404 for unknown operation", async () => {
218
+ const response = await app.request("/execute/missing", {
219
+ method: "POST",
220
+ headers: { "content-type": "application/json" },
221
+ body: JSON.stringify({ input: {}, session: {} }),
222
+ });
223
+
224
+ expect(response.status).toBe(404);
225
+ expect(await response.json()).toEqual({
226
+ error: {
227
+ code: "NOT_FOUND",
228
+ message: "Unknown operation: test-connector/missing",
229
+ details: { fix: "Valid operations: echo" },
230
+ },
231
+ });
232
+ });
233
+ });