@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,174 @@
1
+ import { Hono } from "hono";
2
+ import { nanoid } from "nanoid";
3
+ import type { JWK } from "jose";
4
+ import { signPublishToken } from "@nkmc/core";
5
+ import type { D1Database } from "../../d1/types.js";
6
+ import { queryDnsTxt } from "../lib/dns.js";
7
+
8
+ export interface DomainRouteOptions {
9
+ db: D1Database;
10
+ gatewayPrivateKey: JWK;
11
+ }
12
+
13
+ interface ChallengeRow {
14
+ domain: string;
15
+ challenge_code: string;
16
+ status: string;
17
+ created_at: number;
18
+ verified_at: number | null;
19
+ expires_at: number;
20
+ }
21
+
22
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
23
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
24
+
25
+ function isValidDomain(domain: string): boolean {
26
+ return /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(domain);
27
+ }
28
+
29
+ export function domainRoutes(options: DomainRouteOptions) {
30
+ const { db, gatewayPrivateKey } = options;
31
+ const app = new Hono();
32
+
33
+ // Request a DNS challenge for domain ownership
34
+ app.post("/challenge", async (c) => {
35
+ const body = await c.req.json<{ domain: string }>().catch(() => null);
36
+ const domain = body?.domain;
37
+
38
+ if (!domain || !isValidDomain(domain)) {
39
+ return c.json({ error: "Invalid or missing domain" }, 400);
40
+ }
41
+
42
+ const now = Date.now();
43
+
44
+ // Already verified and not expired → no new challenge needed
45
+ const verified = await db
46
+ .prepare(
47
+ "SELECT * FROM domain_challenges WHERE domain = ? AND status = 'verified' AND expires_at > ?",
48
+ )
49
+ .bind(domain, now)
50
+ .first<ChallengeRow>();
51
+
52
+ if (verified) {
53
+ return c.json(
54
+ {
55
+ error: "Domain already verified. Use `nkmc claim <domain> --verify` to renew your token.",
56
+ expiresAt: verified.expires_at,
57
+ },
58
+ 409,
59
+ );
60
+ }
61
+
62
+ // Check for existing unexpired pending challenge
63
+ const existing = await db
64
+ .prepare(
65
+ "SELECT * FROM domain_challenges WHERE domain = ? AND status = 'pending' AND expires_at > ?",
66
+ )
67
+ .bind(domain, now)
68
+ .first<ChallengeRow>();
69
+
70
+ if (existing) {
71
+ return c.json({
72
+ domain,
73
+ txtRecord: `_nkmc.${domain}`,
74
+ txtValue: `nkmc-verify=${existing.challenge_code}`,
75
+ expiresAt: existing.expires_at,
76
+ });
77
+ }
78
+
79
+ // Generate new challenge (also covers expired verified domains)
80
+ const challengeCode = nanoid(32);
81
+ const expiresAt = now + SEVEN_DAYS_MS;
82
+
83
+ await db
84
+ .prepare(
85
+ `INSERT OR REPLACE INTO domain_challenges (domain, challenge_code, status, created_at, expires_at)
86
+ VALUES (?, ?, 'pending', ?, ?)`,
87
+ )
88
+ .bind(domain, challengeCode, now, expiresAt)
89
+ .run();
90
+
91
+ return c.json({
92
+ domain,
93
+ txtRecord: `_nkmc.${domain}`,
94
+ txtValue: `nkmc-verify=${challengeCode}`,
95
+ expiresAt,
96
+ });
97
+ });
98
+
99
+ // Verify DNS record and issue publish token
100
+ app.post("/verify", async (c) => {
101
+ const body = await c.req.json<{ domain: string }>().catch(() => null);
102
+ const domain = body?.domain;
103
+
104
+ if (!domain || !isValidDomain(domain)) {
105
+ return c.json({ error: "Invalid or missing domain" }, 400);
106
+ }
107
+
108
+ const now = Date.now();
109
+
110
+ // 1. Already verified and not expired → issue new token without DNS check
111
+ const verified = await db
112
+ .prepare(
113
+ "SELECT * FROM domain_challenges WHERE domain = ? AND status = 'verified' AND expires_at > ?",
114
+ )
115
+ .bind(domain, now)
116
+ .first<ChallengeRow>();
117
+
118
+ if (verified) {
119
+ const publishToken = await signPublishToken(gatewayPrivateKey, domain);
120
+ return c.json({ ok: true, domain, publishToken });
121
+ }
122
+
123
+ // 2. Pending challenge → verify DNS
124
+ const challenge = await db
125
+ .prepare(
126
+ "SELECT * FROM domain_challenges WHERE domain = ? AND status = 'pending' AND expires_at > ?",
127
+ )
128
+ .bind(domain, now)
129
+ .first<ChallengeRow>();
130
+
131
+ if (!challenge) {
132
+ return c.json(
133
+ { error: "No pending challenge found. Run `nkmc claim <domain>` first." },
134
+ 404,
135
+ );
136
+ }
137
+
138
+ // Query DNS TXT records
139
+ const expectedValue = `nkmc-verify=${challenge.challenge_code}`;
140
+ let txtRecords: string[];
141
+ try {
142
+ txtRecords = await queryDnsTxt(`_nkmc.${domain}`);
143
+ } catch {
144
+ return c.json(
145
+ { error: "Failed to query DNS. Please try again later." },
146
+ 502,
147
+ );
148
+ }
149
+
150
+ if (!txtRecords.includes(expectedValue)) {
151
+ return c.json(
152
+ {
153
+ error: `DNS TXT record not found. Expected TXT record on _nkmc.${domain} with value "${expectedValue}". DNS propagation can take up to 5 minutes.`,
154
+ },
155
+ 422,
156
+ );
157
+ }
158
+
159
+ // Mark as verified (ownership valid for 1 year)
160
+ await db
161
+ .prepare(
162
+ "UPDATE domain_challenges SET status = 'verified', verified_at = ?, expires_at = ? WHERE domain = ?",
163
+ )
164
+ .bind(now, now + ONE_YEAR_MS, domain)
165
+ .run();
166
+
167
+ // Sign publish token (24h expiry, scoped to domain)
168
+ const publishToken = await signPublishToken(gatewayPrivateKey, domain);
169
+
170
+ return c.json({ ok: true, domain, publishToken });
171
+ });
172
+
173
+ return app;
174
+ }
@@ -0,0 +1,170 @@
1
+ import { Hono } from "hono";
2
+ import type { AgentFs } from "@nkmc/agent-fs";
3
+ import type { PeerStore } from "../../federation/types.js";
4
+ import type { CredentialVault } from "../../credential/types.js";
5
+ import type { Env } from "../app.js";
6
+
7
+ export interface FederationRouteOptions {
8
+ peerStore: PeerStore;
9
+ vault: CredentialVault;
10
+ agentFs: AgentFs;
11
+ }
12
+
13
+ /**
14
+ * Verify that the request comes from a known peer.
15
+ * Returns the peer ID or null if auth fails.
16
+ */
17
+ async function authenticatePeer(
18
+ peerStore: PeerStore,
19
+ peerId: string | undefined,
20
+ authHeader: string | undefined,
21
+ ): ReturnType<typeof peerStore.getPeer> {
22
+ if (!peerId || !authHeader) return null;
23
+
24
+ const peer = await peerStore.getPeer(peerId);
25
+ if (!peer || peer.status !== "active") return null;
26
+
27
+ const token = authHeader.replace(/^Bearer\s+/i, "");
28
+ if (token !== peer.sharedSecret) return null;
29
+
30
+ return peer;
31
+ }
32
+
33
+ /**
34
+ * Extract the domain from a command string.
35
+ * e.g. "ls /api.example.com/data" => "api.example.com"
36
+ * "cat /github.com/repos" => "github.com"
37
+ */
38
+ function extractDomainFromCommand(command: string): string | null {
39
+ const parts = command.trim().split(/\s+/);
40
+ if (parts.length < 2) return null;
41
+ const path = parts[1];
42
+ if (!path.startsWith("/")) return null;
43
+ const segments = path.slice(1).split("/");
44
+ return segments[0] || null;
45
+ }
46
+
47
+ export function federationRoutes(options: FederationRouteOptions) {
48
+ const { peerStore, vault, agentFs } = options;
49
+ const app = new Hono<Env>();
50
+
51
+ // POST /federation/query — Check if we have credentials for a domain
52
+ app.post("/query", async (c) => {
53
+ const peerId = c.req.header("X-Peer-Id");
54
+ const authHeader = c.req.header("Authorization");
55
+
56
+ const peer = await authenticatePeer(peerStore, peerId, authHeader);
57
+ if (!peer) {
58
+ return c.json({ error: "Unauthorized peer" }, 403);
59
+ }
60
+
61
+ const body = await c.req.json<{ domain: string }>();
62
+ if (!body.domain) {
63
+ return c.json({ error: "Missing 'domain' field" }, 400);
64
+ }
65
+
66
+ // Update last seen
67
+ await peerStore.updateLastSeen(peer.id, Date.now());
68
+
69
+ // Check if we have a credential for this domain
70
+ const credential = await vault.get(body.domain);
71
+ if (!credential) {
72
+ return c.json({ available: false });
73
+ }
74
+
75
+ // Check lending rule
76
+ const rule = await peerStore.getRule(body.domain);
77
+ if (!rule || !rule.allow) {
78
+ return c.json({ available: false });
79
+ }
80
+
81
+ // Check if this peer is in the allowed list
82
+ if (rule.peers !== "*" && !rule.peers.includes(peer.id)) {
83
+ return c.json({ available: false });
84
+ }
85
+
86
+ return c.json({
87
+ available: true,
88
+ pricing: rule.pricing,
89
+ });
90
+ });
91
+
92
+ // POST /federation/exec — Execute a command on behalf of a peer
93
+ app.post("/exec", async (c) => {
94
+ const peerId = c.req.header("X-Peer-Id");
95
+ const authHeader = c.req.header("Authorization");
96
+
97
+ const peer = await authenticatePeer(peerStore, peerId, authHeader);
98
+ if (!peer) {
99
+ return c.json({ error: "Unauthorized peer" }, 403);
100
+ }
101
+
102
+ const body = await c.req.json<{ command: string; agentId: string }>();
103
+ if (!body.command) {
104
+ return c.json({ error: "Missing 'command' field" }, 400);
105
+ }
106
+
107
+ // Update last seen
108
+ await peerStore.updateLastSeen(peer.id, Date.now());
109
+
110
+ // Extract domain from command and check lending rule
111
+ const domain = extractDomainFromCommand(body.command);
112
+ if (domain) {
113
+ const rule = await peerStore.getRule(domain);
114
+
115
+ if (!rule || !rule.allow) {
116
+ return c.json({ error: "Domain not available for lending" }, 403);
117
+ }
118
+
119
+ if (rule.peers !== "*" && !rule.peers.includes(peer.id)) {
120
+ return c.json({ error: "Peer not in allowed list" }, 403);
121
+ }
122
+
123
+ // Check if payment is required
124
+ if (rule.pricing.mode !== "free") {
125
+ const paymentHeader = c.req.header("X-402-Payment");
126
+ if (!paymentHeader) {
127
+ return c.json({ error: "Payment required" }, 402);
128
+ }
129
+ }
130
+ }
131
+
132
+ // Execute with synthetic agent context: peer:{peerId}:{agentId}
133
+ const syntheticAgentId = `peer:${peer.id}:${body.agentId}`;
134
+ const result = await agentFs.execute(body.command, ["agent"], {
135
+ id: syntheticAgentId,
136
+ roles: ["agent"],
137
+ });
138
+
139
+ if (!result.ok) {
140
+ return c.json({ ok: false, error: result.error.message }, 500);
141
+ }
142
+
143
+ return c.json({ ok: true, data: result.data });
144
+ });
145
+
146
+ // POST /federation/announce — Peer announces available domains
147
+ app.post("/announce", async (c) => {
148
+ const peerId = c.req.header("X-Peer-Id");
149
+ const authHeader = c.req.header("Authorization");
150
+
151
+ const peer = await authenticatePeer(peerStore, peerId, authHeader);
152
+ if (!peer) {
153
+ return c.json({ error: "Unauthorized peer" }, 403);
154
+ }
155
+
156
+ const body = await c.req.json<{ domains: string[] }>();
157
+ if (!Array.isArray(body.domains)) {
158
+ return c.json({ error: "Missing 'domains' field" }, 400);
159
+ }
160
+
161
+ // Update peer's advertised domains and last seen
162
+ peer.advertisedDomains = body.domains;
163
+ await peerStore.putPeer(peer);
164
+ await peerStore.updateLastSeen(peer.id, Date.now());
165
+
166
+ return c.json({ ok: true });
167
+ });
168
+
169
+ return app;
170
+ }
@@ -0,0 +1,89 @@
1
+ import { Hono } from "hono";
2
+ import type { AgentFs, FsOp } from "@nkmc/agent-fs";
3
+ import type { Env } from "../app.js";
4
+
5
+ export interface FsRouteOptions {
6
+ agentFs: AgentFs;
7
+ }
8
+
9
+ function errorToStatus(code: string): number {
10
+ switch (code) {
11
+ case "PARSE_ERROR":
12
+ case "INVALID_PATH":
13
+ return 400;
14
+ case "PERMISSION_DENIED":
15
+ return 403;
16
+ case "NOT_FOUND":
17
+ case "NO_MOUNT":
18
+ return 404;
19
+ default:
20
+ return 500;
21
+ }
22
+ }
23
+
24
+ export function fsRoutes(options: FsRouteOptions) {
25
+ const { agentFs } = options;
26
+ const app = new Hono<Env>();
27
+
28
+ // POST /execute — raw command execution
29
+ app.post("/execute", async (c) => {
30
+ const body = await c.req.json<{ command: string; roles?: string[] }>();
31
+ if (!body.command || typeof body.command !== "string") {
32
+ return c.json({ error: "Missing 'command' field" }, 400);
33
+ }
34
+
35
+ const agent = c.get("agent");
36
+ const roles = body.roles ?? agent?.roles;
37
+ const result = await agentFs.execute(body.command, roles, agent);
38
+ const status = result.ok ? 200 : errorToStatus(result.error.code);
39
+
40
+ return c.json(result, status as 200);
41
+ });
42
+
43
+ // /fs/* — REST-style access
44
+ app.all("/fs/*", async (c) => {
45
+ const fullPath = c.req.path;
46
+ const virtualPath = fullPath.slice(fullPath.indexOf("/fs") + 3) || "/";
47
+ const query = c.req.query("q");
48
+ const agent = c.get("agent");
49
+ const roles = agent?.roles;
50
+
51
+ let op: FsOp;
52
+ let data: unknown | undefined;
53
+ let pattern: string | undefined;
54
+
55
+ switch (c.req.method) {
56
+ case "GET":
57
+ if (query) {
58
+ op = "grep";
59
+ pattern = query;
60
+ } else if (virtualPath.endsWith("/")) {
61
+ op = "ls";
62
+ } else {
63
+ op = "cat";
64
+ }
65
+ break;
66
+ case "POST":
67
+ case "PUT":
68
+ op = "write";
69
+ data = await c.req.json();
70
+ break;
71
+ case "DELETE":
72
+ op = "rm";
73
+ break;
74
+ default:
75
+ return c.json({ error: "Method not allowed" }, 405);
76
+ }
77
+
78
+ const result = await agentFs.executeCommand(
79
+ { op, path: virtualPath, data, pattern },
80
+ roles,
81
+ agent,
82
+ );
83
+ const status = result.ok ? 200 : errorToStatus(result.error.code);
84
+
85
+ return c.json(result, status as 200);
86
+ });
87
+
88
+ return app;
89
+ }
@@ -0,0 +1,103 @@
1
+ import { Hono } from "hono";
2
+ import type { Env } from "../app.js";
3
+ import type { PeerStore, PeerGateway, LendingRule } from "../../federation/types.js";
4
+
5
+ export interface PeerRouteOptions {
6
+ peerStore: PeerStore;
7
+ }
8
+
9
+ export function peerRoutes(options: PeerRouteOptions) {
10
+ const { peerStore } = options;
11
+ const app = new Hono<Env>();
12
+
13
+ // GET /peers — list all peers (don't expose sharedSecret)
14
+ app.get("/peers", async (c) => {
15
+ const peers = await peerStore.listPeers();
16
+ const safe = peers.map(({ sharedSecret: _, ...rest }) => rest);
17
+ return c.json({ peers: safe });
18
+ });
19
+
20
+ // PUT /peers/:id — create or update peer
21
+ app.put("/peers/:id", async (c) => {
22
+ const id = c.req.param("id");
23
+ const body = await c.req.json<{
24
+ name?: string;
25
+ url?: string;
26
+ sharedSecret?: string;
27
+ }>();
28
+
29
+ if (!body.name || !body.url || !body.sharedSecret) {
30
+ return c.json({ error: "Missing required fields: name, url, sharedSecret" }, 400);
31
+ }
32
+
33
+ const existing = await peerStore.getPeer(id);
34
+ const now = Date.now();
35
+
36
+ const peer: PeerGateway = {
37
+ id,
38
+ name: body.name,
39
+ url: body.url,
40
+ sharedSecret: body.sharedSecret,
41
+ status: "active",
42
+ advertisedDomains: existing?.advertisedDomains ?? [],
43
+ lastSeen: existing?.lastSeen ?? 0,
44
+ createdAt: existing?.createdAt ?? now,
45
+ };
46
+
47
+ await peerStore.putPeer(peer);
48
+ return c.json({ ok: true, id });
49
+ });
50
+
51
+ // DELETE /peers/:id — remove peer
52
+ app.delete("/peers/:id", async (c) => {
53
+ const id = c.req.param("id");
54
+ await peerStore.deletePeer(id);
55
+ return c.json({ ok: true, id });
56
+ });
57
+
58
+ // GET /rules — list all lending rules
59
+ app.get("/rules", async (c) => {
60
+ const rules = await peerStore.listRules();
61
+ return c.json({ rules });
62
+ });
63
+
64
+ // PUT /rules/:domain — create or update lending rule
65
+ app.put("/rules/:domain", async (c) => {
66
+ const domain = c.req.param("domain");
67
+ const body = await c.req.json<{
68
+ allow?: boolean;
69
+ peers?: string[] | "*";
70
+ pricing?: { mode: string; amount?: number };
71
+ rateLimit?: { requests: number; window: string };
72
+ }>();
73
+
74
+ if (body.allow === undefined) {
75
+ return c.json({ error: "Missing required field: allow" }, 400);
76
+ }
77
+
78
+ const existing = await peerStore.getRule(domain);
79
+ const now = Date.now();
80
+
81
+ const rule: LendingRule = {
82
+ domain,
83
+ allow: body.allow,
84
+ peers: body.peers ?? existing?.peers ?? "*",
85
+ pricing: (body.pricing as LendingRule["pricing"]) ?? existing?.pricing ?? { mode: "free" },
86
+ rateLimit: body.rateLimit as LendingRule["rateLimit"],
87
+ createdAt: existing?.createdAt ?? now,
88
+ updatedAt: now,
89
+ };
90
+
91
+ await peerStore.putRule(rule);
92
+ return c.json({ ok: true, domain });
93
+ });
94
+
95
+ // DELETE /rules/:domain — remove lending rule
96
+ app.delete("/rules/:domain", async (c) => {
97
+ const domain = c.req.param("domain");
98
+ await peerStore.deleteRule(domain);
99
+ return c.json({ ok: true, domain });
100
+ });
101
+
102
+ return app;
103
+ }
@@ -0,0 +1,57 @@
1
+ import { Hono } from "hono";
2
+ import type { Env } from "../app.js";
3
+ import type { CredentialVault } from "../../credential/types.js";
4
+ import type { ToolRegistry } from "../../proxy/tool-registry.js";
5
+
6
+ export interface ExecResult {
7
+ stdout: string;
8
+ stderr: string;
9
+ exitCode: number;
10
+ }
11
+
12
+ export interface ProxyRouteOptions {
13
+ vault: CredentialVault;
14
+ toolRegistry: ToolRegistry;
15
+ exec: (tool: string, args: string[], env: Record<string, string>) => Promise<ExecResult>;
16
+ }
17
+
18
+ export function proxyRoutes(options: ProxyRouteOptions) {
19
+ const { vault, toolRegistry, exec } = options;
20
+ const app = new Hono<Env>();
21
+
22
+ // POST /exec — Execute a CLI tool with injected credentials
23
+ app.post("/exec", async (c) => {
24
+ const body = await c.req.json<{ tool: string; args?: string[] }>();
25
+ if (!body.tool || typeof body.tool !== "string") {
26
+ return c.json({ error: "Missing 'tool' field" }, 400);
27
+ }
28
+
29
+ const toolDef = toolRegistry.get(body.tool);
30
+ if (!toolDef) {
31
+ return c.json({ error: `Unknown tool: ${body.tool}` }, 404);
32
+ }
33
+
34
+ const agent = c.get("agent");
35
+ const credential = await vault.get(toolDef.credentialDomain, agent.id);
36
+ if (!credential) {
37
+ return c.json({ error: `No credential for domain: ${toolDef.credentialDomain}` }, 401);
38
+ }
39
+
40
+ const env = toolRegistry.buildEnv(toolDef, credential.auth);
41
+ const args = body.args ?? [];
42
+ const result = await exec(body.tool, args, env);
43
+
44
+ return c.json(result);
45
+ });
46
+
47
+ // GET /tools — List available tools
48
+ app.get("/tools", (c) => {
49
+ const tools = toolRegistry.list().map((t) => ({
50
+ name: t.name,
51
+ credentialDomain: t.credentialDomain,
52
+ }));
53
+ return c.json({ tools });
54
+ });
55
+
56
+ return app;
57
+ }