@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,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
+ }