@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,542 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { MemoryTunnelStore } from "../memory-store.js";
|
|
4
|
+
import { tunnelRoutes } from "../../http/routes/tunnels.js";
|
|
5
|
+
import type { TunnelProvider, TunnelRecord } from "../types.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Mock TunnelProvider
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function createMockProvider(): TunnelProvider & {
|
|
12
|
+
createFn: ReturnType<typeof vi.fn>;
|
|
13
|
+
deleteFn: ReturnType<typeof vi.fn>;
|
|
14
|
+
} {
|
|
15
|
+
const createFn = vi.fn(async (_name: string, _hostname: string) => ({
|
|
16
|
+
tunnelId: "cf-tunnel-abc",
|
|
17
|
+
tunnelToken: "eyJhIjoiMTIzIn0.token",
|
|
18
|
+
}));
|
|
19
|
+
const deleteFn = vi.fn(async () => {});
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
create: createFn,
|
|
23
|
+
delete: deleteFn,
|
|
24
|
+
createFn,
|
|
25
|
+
deleteFn,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Test app helper — stubs agent auth
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function createTestApp(agentId = "agent-1") {
|
|
34
|
+
const store = new MemoryTunnelStore();
|
|
35
|
+
const provider = createMockProvider();
|
|
36
|
+
|
|
37
|
+
type Env = {
|
|
38
|
+
Variables: {
|
|
39
|
+
agent: { id: string; roles: string[] };
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const app = new Hono<Env>();
|
|
44
|
+
|
|
45
|
+
// Stub agent auth
|
|
46
|
+
app.use("*", async (c, next) => {
|
|
47
|
+
c.set("agent", { id: agentId, roles: ["read"] });
|
|
48
|
+
await next();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.route(
|
|
52
|
+
"/tunnels",
|
|
53
|
+
tunnelRoutes({
|
|
54
|
+
tunnelStore: store,
|
|
55
|
+
tunnelProvider: provider,
|
|
56
|
+
tunnelDomain: "tunnel.example.com",
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return { app, store, provider };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function jsonHeaders() {
|
|
64
|
+
return { "Content-Type": "application/json" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Helper to build a complete TunnelRecord with sensible defaults */
|
|
68
|
+
function makeRecord(overrides: Partial<TunnelRecord> & { id: string; agentId: string }): TunnelRecord {
|
|
69
|
+
return {
|
|
70
|
+
tunnelId: `cf-${overrides.id}`,
|
|
71
|
+
publicUrl: `https://${overrides.id}.tunnel.example.com`,
|
|
72
|
+
status: "active",
|
|
73
|
+
createdAt: Date.now(),
|
|
74
|
+
advertisedDomains: [],
|
|
75
|
+
lastSeen: Date.now(),
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// MemoryTunnelStore unit tests
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe("MemoryTunnelStore", () => {
|
|
85
|
+
it("put and get by id", async () => {
|
|
86
|
+
const store = new MemoryTunnelStore();
|
|
87
|
+
const record: TunnelRecord = makeRecord({
|
|
88
|
+
id: "t1",
|
|
89
|
+
agentId: "agent-1",
|
|
90
|
+
tunnelId: "cf-123",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await store.put(record);
|
|
94
|
+
const result = await store.get("t1");
|
|
95
|
+
expect(result).toEqual(record);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("get returns null for missing id", async () => {
|
|
99
|
+
const store = new MemoryTunnelStore();
|
|
100
|
+
expect(await store.get("nonexistent")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("getByAgent returns active tunnel for agent", async () => {
|
|
104
|
+
const store = new MemoryTunnelStore();
|
|
105
|
+
await store.put(makeRecord({
|
|
106
|
+
id: "t1",
|
|
107
|
+
agentId: "agent-1",
|
|
108
|
+
tunnelId: "cf-123",
|
|
109
|
+
}));
|
|
110
|
+
await store.put(makeRecord({
|
|
111
|
+
id: "t2",
|
|
112
|
+
agentId: "agent-2",
|
|
113
|
+
tunnelId: "cf-456",
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const result = await store.getByAgent("agent-1");
|
|
117
|
+
expect(result).not.toBeNull();
|
|
118
|
+
expect(result!.id).toBe("t1");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("getByAgent skips deleted tunnels", async () => {
|
|
122
|
+
const store = new MemoryTunnelStore();
|
|
123
|
+
await store.put(makeRecord({
|
|
124
|
+
id: "t1",
|
|
125
|
+
agentId: "agent-1",
|
|
126
|
+
tunnelId: "cf-123",
|
|
127
|
+
status: "deleted",
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
expect(await store.getByAgent("agent-1")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("delete removes record", async () => {
|
|
134
|
+
const store = new MemoryTunnelStore();
|
|
135
|
+
await store.put(makeRecord({
|
|
136
|
+
id: "t1",
|
|
137
|
+
agentId: "agent-1",
|
|
138
|
+
tunnelId: "cf-123",
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
await store.delete("t1");
|
|
142
|
+
expect(await store.get("t1")).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("list returns all records", async () => {
|
|
146
|
+
const store = new MemoryTunnelStore();
|
|
147
|
+
await store.put(makeRecord({
|
|
148
|
+
id: "t1",
|
|
149
|
+
agentId: "agent-1",
|
|
150
|
+
tunnelId: "cf-1",
|
|
151
|
+
createdAt: 1000,
|
|
152
|
+
}));
|
|
153
|
+
await store.put(makeRecord({
|
|
154
|
+
id: "t2",
|
|
155
|
+
agentId: "agent-2",
|
|
156
|
+
tunnelId: "cf-2",
|
|
157
|
+
createdAt: 2000,
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
const all = await store.list();
|
|
161
|
+
expect(all).toHaveLength(2);
|
|
162
|
+
expect(all.map((r) => r.id).sort()).toEqual(["t1", "t2"]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Tunnel route tests
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
describe("tunnel routes", () => {
|
|
171
|
+
describe("POST /tunnels/create", () => {
|
|
172
|
+
it("creates a tunnel and returns tunnelToken + publicUrl", async () => {
|
|
173
|
+
const { app, provider } = createTestApp();
|
|
174
|
+
|
|
175
|
+
const res = await app.request("/tunnels/create", {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: jsonHeaders(),
|
|
178
|
+
body: JSON.stringify({}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(res.status).toBe(201);
|
|
182
|
+
const body = await res.json();
|
|
183
|
+
expect(body).toHaveProperty("tunnelId");
|
|
184
|
+
expect(body).toHaveProperty("tunnelToken", "eyJhIjoiMTIzIn0.token");
|
|
185
|
+
expect(body.publicUrl).toMatch(/^https:\/\/.+\.tunnel\.example\.com$/);
|
|
186
|
+
|
|
187
|
+
// Provider was called
|
|
188
|
+
expect(provider.createFn).toHaveBeenCalledOnce();
|
|
189
|
+
const [name, hostname] = provider.createFn.mock.calls[0];
|
|
190
|
+
expect(name).toMatch(/^nkmc-agent-1-/);
|
|
191
|
+
expect(hostname).toMatch(/\.tunnel\.example\.com$/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("returns existing tunnel if agent already has one", async () => {
|
|
195
|
+
const { app, store } = createTestApp();
|
|
196
|
+
|
|
197
|
+
// Pre-populate an active tunnel for agent-1
|
|
198
|
+
await store.put(makeRecord({
|
|
199
|
+
id: "existing-id",
|
|
200
|
+
agentId: "agent-1",
|
|
201
|
+
tunnelId: "cf-existing",
|
|
202
|
+
publicUrl: "https://existing-id.tunnel.example.com",
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
const res = await app.request("/tunnels/create", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: jsonHeaders(),
|
|
208
|
+
body: JSON.stringify({}),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(res.status).toBe(200);
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
expect(body.tunnelId).toBe("existing-id");
|
|
214
|
+
expect(body.publicUrl).toBe("https://existing-id.tunnel.example.com");
|
|
215
|
+
expect(body.message).toBe("Tunnel already exists");
|
|
216
|
+
// Should NOT have tunnelToken — we don't re-issue it
|
|
217
|
+
expect(body).not.toHaveProperty("tunnelToken");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("stores advertisedDomains and gatewayName from create request", async () => {
|
|
221
|
+
const { app, store } = createTestApp();
|
|
222
|
+
|
|
223
|
+
const res = await app.request("/tunnels/create", {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: jsonHeaders(),
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
advertisedDomains: ["api.openai.com", "api.github.com"],
|
|
228
|
+
gatewayName: "Alice's Gateway",
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(res.status).toBe(201);
|
|
233
|
+
const body = await res.json();
|
|
234
|
+
|
|
235
|
+
// Verify the store has the advertised domains
|
|
236
|
+
const record = await store.get(body.tunnelId);
|
|
237
|
+
expect(record).not.toBeNull();
|
|
238
|
+
expect(record!.advertisedDomains).toEqual(["api.openai.com", "api.github.com"]);
|
|
239
|
+
expect(record!.gatewayName).toBe("Alice's Gateway");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("DELETE /tunnels/:id", () => {
|
|
244
|
+
it("deletes the agent's tunnel", async () => {
|
|
245
|
+
const { app, store, provider } = createTestApp();
|
|
246
|
+
|
|
247
|
+
await store.put(makeRecord({
|
|
248
|
+
id: "t1",
|
|
249
|
+
agentId: "agent-1",
|
|
250
|
+
tunnelId: "cf-del",
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
const res = await app.request("/tunnels/t1", {
|
|
254
|
+
method: "DELETE",
|
|
255
|
+
headers: jsonHeaders(),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(res.status).toBe(200);
|
|
259
|
+
const body = await res.json();
|
|
260
|
+
expect(body).toEqual({ ok: true });
|
|
261
|
+
|
|
262
|
+
// Provider delete was called with the CF tunnel ID
|
|
263
|
+
expect(provider.deleteFn).toHaveBeenCalledWith("cf-del");
|
|
264
|
+
|
|
265
|
+
// Store no longer has the record
|
|
266
|
+
expect(await store.get("t1")).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("returns 404 for nonexistent tunnel", async () => {
|
|
270
|
+
const { app } = createTestApp();
|
|
271
|
+
|
|
272
|
+
const res = await app.request("/tunnels/nonexistent", {
|
|
273
|
+
method: "DELETE",
|
|
274
|
+
headers: jsonHeaders(),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(res.status).toBe(404);
|
|
278
|
+
const body = await res.json();
|
|
279
|
+
expect(body.error).toBe("Tunnel not found");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns 403 when deleting another agent's tunnel", async () => {
|
|
283
|
+
const { app, store } = createTestApp("agent-1");
|
|
284
|
+
|
|
285
|
+
// Tunnel belongs to agent-2
|
|
286
|
+
await store.put(makeRecord({
|
|
287
|
+
id: "t-other",
|
|
288
|
+
agentId: "agent-2",
|
|
289
|
+
tunnelId: "cf-other",
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
const res = await app.request("/tunnels/t-other", {
|
|
293
|
+
method: "DELETE",
|
|
294
|
+
headers: jsonHeaders(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(res.status).toBe(403);
|
|
298
|
+
const body = await res.json();
|
|
299
|
+
expect(body.error).toBe("Not your tunnel");
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("GET /tunnels", () => {
|
|
304
|
+
it("lists only the authenticated agent's tunnels", async () => {
|
|
305
|
+
const { app, store } = createTestApp("agent-1");
|
|
306
|
+
|
|
307
|
+
await store.put(makeRecord({
|
|
308
|
+
id: "t1",
|
|
309
|
+
agentId: "agent-1",
|
|
310
|
+
tunnelId: "cf-1",
|
|
311
|
+
createdAt: 1000,
|
|
312
|
+
}));
|
|
313
|
+
await store.put(makeRecord({
|
|
314
|
+
id: "t2",
|
|
315
|
+
agentId: "agent-2",
|
|
316
|
+
tunnelId: "cf-2",
|
|
317
|
+
createdAt: 2000,
|
|
318
|
+
}));
|
|
319
|
+
await store.put(makeRecord({
|
|
320
|
+
id: "t3",
|
|
321
|
+
agentId: "agent-1",
|
|
322
|
+
tunnelId: "cf-3",
|
|
323
|
+
status: "deleted",
|
|
324
|
+
createdAt: 3000,
|
|
325
|
+
}));
|
|
326
|
+
|
|
327
|
+
const res = await app.request("/tunnels", {
|
|
328
|
+
headers: jsonHeaders(),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(res.status).toBe(200);
|
|
332
|
+
const body = await res.json();
|
|
333
|
+
// agent-1 has t1 (active) and t3 (deleted) — both returned, filtered by agentId
|
|
334
|
+
expect(body.tunnels).toHaveLength(2);
|
|
335
|
+
expect(body.tunnels.map((t: any) => t.id).sort()).toEqual(["t1", "t3"]);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
// Discovery tests
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
describe("GET /tunnels/discover", () => {
|
|
344
|
+
it("returns all active gateways", async () => {
|
|
345
|
+
const { app, store } = createTestApp();
|
|
346
|
+
|
|
347
|
+
await store.put(makeRecord({
|
|
348
|
+
id: "gw-1",
|
|
349
|
+
agentId: "agent-1",
|
|
350
|
+
advertisedDomains: ["api.openai.com"],
|
|
351
|
+
gatewayName: "Alice",
|
|
352
|
+
}));
|
|
353
|
+
await store.put(makeRecord({
|
|
354
|
+
id: "gw-2",
|
|
355
|
+
agentId: "agent-2",
|
|
356
|
+
advertisedDomains: ["api.github.com"],
|
|
357
|
+
gatewayName: "Bob",
|
|
358
|
+
}));
|
|
359
|
+
// Deleted tunnel should not appear
|
|
360
|
+
await store.put(makeRecord({
|
|
361
|
+
id: "gw-3",
|
|
362
|
+
agentId: "agent-3",
|
|
363
|
+
status: "deleted",
|
|
364
|
+
advertisedDomains: ["api.openai.com"],
|
|
365
|
+
}));
|
|
366
|
+
|
|
367
|
+
const res = await app.request("/tunnels/discover", {
|
|
368
|
+
headers: jsonHeaders(),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(res.status).toBe(200);
|
|
372
|
+
const body = await res.json();
|
|
373
|
+
expect(body.gateways).toHaveLength(2);
|
|
374
|
+
expect(body.gateways.map((g: any) => g.id).sort()).toEqual(["gw-1", "gw-2"]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("filters by advertised domain", async () => {
|
|
378
|
+
const { app, store } = createTestApp();
|
|
379
|
+
|
|
380
|
+
await store.put(makeRecord({
|
|
381
|
+
id: "gw-1",
|
|
382
|
+
agentId: "agent-1",
|
|
383
|
+
advertisedDomains: ["api.openai.com", "api.anthropic.com"],
|
|
384
|
+
gatewayName: "Alice",
|
|
385
|
+
}));
|
|
386
|
+
await store.put(makeRecord({
|
|
387
|
+
id: "gw-2",
|
|
388
|
+
agentId: "agent-2",
|
|
389
|
+
advertisedDomains: ["api.github.com"],
|
|
390
|
+
gatewayName: "Bob",
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
const res = await app.request("/tunnels/discover?domain=api.openai.com", {
|
|
394
|
+
headers: jsonHeaders(),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(res.status).toBe(200);
|
|
398
|
+
const body = await res.json();
|
|
399
|
+
expect(body.gateways).toHaveLength(1);
|
|
400
|
+
expect(body.gateways[0].id).toBe("gw-1");
|
|
401
|
+
expect(body.gateways[0].advertisedDomains).toEqual(["api.openai.com", "api.anthropic.com"]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("returns empty array when no gateways match domain filter", async () => {
|
|
405
|
+
const { app, store } = createTestApp();
|
|
406
|
+
|
|
407
|
+
await store.put(makeRecord({
|
|
408
|
+
id: "gw-1",
|
|
409
|
+
agentId: "agent-1",
|
|
410
|
+
advertisedDomains: ["api.openai.com"],
|
|
411
|
+
}));
|
|
412
|
+
|
|
413
|
+
const res = await app.request("/tunnels/discover?domain=api.stripe.com", {
|
|
414
|
+
headers: jsonHeaders(),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
expect(res.status).toBe(200);
|
|
418
|
+
const body = await res.json();
|
|
419
|
+
expect(body.gateways).toHaveLength(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("does not expose tunnelToken or sensitive data", async () => {
|
|
423
|
+
const { app, store } = createTestApp();
|
|
424
|
+
|
|
425
|
+
await store.put(makeRecord({
|
|
426
|
+
id: "gw-1",
|
|
427
|
+
agentId: "agent-1",
|
|
428
|
+
tunnelId: "cf-secret-123",
|
|
429
|
+
advertisedDomains: ["api.openai.com"],
|
|
430
|
+
gatewayName: "Alice",
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
const res = await app.request("/tunnels/discover", {
|
|
434
|
+
headers: jsonHeaders(),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
expect(res.status).toBe(200);
|
|
438
|
+
const body = await res.json();
|
|
439
|
+
expect(body.gateways).toHaveLength(1);
|
|
440
|
+
const gw = body.gateways[0];
|
|
441
|
+
|
|
442
|
+
// Should have public fields
|
|
443
|
+
expect(gw).toHaveProperty("id");
|
|
444
|
+
expect(gw).toHaveProperty("name");
|
|
445
|
+
expect(gw).toHaveProperty("publicUrl");
|
|
446
|
+
expect(gw).toHaveProperty("advertisedDomains");
|
|
447
|
+
|
|
448
|
+
// Should NOT have sensitive fields
|
|
449
|
+
expect(gw).not.toHaveProperty("tunnelId");
|
|
450
|
+
expect(gw).not.toHaveProperty("tunnelToken");
|
|
451
|
+
expect(gw).not.toHaveProperty("agentId");
|
|
452
|
+
expect(gw).not.toHaveProperty("lastSeen");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("uses default name when gatewayName is not set", async () => {
|
|
456
|
+
const { app, store } = createTestApp();
|
|
457
|
+
|
|
458
|
+
await store.put(makeRecord({
|
|
459
|
+
id: "gw-1",
|
|
460
|
+
agentId: "agent-1",
|
|
461
|
+
advertisedDomains: [],
|
|
462
|
+
}));
|
|
463
|
+
|
|
464
|
+
const res = await app.request("/tunnels/discover", {
|
|
465
|
+
headers: jsonHeaders(),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const body = await res.json();
|
|
469
|
+
expect(body.gateways[0].name).toBe("gateway-gw-1");
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// -------------------------------------------------------------------------
|
|
474
|
+
// Heartbeat tests
|
|
475
|
+
// -------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
describe("POST /tunnels/heartbeat", () => {
|
|
478
|
+
it("updates advertised domains and lastSeen", async () => {
|
|
479
|
+
const { app, store } = createTestApp("agent-1");
|
|
480
|
+
|
|
481
|
+
const earlyTime = Date.now() - 60_000;
|
|
482
|
+
await store.put(makeRecord({
|
|
483
|
+
id: "gw-1",
|
|
484
|
+
agentId: "agent-1",
|
|
485
|
+
advertisedDomains: ["api.openai.com"],
|
|
486
|
+
lastSeen: earlyTime,
|
|
487
|
+
}));
|
|
488
|
+
|
|
489
|
+
const res = await app.request("/tunnels/heartbeat", {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: jsonHeaders(),
|
|
492
|
+
body: JSON.stringify({
|
|
493
|
+
advertisedDomains: ["api.openai.com", "api.github.com"],
|
|
494
|
+
}),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
expect(res.status).toBe(200);
|
|
498
|
+
const body = await res.json();
|
|
499
|
+
expect(body).toEqual({ ok: true });
|
|
500
|
+
|
|
501
|
+
// Verify store was updated
|
|
502
|
+
const record = await store.get("gw-1");
|
|
503
|
+
expect(record!.advertisedDomains).toEqual(["api.openai.com", "api.github.com"]);
|
|
504
|
+
expect(record!.lastSeen).toBeGreaterThan(earlyTime);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("preserves existing domains when none provided in heartbeat", async () => {
|
|
508
|
+
const { app, store } = createTestApp("agent-1");
|
|
509
|
+
|
|
510
|
+
await store.put(makeRecord({
|
|
511
|
+
id: "gw-1",
|
|
512
|
+
agentId: "agent-1",
|
|
513
|
+
advertisedDomains: ["api.openai.com"],
|
|
514
|
+
}));
|
|
515
|
+
|
|
516
|
+
const res = await app.request("/tunnels/heartbeat", {
|
|
517
|
+
method: "POST",
|
|
518
|
+
headers: jsonHeaders(),
|
|
519
|
+
body: JSON.stringify({}),
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(res.status).toBe(200);
|
|
523
|
+
|
|
524
|
+
const record = await store.get("gw-1");
|
|
525
|
+
expect(record!.advertisedDomains).toEqual(["api.openai.com"]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("returns 404 when agent has no active tunnel", async () => {
|
|
529
|
+
const { app } = createTestApp("agent-1");
|
|
530
|
+
|
|
531
|
+
const res = await app.request("/tunnels/heartbeat", {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers: jsonHeaders(),
|
|
534
|
+
body: JSON.stringify({}),
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
expect(res.status).toBe(404);
|
|
538
|
+
const body = await res.json();
|
|
539
|
+
expect(body.error).toBe("No active tunnel");
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { TunnelProvider } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const CF_API = "https://api.cloudflare.com/client/v4";
|
|
4
|
+
|
|
5
|
+
interface CfApiResponse<T = unknown> {
|
|
6
|
+
success: boolean;
|
|
7
|
+
errors: Array<{ code: number; message: string }>;
|
|
8
|
+
result: T;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CloudflareTunnelProvider implements TunnelProvider {
|
|
12
|
+
constructor(
|
|
13
|
+
private accountId: string,
|
|
14
|
+
private apiToken: string,
|
|
15
|
+
private tunnelDomain: string, // e.g. "tunnel.example.com"
|
|
16
|
+
private zoneId: string, // Cloudflare zone ID for DNS records
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async create(
|
|
20
|
+
name: string,
|
|
21
|
+
hostname: string,
|
|
22
|
+
): Promise<{ tunnelId: string; tunnelToken: string }> {
|
|
23
|
+
// 1. Generate a random tunnel secret (32 bytes, base64-encoded)
|
|
24
|
+
const secretBytes = new Uint8Array(32);
|
|
25
|
+
crypto.getRandomValues(secretBytes);
|
|
26
|
+
const tunnelSecret = btoa(String.fromCharCode(...secretBytes));
|
|
27
|
+
|
|
28
|
+
// 2. Create tunnel via CF API
|
|
29
|
+
const tunnelRes = await this.cfFetch<{ id: string }>(
|
|
30
|
+
`/accounts/${this.accountId}/cfd_tunnel`,
|
|
31
|
+
{
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
name,
|
|
35
|
+
tunnel_secret: tunnelSecret,
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
const tunnelId = tunnelRes.id;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// 3. Create DNS CNAME record: hostname -> tunnelId.cfargotunnel.com
|
|
43
|
+
await this.cfFetch(`/zones/${this.zoneId}/dns_records`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
type: "CNAME",
|
|
47
|
+
name: hostname,
|
|
48
|
+
content: `${tunnelId}.cfargotunnel.com`,
|
|
49
|
+
proxied: true,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 4. Create tunnel ingress config
|
|
54
|
+
await this.cfFetch(
|
|
55
|
+
`/accounts/${this.accountId}/cfd_tunnel/${tunnelId}/configurations`,
|
|
56
|
+
{
|
|
57
|
+
method: "PUT",
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
config: {
|
|
60
|
+
ingress: [
|
|
61
|
+
{ hostname, service: "http://localhost:9090" },
|
|
62
|
+
{ service: "http_status:404" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// 5. Get tunnel token
|
|
70
|
+
const tokenRes = await this.cfFetch<string>(
|
|
71
|
+
`/accounts/${this.accountId}/cfd_tunnel/${tunnelId}/token`,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return { tunnelId, tunnelToken: tokenRes };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Clean up tunnel if subsequent steps fail
|
|
77
|
+
await this.cfFetch(
|
|
78
|
+
`/accounts/${this.accountId}/cfd_tunnel/${tunnelId}?cascade=true`,
|
|
79
|
+
{ method: "DELETE" },
|
|
80
|
+
).catch(() => {}); // best-effort cleanup
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async delete(tunnelId: string): Promise<void> {
|
|
86
|
+
// 1. Clean up DNS records pointing to this tunnel
|
|
87
|
+
const dnsRecords = await this.cfFetch<Array<{ id: string; content: string }>>(
|
|
88
|
+
`/zones/${this.zoneId}/dns_records?type=CNAME&content=${tunnelId}.cfargotunnel.com`,
|
|
89
|
+
);
|
|
90
|
+
for (const record of dnsRecords) {
|
|
91
|
+
await this.cfFetch(`/zones/${this.zoneId}/dns_records/${record.id}`, {
|
|
92
|
+
method: "DELETE",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Delete tunnel with cascade (cleans up connections)
|
|
97
|
+
await this.cfFetch(
|
|
98
|
+
`/accounts/${this.accountId}/cfd_tunnel/${tunnelId}?cascade=true`,
|
|
99
|
+
{ method: "DELETE" },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async cfFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
104
|
+
const res = await fetch(`${CF_API}${path}`, {
|
|
105
|
+
...init,
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
...init?.headers,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const data = (await res.json()) as CfApiResponse<T>;
|
|
114
|
+
if (!data.success) {
|
|
115
|
+
const msg = data.errors.map((e) => e.message).join(", ");
|
|
116
|
+
throw new Error(`Cloudflare API error: ${msg}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return data.result;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TunnelRecord, TunnelStore } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class MemoryTunnelStore implements TunnelStore {
|
|
4
|
+
private records = new Map<string, TunnelRecord>();
|
|
5
|
+
|
|
6
|
+
async get(id: string): Promise<TunnelRecord | null> {
|
|
7
|
+
return this.records.get(id) ?? null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async getByAgent(agentId: string): Promise<TunnelRecord | null> {
|
|
11
|
+
for (const record of this.records.values()) {
|
|
12
|
+
if (record.agentId === agentId && record.status === "active") {
|
|
13
|
+
return record;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async put(record: TunnelRecord): Promise<void> {
|
|
20
|
+
this.records.set(record.id, record);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async delete(id: string): Promise<void> {
|
|
24
|
+
this.records.delete(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async list(): Promise<TunnelRecord[]> {
|
|
28
|
+
return Array.from(this.records.values());
|
|
29
|
+
}
|
|
30
|
+
}
|