@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.
- package/dist/chunk-56RA53VS.js +37 -0
- package/dist/chunk-CZJ75YTV.js +969 -0
- package/dist/chunk-QGM4M3NI.js +37 -0
- package/dist/http.cjs +1772 -0
- package/dist/http.d.cts +49 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +748 -0
- package/dist/index.cjs +2436 -0
- package/dist/index.d.cts +436 -0
- package/dist/index.d.ts +436 -0
- package/dist/index.js +1434 -0
- package/dist/proxy-ClPcDgsO.d.cts +283 -0
- package/dist/proxy-qpda1ANS.d.ts +283 -0
- package/dist/proxy.cjs +148 -0
- package/dist/proxy.d.cts +6 -0
- package/dist/proxy.d.ts +6 -0
- package/dist/proxy.js +90 -0
- package/dist/testing.cjs +865 -0
- package/dist/testing.d.cts +12 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +831 -0
- package/dist/tunnels-BviBEaih.d.cts +12 -0
- package/dist/tunnels-DFHNgmN7.d.ts +12 -0
- package/dist/types-C6JC9oTm.d.cts +21 -0
- package/dist/types-C6JC9oTm.d.ts +21 -0
- package/package.json +47 -0
- package/src/__tests__/sqlite-integration.test.ts +384 -0
- package/src/credential/d1-vault.ts +134 -0
- package/src/credential/memory-vault.ts +50 -0
- package/src/credential/types.ts +16 -0
- package/src/d1/__tests__/sqlite-adapter.test.ts +75 -0
- package/src/d1/sqlite-adapter.ts +59 -0
- package/src/d1/types.ts +22 -0
- package/src/federation/__tests__/d1-peer-store.test.ts +218 -0
- package/src/federation/__tests__/peer-client.test.ts +205 -0
- package/src/federation/__tests__/peer-store.test.ts +114 -0
- package/src/federation/d1-peer-store.ts +164 -0
- package/src/federation/peer-backend.ts +60 -0
- package/src/federation/peer-client.ts +122 -0
- package/src/federation/peer-store.ts +45 -0
- package/src/federation/types.ts +39 -0
- package/src/http/app.ts +152 -0
- package/src/http/lib/dns.ts +30 -0
- package/src/http/middleware/admin-auth.ts +18 -0
- package/src/http/middleware/agent-auth.ts +27 -0
- package/src/http/middleware/publish-auth.ts +39 -0
- package/src/http/routes/__tests__/federation.test.ts +364 -0
- package/src/http/routes/__tests__/peers.test.ts +290 -0
- package/src/http/routes/__tests__/proxy.test.ts +159 -0
- package/src/http/routes/auth.ts +39 -0
- package/src/http/routes/byok.ts +62 -0
- package/src/http/routes/credentials.ts +40 -0
- package/src/http/routes/domains.ts +174 -0
- package/src/http/routes/federation.ts +170 -0
- package/src/http/routes/fs.ts +89 -0
- package/src/http/routes/peers.ts +103 -0
- package/src/http/routes/proxy.ts +57 -0
- package/src/http/routes/registry.ts +222 -0
- package/src/http/routes/tunnels.ts +124 -0
- package/src/http.ts +9 -0
- package/src/index.ts +63 -0
- package/src/metering/d1-store.ts +123 -0
- package/src/metering/memory-store.ts +29 -0
- package/src/metering/pricing-guard.ts +68 -0
- package/src/metering/types.ts +25 -0
- package/src/onboard/apis-guru.ts +64 -0
- package/src/onboard/index.ts +4 -0
- package/src/onboard/manifest.ts +362 -0
- package/src/onboard/pipeline.ts +214 -0
- package/src/onboard/types.ts +72 -0
- package/src/proxy/__tests__/tool-registry.test.ts +93 -0
- package/src/proxy/tool-registry.ts +122 -0
- package/src/proxy.ts +12 -0
- package/src/registry/context7-backend.ts +93 -0
- package/src/registry/context7.ts +54 -0
- package/src/registry/d1-store.ts +242 -0
- package/src/registry/memory-store.ts +101 -0
- package/src/registry/openapi-compiler.ts +284 -0
- package/src/registry/resolver.ts +196 -0
- package/src/registry/rpc-compiler.ts +142 -0
- package/src/registry/skill-parser.ts +119 -0
- package/src/registry/skill-to-config.ts +239 -0
- package/src/registry/source-refresher.ts +83 -0
- package/src/registry/types.ts +129 -0
- package/src/registry/virtual-files.ts +76 -0
- package/src/testing/sqlite-d1.ts +64 -0
- package/src/testing.ts +2 -0
- package/src/tunnel/__tests__/cloudflare-provider.test.ts +255 -0
- package/src/tunnel/__tests__/tunnel.test.ts +542 -0
- package/src/tunnel/cloudflare-provider.ts +121 -0
- package/src/tunnel/memory-store.ts +30 -0
- package/src/tunnel/types.ts +28 -0
- package/test/credential/d1-vault.test.ts +127 -0
- package/test/credential/injection.test.ts +67 -0
- package/test/credential/memory-vault.test.ts +63 -0
- package/test/http/app.test.ts +300 -0
- package/test/http/byok-e2e.test.ts +240 -0
- package/test/http/byok.test.ts +115 -0
- package/test/http/credentials.test.ts +57 -0
- package/test/http/e2e.test.ts +260 -0
- package/test/integration/authenticated-apis.test.ts +185 -0
- package/test/integration/free-apis-e2e.test.ts +222 -0
- package/test/metering/d1-store.test.ts +82 -0
- package/test/metering/memory-store.test.ts +76 -0
- package/test/metering/pricing-guard.test.ts +108 -0
- package/test/onboard/apis-guru.test.ts +57 -0
- package/test/onboard/e2e.test.ts +70 -0
- package/test/onboard/pipeline.test.ts +318 -0
- package/test/onboard/real-apis.test.ts +483 -0
- package/test/registry/compilation-correctness.test.ts +132 -0
- package/test/registry/context7-backend.test.ts +88 -0
- package/test/registry/context7-e2e.test.ts +92 -0
- package/test/registry/context7.test.ts +73 -0
- package/test/registry/d1-store.test.ts +184 -0
- package/test/registry/integration.test.ts +129 -0
- package/test/registry/lazy-mount.test.ts +138 -0
- package/test/registry/memory-store.test.ts +171 -0
- package/test/registry/openapi-compiler.test.ts +267 -0
- package/test/registry/openapi-e2e.test.ts +154 -0
- package/test/registry/passthrough-e2e.test.ts +109 -0
- package/test/registry/resolver-peer.test.ts +299 -0
- package/test/registry/resolver.test.ts +228 -0
- package/test/registry/rpc-compiler.test.ts +112 -0
- package/test/registry/skill-parser.test.ts +151 -0
- package/test/registry/skill-to-config.test.ts +151 -0
- package/test/registry/skill-to-rpc-config.test.ts +142 -0
- package/test/registry/source-refresher.test.ts +90 -0
- package/test/registry/virtual-files.test.ts +96 -0
- package/tsconfig.json +4 -0
- package/tsup.config.ts +8 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E integration test: OpenAPI → Registry → AgentFs → Real HTTP
|
|
3
|
+
*
|
|
4
|
+
* Uses JSONPlaceholder (https://jsonplaceholder.typicode.com) as a real, public REST API.
|
|
5
|
+
* These tests require network access and will be slower than unit tests.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
8
|
+
import { compileOpenApiSpec, fetchAndCompile } from "../../src/registry/openapi-compiler.js";
|
|
9
|
+
import { MemoryRegistryStore } from "../../src/registry/memory-store.js";
|
|
10
|
+
import { createRegistryResolver } from "../../src/registry/resolver.js";
|
|
11
|
+
import { AgentFs } from "@nkmc/agent-fs";
|
|
12
|
+
|
|
13
|
+
const DOMAIN = "jsonplaceholder.typicode.com";
|
|
14
|
+
|
|
15
|
+
const JSONPLACEHOLDER_SPEC = {
|
|
16
|
+
openapi: "3.0.0",
|
|
17
|
+
info: {
|
|
18
|
+
title: "JSONPlaceholder",
|
|
19
|
+
version: "1.0.0",
|
|
20
|
+
description: "Free fake REST API for testing and prototyping.",
|
|
21
|
+
},
|
|
22
|
+
paths: {
|
|
23
|
+
"/posts": {
|
|
24
|
+
get: { summary: "List all posts", operationId: "listPosts" },
|
|
25
|
+
post: { summary: "Create a post", operationId: "createPost" },
|
|
26
|
+
},
|
|
27
|
+
"/posts/{id}": {
|
|
28
|
+
get: { summary: "Get post by ID", operationId: "getPost" },
|
|
29
|
+
},
|
|
30
|
+
"/users": {
|
|
31
|
+
get: { summary: "List all users", operationId: "listUsers" },
|
|
32
|
+
},
|
|
33
|
+
"/users/{id}": {
|
|
34
|
+
get: { summary: "Get user by ID", operationId: "getUser" },
|
|
35
|
+
},
|
|
36
|
+
"/comments": {
|
|
37
|
+
get: { summary: "List all comments", operationId: "listComments" },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe("OpenAPI E2E: JSONPlaceholder (real network)", () => {
|
|
43
|
+
let fs: AgentFs;
|
|
44
|
+
let store: MemoryRegistryStore;
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
// 1. Compile OpenAPI spec → ServiceRecord
|
|
48
|
+
const { record } = compileOpenApiSpec(JSONPLACEHOLDER_SPEC, {
|
|
49
|
+
domain: DOMAIN,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 2. Register in store
|
|
53
|
+
store = new MemoryRegistryStore();
|
|
54
|
+
await store.put(DOMAIN, record);
|
|
55
|
+
|
|
56
|
+
// 3. Create AgentFs with registry resolver
|
|
57
|
+
const { onMiss, listDomains, searchDomains } = createRegistryResolver({
|
|
58
|
+
store,
|
|
59
|
+
wrapVirtualFiles: true,
|
|
60
|
+
});
|
|
61
|
+
fs = new AgentFs({ mounts: [], onMiss, listDomains, searchDomains });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("ls / should list the registered service", async () => {
|
|
65
|
+
const result = await fs.execute("ls /");
|
|
66
|
+
expect(result.ok).toBe(true);
|
|
67
|
+
const entries = result.data as string[];
|
|
68
|
+
expect(entries).toContain(`${DOMAIN}/`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("ls /domain/ should list resources, _api, and virtual files", async () => {
|
|
72
|
+
const result = await fs.execute(`ls /${DOMAIN}/`);
|
|
73
|
+
expect(result.ok).toBe(true);
|
|
74
|
+
const entries = result.data as string[];
|
|
75
|
+
// Resources from Schema section
|
|
76
|
+
expect(entries).toContain("posts/");
|
|
77
|
+
expect(entries).toContain("users/");
|
|
78
|
+
expect(entries).toContain("comments/");
|
|
79
|
+
// API endpoints directory
|
|
80
|
+
expect(entries).toContain("_api/");
|
|
81
|
+
// Virtual files from VirtualFileBackend
|
|
82
|
+
expect(entries).toContain("_pricing.json");
|
|
83
|
+
expect(entries).toContain("_versions.json");
|
|
84
|
+
}, 10_000);
|
|
85
|
+
|
|
86
|
+
it("ls /domain/posts/ should list real posts from the API", async () => {
|
|
87
|
+
const result = await fs.execute(`ls /${DOMAIN}/posts/`);
|
|
88
|
+
expect(result.ok).toBe(true);
|
|
89
|
+
const entries = result.data as string[];
|
|
90
|
+
// JSONPlaceholder has 100 posts
|
|
91
|
+
expect(entries.length).toBe(100);
|
|
92
|
+
expect(entries).toContain("1.json");
|
|
93
|
+
expect(entries).toContain("100.json");
|
|
94
|
+
}, 15_000);
|
|
95
|
+
|
|
96
|
+
it("cat /domain/posts/1 should return real post data", async () => {
|
|
97
|
+
const result = await fs.execute(`cat /${DOMAIN}/posts/1`);
|
|
98
|
+
expect(result.ok).toBe(true);
|
|
99
|
+
const post = result.data as any;
|
|
100
|
+
expect(post.id).toBe(1);
|
|
101
|
+
expect(post.userId).toBe(1);
|
|
102
|
+
expect(post.title).toBeTruthy();
|
|
103
|
+
expect(post.body).toBeTruthy();
|
|
104
|
+
}, 10_000);
|
|
105
|
+
|
|
106
|
+
it("cat /domain/users/1 should return real user data", async () => {
|
|
107
|
+
const result = await fs.execute(`cat /${DOMAIN}/users/1`);
|
|
108
|
+
expect(result.ok).toBe(true);
|
|
109
|
+
const user = result.data as any;
|
|
110
|
+
expect(user.id).toBe(1);
|
|
111
|
+
expect(user.name).toBeTruthy();
|
|
112
|
+
expect(user.email).toBeTruthy();
|
|
113
|
+
}, 10_000);
|
|
114
|
+
|
|
115
|
+
it("ls /domain/_api/ should list all endpoints", async () => {
|
|
116
|
+
const result = await fs.execute(`ls /${DOMAIN}/_api/`);
|
|
117
|
+
expect(result.ok).toBe(true);
|
|
118
|
+
const entries = result.data as string[];
|
|
119
|
+
expect(entries.length).toBe(6);
|
|
120
|
+
expect(entries.some((e) => e.includes("list-all-posts"))).toBe(true);
|
|
121
|
+
expect(entries.some((e) => e.includes("list-all-users"))).toBe(true);
|
|
122
|
+
}, 10_000);
|
|
123
|
+
|
|
124
|
+
it("cat /domain/_pricing.json should return pricing info", async () => {
|
|
125
|
+
const result = await fs.execute(`cat /${DOMAIN}/_pricing.json`);
|
|
126
|
+
expect(result.ok).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("cat /domain/_versions.json should return version info", async () => {
|
|
130
|
+
const result = await fs.execute(`cat /${DOMAIN}/_versions.json`);
|
|
131
|
+
expect(result.ok).toBe(true);
|
|
132
|
+
const data = result.data as any;
|
|
133
|
+
expect(data.domain).toBe(DOMAIN);
|
|
134
|
+
expect(data.versions).toHaveLength(1);
|
|
135
|
+
expect(data.versions[0].version).toBe("1.0.0");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("fetchAndCompile with real Petstore spec", () => {
|
|
140
|
+
it("should fetch and compile the Petstore OpenAPI spec", async () => {
|
|
141
|
+
const result = await fetchAndCompile(
|
|
142
|
+
"https://petstore3.swagger.io/api/v3/openapi.json",
|
|
143
|
+
{ domain: "petstore3.swagger.io" },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(result.record.name).toBeTruthy();
|
|
147
|
+
expect(result.record.endpoints.length).toBeGreaterThan(0);
|
|
148
|
+
expect(result.resources.length).toBeGreaterThan(0);
|
|
149
|
+
expect(result.record.source?.type).toBe("openapi");
|
|
150
|
+
expect(result.record.source?.url).toBe("https://petstore3.swagger.io/api/v3/openapi.json");
|
|
151
|
+
expect(result.skillMd).toContain("## Schema");
|
|
152
|
+
expect(result.skillMd).toContain("## API");
|
|
153
|
+
}, 15_000);
|
|
154
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E integration test: HTTP Passthrough mode with real API.
|
|
3
|
+
*
|
|
4
|
+
* Registers a service with a bare skill.md (no Schema, no API sections),
|
|
5
|
+
* causing HttpBackend to enter passthrough mode where all paths proxy
|
|
6
|
+
* directly to the target API without resource/endpoint mapping.
|
|
7
|
+
*
|
|
8
|
+
* Uses JSONPlaceholder as the real API target.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
11
|
+
import { parseSkillMd } from "../../src/registry/skill-parser.js";
|
|
12
|
+
import { MemoryRegistryStore } from "../../src/registry/memory-store.js";
|
|
13
|
+
import { createRegistryResolver } from "../../src/registry/resolver.js";
|
|
14
|
+
import { AgentFs } from "@nkmc/agent-fs";
|
|
15
|
+
|
|
16
|
+
const DOMAIN = "jsonplaceholder.typicode.com";
|
|
17
|
+
|
|
18
|
+
// Bare skill.md — no ## Schema, no ## API
|
|
19
|
+
// skillToHttpConfig will produce resources=[], endpoints=[] → passthrough mode
|
|
20
|
+
const BARE_SKILL_MD = `---
|
|
21
|
+
name: "JSONPlaceholder"
|
|
22
|
+
gateway: nkmc
|
|
23
|
+
version: "1.0"
|
|
24
|
+
roles: [agent]
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# JSONPlaceholder
|
|
28
|
+
|
|
29
|
+
Free fake REST API for testing. All paths proxy directly to the target.
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
describe("HTTP Passthrough E2E: JSONPlaceholder (real network)", () => {
|
|
33
|
+
let fs: AgentFs;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
const store = new MemoryRegistryStore();
|
|
37
|
+
const record = parseSkillMd(DOMAIN, BARE_SKILL_MD);
|
|
38
|
+
await store.put(DOMAIN, record);
|
|
39
|
+
|
|
40
|
+
const { onMiss, listDomains, searchDomains } = createRegistryResolver({
|
|
41
|
+
store,
|
|
42
|
+
wrapVirtualFiles: true,
|
|
43
|
+
});
|
|
44
|
+
fs = new AgentFs({ mounts: [], onMiss, listDomains, searchDomains });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ls / should list the registered service", async () => {
|
|
48
|
+
const result = await fs.execute("ls /");
|
|
49
|
+
expect(result.ok).toBe(true);
|
|
50
|
+
expect(result.data).toContain(`${DOMAIN}/`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("ls /domain/ should show only virtual files (no resources in passthrough)", async () => {
|
|
54
|
+
const result = await fs.execute(`ls /${DOMAIN}/`);
|
|
55
|
+
expect(result.ok).toBe(true);
|
|
56
|
+
const entries = result.data as string[];
|
|
57
|
+
// Passthrough root = empty from HttpBackend + virtual files from wrapper
|
|
58
|
+
expect(entries).toContain("_pricing.json");
|
|
59
|
+
expect(entries).toContain("_versions.json");
|
|
60
|
+
// No resource directories — passthrough doesn't know them
|
|
61
|
+
expect(entries).not.toContain("posts/");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("ls /domain/posts should list real posts via direct proxy", async () => {
|
|
65
|
+
const result = await fs.execute(`ls /${DOMAIN}/posts`);
|
|
66
|
+
expect(result.ok).toBe(true);
|
|
67
|
+
const entries = result.data as string[];
|
|
68
|
+
// JSONPlaceholder has 100 posts, passthrough maps item.id to string
|
|
69
|
+
expect(entries.length).toBe(100);
|
|
70
|
+
expect(entries).toContain("1");
|
|
71
|
+
}, 15_000);
|
|
72
|
+
|
|
73
|
+
it("cat /domain/posts/1 should proxy GET and return real post", async () => {
|
|
74
|
+
const result = await fs.execute(`cat /${DOMAIN}/posts/1`);
|
|
75
|
+
expect(result.ok).toBe(true);
|
|
76
|
+
const post = result.data as any;
|
|
77
|
+
expect(post.id).toBe(1);
|
|
78
|
+
expect(post.userId).toBe(1);
|
|
79
|
+
expect(post.title).toBeTruthy();
|
|
80
|
+
expect(post.body).toBeTruthy();
|
|
81
|
+
}, 10_000);
|
|
82
|
+
|
|
83
|
+
it("cat /domain/users/3 should proxy GET and return real user", async () => {
|
|
84
|
+
const result = await fs.execute(`cat /${DOMAIN}/users/3`);
|
|
85
|
+
expect(result.ok).toBe(true);
|
|
86
|
+
const user = result.data as any;
|
|
87
|
+
expect(user.id).toBe(3);
|
|
88
|
+
expect(user.name).toBeTruthy();
|
|
89
|
+
expect(user.email).toBeTruthy();
|
|
90
|
+
}, 10_000);
|
|
91
|
+
|
|
92
|
+
it("cat /domain/posts/1/comments should proxy nested path", async () => {
|
|
93
|
+
const result = await fs.execute(`cat /${DOMAIN}/posts/1/comments`);
|
|
94
|
+
expect(result.ok).toBe(true);
|
|
95
|
+
const comments = result.data as any[];
|
|
96
|
+
expect(Array.isArray(comments)).toBe(true);
|
|
97
|
+
expect(comments.length).toBeGreaterThan(0);
|
|
98
|
+
expect(comments[0].postId).toBe(1);
|
|
99
|
+
}, 10_000);
|
|
100
|
+
|
|
101
|
+
it("grep on /domain/posts should proxy search with ?q=", async () => {
|
|
102
|
+
const result = await fs.execute(`grep "test" /${DOMAIN}/posts`);
|
|
103
|
+
expect(result.ok).toBe(true);
|
|
104
|
+
// JSONPlaceholder doesn't actually filter by ?q=, it returns all posts
|
|
105
|
+
// But the proxy mechanism should work without error
|
|
106
|
+
const data = result.data as any[];
|
|
107
|
+
expect(Array.isArray(data)).toBe(true);
|
|
108
|
+
}, 10_000);
|
|
109
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { MemoryRegistryStore } from "../../src/registry/memory-store.js";
|
|
3
|
+
import { parseSkillMd } from "../../src/registry/skill-parser.js";
|
|
4
|
+
import { createRegistryResolver } from "../../src/registry/resolver.js";
|
|
5
|
+
import type { PeerGateway } from "../../src/federation/types.js";
|
|
6
|
+
import type { PeerClient, PeerQueryResult } from "../../src/federation/peer-client.js";
|
|
7
|
+
import { PeerBackend } from "../../src/federation/peer-backend.js";
|
|
8
|
+
import type { HttpAuth } from "@nkmc/agent-fs";
|
|
9
|
+
|
|
10
|
+
const ACME_SKILL = `---
|
|
11
|
+
name: "Acme Store"
|
|
12
|
+
gateway: nkmc
|
|
13
|
+
version: "1.0"
|
|
14
|
+
roles: [agent]
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Acme Store
|
|
18
|
+
|
|
19
|
+
E-commerce store.
|
|
20
|
+
|
|
21
|
+
## API
|
|
22
|
+
|
|
23
|
+
### List products
|
|
24
|
+
|
|
25
|
+
\`GET /api/products\` — free, public
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function makePeer(overrides: Partial<PeerGateway> = {}): PeerGateway {
|
|
29
|
+
return {
|
|
30
|
+
id: "peer-1",
|
|
31
|
+
name: "Peer Gateway 1",
|
|
32
|
+
url: "https://peer1.example.com",
|
|
33
|
+
sharedSecret: "secret-1",
|
|
34
|
+
status: "active",
|
|
35
|
+
advertisedDomains: [],
|
|
36
|
+
lastSeen: Date.now(),
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeMockPeerClient(
|
|
43
|
+
queryFn: (peer: PeerGateway, domain: string) => Promise<PeerQueryResult>,
|
|
44
|
+
): PeerClient {
|
|
45
|
+
return {
|
|
46
|
+
selfId: "self-gateway",
|
|
47
|
+
query: queryFn,
|
|
48
|
+
exec: vi.fn(),
|
|
49
|
+
announce: vi.fn(),
|
|
50
|
+
} as unknown as PeerClient;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeMockPeerStore(peers: PeerGateway[]) {
|
|
54
|
+
return {
|
|
55
|
+
listPeers: vi.fn(async () => peers),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeMockVault(entries: Record<string, HttpAuth>) {
|
|
60
|
+
return {
|
|
61
|
+
get: vi.fn(async (domain: string) => {
|
|
62
|
+
const auth = entries[domain];
|
|
63
|
+
return auth ? { auth } : null;
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("Resolver peer fallback", () => {
|
|
69
|
+
let store: MemoryRegistryStore;
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
store = new MemoryRegistryStore();
|
|
73
|
+
const record = parseSkillMd("acme-store.com", ACME_SKILL);
|
|
74
|
+
await store.put("acme-store.com", record);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("local credential exists → uses local HttpBackend (no peer query)", async () => {
|
|
78
|
+
const peer = makePeer();
|
|
79
|
+
const peerClient = makeMockPeerClient(async () => ({ available: true }));
|
|
80
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
81
|
+
const vault = makeMockVault({
|
|
82
|
+
"acme-store.com": { type: "bearer", token: "local-token" },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const { onMiss } = createRegistryResolver({
|
|
86
|
+
store,
|
|
87
|
+
vault,
|
|
88
|
+
peerStore,
|
|
89
|
+
peerClient,
|
|
90
|
+
wrapVirtualFiles: false,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let mountedPath: string | undefined;
|
|
94
|
+
const result = await onMiss("/acme-store.com/products", (mount) => {
|
|
95
|
+
mountedPath = mount.path;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result).toBe(true);
|
|
99
|
+
expect(mountedPath).toBe("/acme-store.com");
|
|
100
|
+
// Peer should NOT have been queried since local credential was found
|
|
101
|
+
expect(peerStore.listPeers).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("no local credential, peer has domain → mounts PeerBackend", async () => {
|
|
105
|
+
const peer = makePeer();
|
|
106
|
+
const peerClient = makeMockPeerClient(async () => ({ available: true }));
|
|
107
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
108
|
+
// No vault → no local credentials
|
|
109
|
+
const { onMiss } = createRegistryResolver({
|
|
110
|
+
store,
|
|
111
|
+
peerStore,
|
|
112
|
+
peerClient,
|
|
113
|
+
wrapVirtualFiles: false,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
let mountedBackend: unknown;
|
|
117
|
+
let mountedPath: string | undefined;
|
|
118
|
+
const result = await onMiss("/acme-store.com/products", (mount) => {
|
|
119
|
+
mountedBackend = mount.backend;
|
|
120
|
+
mountedPath = mount.path;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result).toBe(true);
|
|
124
|
+
expect(mountedPath).toBe("/acme-store.com");
|
|
125
|
+
expect(mountedBackend).toBeInstanceOf(PeerBackend);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("no local credential, no peer has domain → returns false", async () => {
|
|
129
|
+
const peer = makePeer();
|
|
130
|
+
const peerClient = makeMockPeerClient(async () => ({ available: false }));
|
|
131
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
132
|
+
|
|
133
|
+
const { onMiss } = createRegistryResolver({
|
|
134
|
+
store,
|
|
135
|
+
peerStore,
|
|
136
|
+
peerClient,
|
|
137
|
+
wrapVirtualFiles: false,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Note: with no vault and peer returning unavailable, the resolver
|
|
141
|
+
// still falls through to mount a local HttpBackend (no auth).
|
|
142
|
+
// The peer fallback returns false, but the local path continues.
|
|
143
|
+
const result = await onMiss("/acme-store.com/products", () => {});
|
|
144
|
+
// Local record exists, so it mounts the local backend (without auth)
|
|
145
|
+
expect(result).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("peer with non-matching advertised domains → skips that peer", async () => {
|
|
149
|
+
const peer = makePeer({
|
|
150
|
+
advertisedDomains: ["other-api.com", "another-api.com"],
|
|
151
|
+
});
|
|
152
|
+
const queryFn = vi.fn(async () => ({ available: true }));
|
|
153
|
+
const peerClient = makeMockPeerClient(queryFn);
|
|
154
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
155
|
+
|
|
156
|
+
const { onMiss } = createRegistryResolver({
|
|
157
|
+
store,
|
|
158
|
+
peerStore,
|
|
159
|
+
peerClient,
|
|
160
|
+
wrapVirtualFiles: false,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
let mountedBackend: unknown;
|
|
164
|
+
await onMiss("/acme-store.com/products", (mount) => {
|
|
165
|
+
mountedBackend = mount.backend;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Peer's advertised domains don't include acme-store.com, so query was never called
|
|
169
|
+
expect(queryFn).not.toHaveBeenCalled();
|
|
170
|
+
// Falls through to local mount (no credential, but still mounts local backend)
|
|
171
|
+
expect(mountedBackend).not.toBeInstanceOf(PeerBackend);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("no local record at all, but peer has domain → mounts PeerBackend", async () => {
|
|
175
|
+
const peer = makePeer();
|
|
176
|
+
const peerClient = makeMockPeerClient(async () => ({ available: true }));
|
|
177
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
178
|
+
|
|
179
|
+
const { onMiss } = createRegistryResolver({
|
|
180
|
+
store,
|
|
181
|
+
peerStore,
|
|
182
|
+
peerClient,
|
|
183
|
+
wrapVirtualFiles: false,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// "unknown-api.com" is NOT in the local store
|
|
187
|
+
let mountedBackend: unknown;
|
|
188
|
+
let mountedPath: string | undefined;
|
|
189
|
+
const result = await onMiss("/unknown-api.com/data", (mount) => {
|
|
190
|
+
mountedBackend = mount.backend;
|
|
191
|
+
mountedPath = mount.path;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result).toBe(true);
|
|
195
|
+
expect(mountedPath).toBe("/unknown-api.com");
|
|
196
|
+
expect(mountedBackend).toBeInstanceOf(PeerBackend);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("no local record, no peerClient configured → returns false", async () => {
|
|
200
|
+
const { onMiss } = createRegistryResolver({
|
|
201
|
+
store,
|
|
202
|
+
wrapVirtualFiles: false,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const result = await onMiss("/unknown-api.com/data", () => {});
|
|
206
|
+
expect(result).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("no local record, peer does not have domain → returns false", async () => {
|
|
210
|
+
const peer = makePeer();
|
|
211
|
+
const peerClient = makeMockPeerClient(async () => ({ available: false }));
|
|
212
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
213
|
+
|
|
214
|
+
const { onMiss } = createRegistryResolver({
|
|
215
|
+
store,
|
|
216
|
+
peerStore,
|
|
217
|
+
peerClient,
|
|
218
|
+
wrapVirtualFiles: false,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await onMiss("/unknown-api.com/data", () => {});
|
|
222
|
+
expect(result).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("peer fallback with versioned path → mounts at versioned path", async () => {
|
|
226
|
+
const peer = makePeer();
|
|
227
|
+
const peerClient = makeMockPeerClient(async () => ({ available: true }));
|
|
228
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
229
|
+
|
|
230
|
+
const { onMiss } = createRegistryResolver({
|
|
231
|
+
store,
|
|
232
|
+
peerStore,
|
|
233
|
+
peerClient,
|
|
234
|
+
wrapVirtualFiles: false,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let mountedPath: string | undefined;
|
|
238
|
+
const result = await onMiss("/unknown-api.com@v2/data", (mount) => {
|
|
239
|
+
mountedPath = mount.path;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(result).toBe(true);
|
|
243
|
+
expect(mountedPath).toBe("/unknown-api.com@v2");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("peer fallback uses agent id when available", async () => {
|
|
247
|
+
const peer = makePeer();
|
|
248
|
+
let capturedPeerBackend: PeerBackend | undefined;
|
|
249
|
+
const peerClient = makeMockPeerClient(async () => ({ available: true }));
|
|
250
|
+
const peerStore = makeMockPeerStore([peer]);
|
|
251
|
+
|
|
252
|
+
const { onMiss } = createRegistryResolver({
|
|
253
|
+
store,
|
|
254
|
+
peerStore,
|
|
255
|
+
peerClient,
|
|
256
|
+
wrapVirtualFiles: false,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const agent = { id: "agent-42", roles: ["user"] };
|
|
260
|
+
await onMiss(
|
|
261
|
+
"/unknown-api.com/data",
|
|
262
|
+
(mount) => {
|
|
263
|
+
capturedPeerBackend = mount.backend as PeerBackend;
|
|
264
|
+
},
|
|
265
|
+
agent,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(capturedPeerBackend).toBeInstanceOf(PeerBackend);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("selects first available peer when multiple peers exist", async () => {
|
|
272
|
+
const peer1 = makePeer({ id: "peer-1", url: "https://peer1.example.com" });
|
|
273
|
+
const peer2 = makePeer({ id: "peer-2", url: "https://peer2.example.com" });
|
|
274
|
+
|
|
275
|
+
const queryFn = vi.fn(async (peer: PeerGateway) => {
|
|
276
|
+
if (peer.id === "peer-1") return { available: false };
|
|
277
|
+
return { available: true };
|
|
278
|
+
});
|
|
279
|
+
const peerClient = makeMockPeerClient(queryFn);
|
|
280
|
+
const peerStore = makeMockPeerStore([peer1, peer2]);
|
|
281
|
+
|
|
282
|
+
const { onMiss } = createRegistryResolver({
|
|
283
|
+
store,
|
|
284
|
+
peerStore,
|
|
285
|
+
peerClient,
|
|
286
|
+
wrapVirtualFiles: false,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
let mountedBackend: unknown;
|
|
290
|
+
const result = await onMiss("/unknown-api.com/data", (mount) => {
|
|
291
|
+
mountedBackend = mount.backend;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result).toBe(true);
|
|
295
|
+
expect(mountedBackend).toBeInstanceOf(PeerBackend);
|
|
296
|
+
// Both peers were queried since first returned unavailable
|
|
297
|
+
expect(queryFn).toHaveBeenCalledTimes(2);
|
|
298
|
+
});
|
|
299
|
+
});
|