@nkmc/gateway 0.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 (130) hide show
  1. package/dist/chunk-56RA53VS.js +37 -0
  2. package/dist/chunk-CZJ75YTV.js +969 -0
  3. package/dist/chunk-QGM4M3NI.js +37 -0
  4. package/dist/http.cjs +1772 -0
  5. package/dist/http.d.cts +49 -0
  6. package/dist/http.d.ts +49 -0
  7. package/dist/http.js +748 -0
  8. package/dist/index.cjs +2436 -0
  9. package/dist/index.d.cts +436 -0
  10. package/dist/index.d.ts +436 -0
  11. package/dist/index.js +1434 -0
  12. package/dist/proxy-ClPcDgsO.d.cts +283 -0
  13. package/dist/proxy-qpda1ANS.d.ts +283 -0
  14. package/dist/proxy.cjs +148 -0
  15. package/dist/proxy.d.cts +6 -0
  16. package/dist/proxy.d.ts +6 -0
  17. package/dist/proxy.js +90 -0
  18. package/dist/testing.cjs +865 -0
  19. package/dist/testing.d.cts +12 -0
  20. package/dist/testing.d.ts +12 -0
  21. package/dist/testing.js +831 -0
  22. package/dist/tunnels-BviBEaih.d.cts +12 -0
  23. package/dist/tunnels-DFHNgmN7.d.ts +12 -0
  24. package/dist/types-C6JC9oTm.d.cts +21 -0
  25. package/dist/types-C6JC9oTm.d.ts +21 -0
  26. package/package.json +47 -0
  27. package/src/__tests__/sqlite-integration.test.ts +384 -0
  28. package/src/credential/d1-vault.ts +134 -0
  29. package/src/credential/memory-vault.ts +50 -0
  30. package/src/credential/types.ts +16 -0
  31. package/src/d1/__tests__/sqlite-adapter.test.ts +75 -0
  32. package/src/d1/sqlite-adapter.ts +59 -0
  33. package/src/d1/types.ts +22 -0
  34. package/src/federation/__tests__/d1-peer-store.test.ts +218 -0
  35. package/src/federation/__tests__/peer-client.test.ts +205 -0
  36. package/src/federation/__tests__/peer-store.test.ts +114 -0
  37. package/src/federation/d1-peer-store.ts +164 -0
  38. package/src/federation/peer-backend.ts +60 -0
  39. package/src/federation/peer-client.ts +122 -0
  40. package/src/federation/peer-store.ts +45 -0
  41. package/src/federation/types.ts +39 -0
  42. package/src/http/app.ts +152 -0
  43. package/src/http/lib/dns.ts +30 -0
  44. package/src/http/middleware/admin-auth.ts +18 -0
  45. package/src/http/middleware/agent-auth.ts +27 -0
  46. package/src/http/middleware/publish-auth.ts +39 -0
  47. package/src/http/routes/__tests__/federation.test.ts +364 -0
  48. package/src/http/routes/__tests__/peers.test.ts +290 -0
  49. package/src/http/routes/__tests__/proxy.test.ts +159 -0
  50. package/src/http/routes/auth.ts +39 -0
  51. package/src/http/routes/byok.ts +62 -0
  52. package/src/http/routes/credentials.ts +40 -0
  53. package/src/http/routes/domains.ts +174 -0
  54. package/src/http/routes/federation.ts +170 -0
  55. package/src/http/routes/fs.ts +89 -0
  56. package/src/http/routes/peers.ts +103 -0
  57. package/src/http/routes/proxy.ts +57 -0
  58. package/src/http/routes/registry.ts +222 -0
  59. package/src/http/routes/tunnels.ts +124 -0
  60. package/src/http.ts +9 -0
  61. package/src/index.ts +63 -0
  62. package/src/metering/d1-store.ts +123 -0
  63. package/src/metering/memory-store.ts +29 -0
  64. package/src/metering/pricing-guard.ts +68 -0
  65. package/src/metering/types.ts +25 -0
  66. package/src/onboard/apis-guru.ts +64 -0
  67. package/src/onboard/index.ts +4 -0
  68. package/src/onboard/manifest.ts +362 -0
  69. package/src/onboard/pipeline.ts +214 -0
  70. package/src/onboard/types.ts +72 -0
  71. package/src/proxy/__tests__/tool-registry.test.ts +93 -0
  72. package/src/proxy/tool-registry.ts +122 -0
  73. package/src/proxy.ts +12 -0
  74. package/src/registry/context7-backend.ts +93 -0
  75. package/src/registry/context7.ts +54 -0
  76. package/src/registry/d1-store.ts +242 -0
  77. package/src/registry/memory-store.ts +101 -0
  78. package/src/registry/openapi-compiler.ts +284 -0
  79. package/src/registry/resolver.ts +196 -0
  80. package/src/registry/rpc-compiler.ts +142 -0
  81. package/src/registry/skill-parser.ts +119 -0
  82. package/src/registry/skill-to-config.ts +239 -0
  83. package/src/registry/source-refresher.ts +83 -0
  84. package/src/registry/types.ts +129 -0
  85. package/src/registry/virtual-files.ts +76 -0
  86. package/src/testing/sqlite-d1.ts +64 -0
  87. package/src/testing.ts +2 -0
  88. package/src/tunnel/__tests__/cloudflare-provider.test.ts +255 -0
  89. package/src/tunnel/__tests__/tunnel.test.ts +542 -0
  90. package/src/tunnel/cloudflare-provider.ts +121 -0
  91. package/src/tunnel/memory-store.ts +30 -0
  92. package/src/tunnel/types.ts +28 -0
  93. package/test/credential/d1-vault.test.ts +127 -0
  94. package/test/credential/injection.test.ts +67 -0
  95. package/test/credential/memory-vault.test.ts +63 -0
  96. package/test/http/app.test.ts +300 -0
  97. package/test/http/byok-e2e.test.ts +240 -0
  98. package/test/http/byok.test.ts +115 -0
  99. package/test/http/credentials.test.ts +57 -0
  100. package/test/http/e2e.test.ts +260 -0
  101. package/test/integration/authenticated-apis.test.ts +185 -0
  102. package/test/integration/free-apis-e2e.test.ts +222 -0
  103. package/test/metering/d1-store.test.ts +82 -0
  104. package/test/metering/memory-store.test.ts +76 -0
  105. package/test/metering/pricing-guard.test.ts +108 -0
  106. package/test/onboard/apis-guru.test.ts +57 -0
  107. package/test/onboard/e2e.test.ts +70 -0
  108. package/test/onboard/pipeline.test.ts +318 -0
  109. package/test/onboard/real-apis.test.ts +483 -0
  110. package/test/registry/compilation-correctness.test.ts +132 -0
  111. package/test/registry/context7-backend.test.ts +88 -0
  112. package/test/registry/context7-e2e.test.ts +92 -0
  113. package/test/registry/context7.test.ts +73 -0
  114. package/test/registry/d1-store.test.ts +184 -0
  115. package/test/registry/integration.test.ts +129 -0
  116. package/test/registry/lazy-mount.test.ts +138 -0
  117. package/test/registry/memory-store.test.ts +171 -0
  118. package/test/registry/openapi-compiler.test.ts +267 -0
  119. package/test/registry/openapi-e2e.test.ts +154 -0
  120. package/test/registry/passthrough-e2e.test.ts +109 -0
  121. package/test/registry/resolver-peer.test.ts +299 -0
  122. package/test/registry/resolver.test.ts +228 -0
  123. package/test/registry/rpc-compiler.test.ts +112 -0
  124. package/test/registry/skill-parser.test.ts +151 -0
  125. package/test/registry/skill-to-config.test.ts +151 -0
  126. package/test/registry/skill-to-rpc-config.test.ts +142 -0
  127. package/test/registry/source-refresher.test.ts +90 -0
  128. package/test/registry/virtual-files.test.ts +96 -0
  129. package/tsconfig.json +4 -0
  130. package/tsup.config.ts +8 -0
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AgentFs, MountResolver } from "@nkmc/agent-fs";
3
+ import { MemoryBackend } from "@nkmc/agent-fs/testing";
4
+ import type { Mount } from "@nkmc/agent-fs";
5
+
6
+ describe("Lazy mount loading", () => {
7
+ it("MountResolver should call onMiss when no mount found", async () => {
8
+ const resolver = new MountResolver();
9
+ const dynamicBackend = new MemoryBackend();
10
+ dynamicBackend.seed("products", [{ id: "1", name: "Widget" }]);
11
+
12
+ let missCalled = false;
13
+ resolver.onMiss = async (path: string) => {
14
+ missCalled = true;
15
+ const domain = path.split("/").filter(Boolean)[0];
16
+ if (domain === "acme-store.com") {
17
+ resolver.add({
18
+ path: "/acme-store.com",
19
+ backend: dynamicBackend,
20
+ });
21
+ return true;
22
+ }
23
+ return false;
24
+ };
25
+
26
+ const result = await resolver.resolveAsync(
27
+ "/acme-store.com/products/1.json",
28
+ );
29
+ expect(missCalled).toBe(true);
30
+ expect(result).not.toBeNull();
31
+ expect(result!.mount.path).toBe("/acme-store.com");
32
+ expect(result!.relativePath).toBe("/products/1.json");
33
+ });
34
+
35
+ it("MountResolver should return null if onMiss returns false", async () => {
36
+ const resolver = new MountResolver();
37
+ resolver.onMiss = async () => false;
38
+
39
+ const result = await resolver.resolveAsync("/unknown.com/test");
40
+ expect(result).toBeNull();
41
+ });
42
+
43
+ it("MountResolver should not call onMiss when mount exists", async () => {
44
+ const resolver = new MountResolver();
45
+ const backend = new MemoryBackend();
46
+ resolver.add({ path: "/existing", backend });
47
+
48
+ let missCalled = false;
49
+ resolver.onMiss = async () => {
50
+ missCalled = true;
51
+ return false;
52
+ };
53
+
54
+ const result = await resolver.resolveAsync("/existing/file.json");
55
+ expect(missCalled).toBe(false);
56
+ expect(result).not.toBeNull();
57
+ expect(result!.mount.path).toBe("/existing");
58
+ });
59
+
60
+ it("MountResolver resolveAsync works without onMiss set", async () => {
61
+ const resolver = new MountResolver();
62
+ const result = await resolver.resolveAsync("/anything");
63
+ expect(result).toBeNull();
64
+ });
65
+
66
+ it("AgentFs should use resolveAsync for lazy loading", async () => {
67
+ const dynamicBackend = new MemoryBackend();
68
+ dynamicBackend.seed("products", [{ id: "42", name: "Widget" }]);
69
+
70
+ const fs = new AgentFs({
71
+ mounts: [],
72
+ onMiss: async (path, addMount) => {
73
+ const domain = path.split("/").filter(Boolean)[0];
74
+ if (domain === "acme-store.com") {
75
+ addMount({ path: "/acme-store.com", backend: dynamicBackend });
76
+ return true;
77
+ }
78
+ return false;
79
+ },
80
+ });
81
+
82
+ const result = await fs.execute("cat /acme-store.com/products/42.json");
83
+ expect(result.ok).toBe(true);
84
+ if (result.ok) {
85
+ expect((result.data as { name: string }).name).toBe("Widget");
86
+ }
87
+ });
88
+
89
+ it("AgentFs ls / should include lazily-loaded domains", async () => {
90
+ const staticBackend = new MemoryBackend();
91
+
92
+ const fs = new AgentFs({
93
+ mounts: [{ path: "/memory", backend: staticBackend }],
94
+ listDomains: async () => ["acme-store.com", "stripe.com"],
95
+ });
96
+
97
+ const result = await fs.execute("ls /");
98
+ expect(result.ok).toBe(true);
99
+ if (result.ok) {
100
+ const entries = result.data as string[];
101
+ expect(entries).toContain("memory/");
102
+ expect(entries).toContain("acme-store.com/");
103
+ expect(entries).toContain("stripe.com/");
104
+ }
105
+ });
106
+
107
+ it("AgentFs ls / should deduplicate static mounts and dynamic domains", async () => {
108
+ const staticBackend = new MemoryBackend();
109
+
110
+ const fs = new AgentFs({
111
+ mounts: [{ path: "/acme-store.com", backend: staticBackend }],
112
+ listDomains: async () => ["acme-store.com"],
113
+ });
114
+
115
+ const result = await fs.execute("ls /");
116
+ expect(result.ok).toBe(true);
117
+ if (result.ok) {
118
+ const entries = result.data as string[];
119
+ const acmeCount = entries.filter(
120
+ (e: string) => e === "acme-store.com/",
121
+ ).length;
122
+ expect(acmeCount).toBe(1);
123
+ }
124
+ });
125
+
126
+ it("AgentFs should return NO_MOUNT when onMiss returns false", async () => {
127
+ const fs = new AgentFs({
128
+ mounts: [],
129
+ onMiss: async () => false,
130
+ });
131
+
132
+ const result = await fs.execute("cat /unknown.com/data");
133
+ expect(result.ok).toBe(false);
134
+ if (!result.ok) {
135
+ expect(result.error.code).toBe("NO_MOUNT");
136
+ }
137
+ });
138
+ });
@@ -0,0 +1,171 @@
1
+ // packages/gateway/test/registry/memory-store.test.ts
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { MemoryRegistryStore } from "../../src/registry/memory-store.js";
4
+ import type { ServiceRecord } from "../../src/registry/types.js";
5
+
6
+ function makeRecord(domain: string, overrides?: Partial<ServiceRecord>): ServiceRecord {
7
+ return {
8
+ domain,
9
+ name: overrides?.name ?? domain,
10
+ description: overrides?.description ?? `Service ${domain}`,
11
+ version: overrides?.version ?? "1.0",
12
+ roles: ["agent"],
13
+ skillMd: "---\nname: test\n---\n# Test",
14
+ endpoints: [{ method: "GET", path: "/api/test", description: "test endpoint" }],
15
+ isFirstParty: overrides?.isFirstParty ?? false,
16
+ createdAt: overrides?.createdAt ?? Date.now(),
17
+ updatedAt: overrides?.updatedAt ?? Date.now(),
18
+ status: overrides?.status ?? "active",
19
+ isDefault: overrides?.isDefault ?? true,
20
+ };
21
+ }
22
+
23
+ describe("MemoryRegistryStore", () => {
24
+ let store: MemoryRegistryStore;
25
+
26
+ beforeEach(() => {
27
+ store = new MemoryRegistryStore();
28
+ });
29
+
30
+ it("should put and get a service", async () => {
31
+ const record = makeRecord("acme-store.com");
32
+ await store.put("acme-store.com", record);
33
+ const result = await store.get("acme-store.com");
34
+ expect(result).toEqual(record);
35
+ });
36
+
37
+ it("should return null for unknown domain", async () => {
38
+ const result = await store.get("unknown.com");
39
+ expect(result).toBeNull();
40
+ });
41
+
42
+ it("should delete a service", async () => {
43
+ await store.put("acme.com", makeRecord("acme.com"));
44
+ await store.delete("acme.com");
45
+ expect(await store.get("acme.com")).toBeNull();
46
+ });
47
+
48
+ it("should list all services as summaries", async () => {
49
+ await store.put("acme.com", makeRecord("acme.com"));
50
+ await store.put("memory", makeRecord("memory", { isFirstParty: true }));
51
+ const list = await store.list();
52
+ expect(list).toHaveLength(2);
53
+ expect(list[0]).toEqual({
54
+ domain: "acme.com",
55
+ name: "acme.com",
56
+ description: "Service acme.com",
57
+ isFirstParty: false,
58
+ });
59
+ });
60
+
61
+ it("should search by description with empty matchedEndpoints", async () => {
62
+ await store.put("weather.com", makeRecord("weather.com", { description: "Weather forecasts" }));
63
+ await store.put("acme.com", makeRecord("acme.com", { description: "E-commerce store" }));
64
+ const results = await store.search("weather");
65
+ expect(results).toHaveLength(1);
66
+ expect(results[0].domain).toBe("weather.com");
67
+ // "weather" matches the service description, not any endpoint
68
+ expect(results[0].matchedEndpoints).toEqual([]);
69
+ });
70
+
71
+ it("should search by endpoint description and return matchedEndpoints", async () => {
72
+ const record = makeRecord("stripe.com", { description: "Payments" });
73
+ record.endpoints = [
74
+ { method: "POST", path: "/charges", description: "Create a charge" },
75
+ { method: "GET", path: "/charges/{id}", description: "Retrieve a charge" },
76
+ { method: "GET", path: "/balance", description: "Get balance" },
77
+ ];
78
+ await store.put("stripe.com", record);
79
+ const results = await store.search("charge");
80
+ expect(results).toHaveLength(1);
81
+ expect(results[0].domain).toBe("stripe.com");
82
+ expect(results[0].matchedEndpoints).toHaveLength(2);
83
+ expect(results[0].matchedEndpoints).toEqual([
84
+ { method: "POST", path: "/charges", description: "Create a charge" },
85
+ { method: "GET", path: "/charges/{id}", description: "Retrieve a charge" },
86
+ ]);
87
+ });
88
+
89
+ it("should search by endpoint method", async () => {
90
+ const record = makeRecord("api.example.com", { description: "Example API" });
91
+ record.endpoints = [
92
+ { method: "DELETE", path: "/users/{id}", description: "Remove user" },
93
+ { method: "GET", path: "/users", description: "List users" },
94
+ ];
95
+ await store.put("api.example.com", record);
96
+ const results = await store.search("DELETE");
97
+ expect(results).toHaveLength(1);
98
+ expect(results[0].matchedEndpoints).toEqual([
99
+ { method: "DELETE", path: "/users/{id}", description: "Remove user" },
100
+ ]);
101
+ });
102
+
103
+ it("should search by endpoint path", async () => {
104
+ const record = makeRecord("api.weather.gov", { description: "Weather API" });
105
+ record.endpoints = [
106
+ { method: "GET", path: "/alerts/active", description: "Active alerts" },
107
+ { method: "GET", path: "/alerts/{id}", description: "Single alert" },
108
+ { method: "GET", path: "/stations", description: "Weather stations" },
109
+ ];
110
+ await store.put("api.weather.gov", record);
111
+ const results = await store.search("alerts");
112
+ expect(results).toHaveLength(1);
113
+ expect(results[0].matchedEndpoints).toHaveLength(2);
114
+ expect(results[0].matchedEndpoints[0].path).toBe("/alerts/active");
115
+ expect(results[0].matchedEndpoints[1].path).toBe("/alerts/{id}");
116
+ });
117
+
118
+ it("should return empty for no search match", async () => {
119
+ await store.put("acme.com", makeRecord("acme.com"));
120
+ const results = await store.search("zzzzz");
121
+ expect(results).toHaveLength(0);
122
+ });
123
+
124
+ it("should overwrite on duplicate put", async () => {
125
+ await store.put("acme.com", makeRecord("acme.com", { description: "v1" }));
126
+ await store.put("acme.com", makeRecord("acme.com", { description: "v2" }));
127
+ const result = await store.get("acme.com");
128
+ expect(result?.description).toBe("v2");
129
+ });
130
+
131
+ it("get() should return only isDefault=true records", async () => {
132
+ await store.put("acme.com", makeRecord("acme.com", { version: "1.0", isDefault: false }));
133
+ await store.put("acme.com", makeRecord("acme.com", { version: "2.0", isDefault: true }));
134
+ const result = await store.get("acme.com");
135
+ expect(result?.version).toBe("2.0");
136
+ });
137
+
138
+ it("list() should only return default versions", async () => {
139
+ await store.put("acme.com", makeRecord("acme.com", { version: "1.0", isDefault: false }));
140
+ await store.put("acme.com", makeRecord("acme.com", { version: "2.0", isDefault: true }));
141
+ const list = await store.list();
142
+ expect(list).toHaveLength(1);
143
+ expect(list[0].domain).toBe("acme.com");
144
+ });
145
+
146
+ it("getVersion() should return specific version", async () => {
147
+ await store.put("acme.com", makeRecord("acme.com", { version: "1.0", isDefault: false }));
148
+ await store.put("acme.com", makeRecord("acme.com", { version: "2.0", isDefault: true }));
149
+ const v1 = await store.getVersion("acme.com", "1.0");
150
+ expect(v1?.version).toBe("1.0");
151
+ expect(v1?.isDefault).toBe(false);
152
+ });
153
+
154
+ it("listVersions() should return all versions for a domain", async () => {
155
+ await store.put("acme.com", makeRecord("acme.com", { version: "1.0", createdAt: 1000 }));
156
+ await store.put("acme.com", makeRecord("acme.com", { version: "2.0", createdAt: 2000 }));
157
+ const versions = await store.listVersions("acme.com");
158
+ expect(versions).toHaveLength(2);
159
+ expect(versions[0].version).toBe("2.0"); // most recent first
160
+ expect(versions[1].version).toBe("1.0");
161
+ });
162
+
163
+ it("delete() should remove all versions", async () => {
164
+ await store.put("acme.com", makeRecord("acme.com", { version: "1.0" }));
165
+ await store.put("acme.com", makeRecord("acme.com", { version: "2.0" }));
166
+ await store.delete("acme.com");
167
+ expect(await store.get("acme.com")).toBeNull();
168
+ expect(await store.getVersion("acme.com", "1.0")).toBeNull();
169
+ expect(await store.listVersions("acme.com")).toEqual([]);
170
+ });
171
+ });
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { compileOpenApiSpec, fetchAndCompile, extractBasePath } from "../../src/registry/openapi-compiler.js";
3
+
4
+ const PETSTORE_SPEC = {
5
+ openapi: "3.0.0",
6
+ info: { title: "Petstore", description: "A sample pet store", version: "1.0.0" },
7
+ paths: {
8
+ "/pets": {
9
+ get: { summary: "List all pets", operationId: "listPets" },
10
+ post: { summary: "Create a pet", operationId: "createPet" },
11
+ },
12
+ "/pets/{petId}": {
13
+ get: { summary: "Get pet by ID", operationId: "getPet" },
14
+ delete: { summary: "Delete a pet", operationId: "deletePet" },
15
+ },
16
+ },
17
+ };
18
+
19
+ describe("compileOpenApiSpec", () => {
20
+ it("should extract service name and description from info", () => {
21
+ const { record } = compileOpenApiSpec(PETSTORE_SPEC, { domain: "petstore.com" });
22
+ expect(record.name).toBe("Petstore");
23
+ expect(record.description).toBe("A sample pet store");
24
+ expect(record.domain).toBe("petstore.com");
25
+ });
26
+
27
+ it("should extract endpoints from paths", () => {
28
+ const { record } = compileOpenApiSpec(PETSTORE_SPEC, { domain: "petstore.com" });
29
+ expect(record.endpoints).toHaveLength(4);
30
+ expect(record.endpoints[0]).toMatchObject({ method: "GET", path: "/pets", description: "List all pets" });
31
+ expect(record.endpoints[1]).toMatchObject({ method: "POST", path: "/pets" });
32
+ });
33
+
34
+ it("should infer resources from path patterns", () => {
35
+ const { resources } = compileOpenApiSpec(PETSTORE_SPEC, { domain: "petstore.com" });
36
+ expect(resources.length).toBeGreaterThanOrEqual(1);
37
+ expect(resources[0].name).toBe("pets");
38
+ });
39
+
40
+ it("should generate skill.md with Schema and API sections", () => {
41
+ const { skillMd } = compileOpenApiSpec(PETSTORE_SPEC, { domain: "petstore.com" });
42
+ expect(skillMd).toContain("Petstore");
43
+ expect(skillMd).toContain("## Schema");
44
+ expect(skillMd).toContain("### pets (public)");
45
+ expect(skillMd).toContain("## API");
46
+ });
47
+
48
+ it("should set source type to openapi", () => {
49
+ const { record } = compileOpenApiSpec(PETSTORE_SPEC, { domain: "petstore.com" });
50
+ expect(record.source?.type).toBe("openapi");
51
+ });
52
+
53
+ it("should use custom version", () => {
54
+ const { record } = compileOpenApiSpec(PETSTORE_SPEC, { domain: "petstore.com", version: "2.0" });
55
+ expect(record.version).toBe("2.0");
56
+ });
57
+
58
+ it("should set status and isDefault", () => {
59
+ const { record } = compileOpenApiSpec(PETSTORE_SPEC, { domain: "petstore.com" });
60
+ expect(record.status).toBe("active");
61
+ expect(record.isDefault).toBe(true);
62
+ });
63
+
64
+ it("should handle empty spec", () => {
65
+ const { record } = compileOpenApiSpec({}, { domain: "empty.com" });
66
+ expect(record.name).toBe("empty.com");
67
+ expect(record.endpoints).toEqual([]);
68
+ });
69
+
70
+ it("should extract basePath from servers[0].url", () => {
71
+ const spec = {
72
+ ...PETSTORE_SPEC,
73
+ servers: [{ url: "https://petstore.com/api/v3" }],
74
+ };
75
+ const { record } = compileOpenApiSpec(spec, { domain: "petstore.com" });
76
+ expect(record.source?.basePath).toBe("/api/v3");
77
+ });
78
+
79
+ it("should not set basePath when servers URL has no path", () => {
80
+ const spec = {
81
+ ...PETSTORE_SPEC,
82
+ servers: [{ url: "https://api.github.com" }],
83
+ };
84
+ const { record } = compileOpenApiSpec(spec, { domain: "api.github.com" });
85
+ expect(record.source?.basePath).toBeUndefined();
86
+ });
87
+ });
88
+
89
+ describe("schema extraction", () => {
90
+ const SPEC_WITH_SCHEMAS = {
91
+ openapi: "3.0.0",
92
+ info: { title: "Test API", version: "1.0.0" },
93
+ components: {
94
+ schemas: {
95
+ Pet: {
96
+ type: "object",
97
+ required: ["name"],
98
+ properties: {
99
+ id: { type: "integer", description: "Pet ID" },
100
+ name: { type: "string", description: "Pet name" },
101
+ tag: { type: "string" },
102
+ },
103
+ },
104
+ },
105
+ },
106
+ paths: {
107
+ "/pets": {
108
+ get: {
109
+ summary: "List pets",
110
+ parameters: [
111
+ { name: "limit", in: "query", required: false, schema: { type: "integer" }, description: "Max items" },
112
+ { name: "status", in: "query", required: true, schema: { type: "string" } },
113
+ ],
114
+ responses: {
115
+ "200": {
116
+ description: "A list of pets",
117
+ content: {
118
+ "application/json": {
119
+ schema: { $ref: "#/components/schemas/Pet" },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ },
125
+ post: {
126
+ summary: "Create pet",
127
+ requestBody: {
128
+ required: true,
129
+ content: {
130
+ "application/json": {
131
+ schema: { $ref: "#/components/schemas/Pet" },
132
+ },
133
+ },
134
+ },
135
+ responses: {
136
+ "201": { description: "Created" },
137
+ },
138
+ },
139
+ },
140
+ "/pets/{petId}": {
141
+ get: {
142
+ summary: "Get pet",
143
+ parameters: [
144
+ { name: "petId", in: "path", required: true, schema: { type: "string" } },
145
+ ],
146
+ },
147
+ },
148
+ },
149
+ };
150
+
151
+ it("should extract parameters from operations", () => {
152
+ const { record } = compileOpenApiSpec(SPEC_WITH_SCHEMAS, { domain: "test.com" });
153
+ const listPets = record.endpoints.find((e) => e.method === "GET" && e.path === "/pets");
154
+ expect(listPets?.parameters).toHaveLength(2);
155
+ expect(listPets?.parameters?.[0]).toMatchObject({
156
+ name: "limit",
157
+ in: "query",
158
+ required: false,
159
+ type: "integer",
160
+ description: "Max items",
161
+ });
162
+ expect(listPets?.parameters?.[1]).toMatchObject({
163
+ name: "status",
164
+ in: "query",
165
+ required: true,
166
+ type: "string",
167
+ });
168
+ });
169
+
170
+ it("should extract path parameters", () => {
171
+ const { record } = compileOpenApiSpec(SPEC_WITH_SCHEMAS, { domain: "test.com" });
172
+ const getPet = record.endpoints.find((e) => e.method === "GET" && e.path === "/pets/{petId}");
173
+ expect(getPet?.parameters).toHaveLength(1);
174
+ expect(getPet?.parameters?.[0]).toMatchObject({
175
+ name: "petId",
176
+ in: "path",
177
+ required: true,
178
+ type: "string",
179
+ });
180
+ });
181
+
182
+ it("should extract requestBody with $ref resolution", () => {
183
+ const { record } = compileOpenApiSpec(SPEC_WITH_SCHEMAS, { domain: "test.com" });
184
+ const createPet = record.endpoints.find((e) => e.method === "POST" && e.path === "/pets");
185
+ expect(createPet?.requestBody).toBeDefined();
186
+ expect(createPet?.requestBody?.contentType).toBe("application/json");
187
+ expect(createPet?.requestBody?.required).toBe(true);
188
+ expect(createPet?.requestBody?.properties).toHaveLength(3);
189
+ expect(createPet?.requestBody?.properties).toContainEqual({
190
+ name: "name",
191
+ type: "string",
192
+ required: true,
193
+ description: "Pet name",
194
+ });
195
+ });
196
+
197
+ it("should extract 2xx responses with $ref resolution", () => {
198
+ const { record } = compileOpenApiSpec(SPEC_WITH_SCHEMAS, { domain: "test.com" });
199
+ const listPets = record.endpoints.find((e) => e.method === "GET" && e.path === "/pets");
200
+ expect(listPets?.responses).toHaveLength(1);
201
+ expect(listPets?.responses?.[0].status).toBe(200);
202
+ expect(listPets?.responses?.[0].description).toBe("A list of pets");
203
+ expect(listPets?.responses?.[0].properties).toHaveLength(3);
204
+ });
205
+
206
+ it("should include schema tables in generated skill.md", () => {
207
+ const { skillMd } = compileOpenApiSpec(SPEC_WITH_SCHEMAS, { domain: "test.com" });
208
+ // Parameters table
209
+ expect(skillMd).toContain("**Parameters:**");
210
+ expect(skillMd).toContain("| limit | query | integer |");
211
+ expect(skillMd).toContain("| status | query | string | * |");
212
+ // Request body table
213
+ expect(skillMd).toContain("**Body** (application/json, required):");
214
+ expect(skillMd).toContain("| name | string | * |");
215
+ // Response
216
+ expect(skillMd).toContain("**Response 200**: A list of pets");
217
+ expect(skillMd).toContain("| id | integer |");
218
+ });
219
+
220
+ it("should not include schema fields when absent", () => {
221
+ const { record } = compileOpenApiSpec({
222
+ openapi: "3.0.0",
223
+ info: { title: "Bare", version: "1.0.0" },
224
+ paths: { "/health": { get: { summary: "Health check" } } },
225
+ }, { domain: "bare.com" });
226
+ const ep = record.endpoints[0];
227
+ expect(ep.parameters).toBeUndefined();
228
+ expect(ep.requestBody).toBeUndefined();
229
+ expect(ep.responses).toBeUndefined();
230
+ });
231
+ });
232
+
233
+ describe("fetchAndCompile", () => {
234
+ it("should fetch and compile remote spec", async () => {
235
+ const mockFetch = async (url: string) => {
236
+ return new Response(JSON.stringify(PETSTORE_SPEC), { status: 200, headers: { "Content-Type": "application/json" } });
237
+ };
238
+ const result = await fetchAndCompile("https://petstore.com/openapi.json", { domain: "petstore.com" }, mockFetch as any);
239
+ expect(result.record.name).toBe("Petstore");
240
+ expect(result.record.source?.url).toBe("https://petstore.com/openapi.json");
241
+ });
242
+
243
+ it("should throw on failed fetch", async () => {
244
+ const mockFetch = async () => new Response("Not found", { status: 404 });
245
+ await expect(fetchAndCompile("https://bad.com/spec.json", { domain: "bad.com" }, mockFetch as any)).rejects.toThrow("Failed to fetch spec");
246
+ });
247
+
248
+ it("should preserve basePath from spec in fetchAndCompile", async () => {
249
+ const specWithServers = {
250
+ ...PETSTORE_SPEC,
251
+ servers: [{ url: "https://api.stripe.com/v1" }],
252
+ };
253
+ const mockFetch = async () => {
254
+ return new Response(JSON.stringify(specWithServers), {
255
+ status: 200,
256
+ headers: { "Content-Type": "application/json" },
257
+ });
258
+ };
259
+ const result = await fetchAndCompile(
260
+ "https://api.stripe.com/openapi.json",
261
+ { domain: "api.stripe.com" },
262
+ mockFetch as any,
263
+ );
264
+ expect(result.record.source?.url).toBe("https://api.stripe.com/openapi.json");
265
+ expect(result.record.source?.basePath).toBe("/v1");
266
+ });
267
+ });