@nkmc/server 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.
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { startServer, type ServerHandle } from "../src/server.js";
7
+ import type { ServerConfig } from "../src/config.js";
8
+
9
+ /**
10
+ * Full end-to-end integration test for the Nakamichi gateway.
11
+ *
12
+ * Exercises the complete flow: start gateway -> auth -> browse services
13
+ * -> set credentials -> proxy call -> federation admin.
14
+ *
15
+ * Uses a random high port and an isolated temp data directory so tests
16
+ * never collide with a real instance.
17
+ */
18
+ describe("Gateway E2E", () => {
19
+ const tmpDir = join(tmpdir(), `nkmc-e2e-${randomUUID()}`);
20
+ const randomPort = 19_000 + Math.floor(Math.random() * 10_000);
21
+
22
+ let handle: ServerHandle;
23
+ let adminToken: string;
24
+ let agentToken: string;
25
+
26
+ function url(path: string): string {
27
+ return `http://127.0.0.1:${handle.port}${path}`;
28
+ }
29
+
30
+ const config: ServerConfig = {
31
+ port: randomPort,
32
+ host: "127.0.0.1",
33
+ dataDir: tmpDir,
34
+ };
35
+
36
+ beforeAll(async () => {
37
+ handle = await startServer({ config, silent: true });
38
+ // Read the auto-generated admin token from the data directory
39
+ adminToken = readFileSync(join(tmpDir, "admin-token"), "utf-8").trim();
40
+ });
41
+
42
+ afterAll(() => {
43
+ try {
44
+ handle?.close();
45
+ } catch {
46
+ // ignore
47
+ }
48
+ rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+
51
+ // ── Test 1: Auth flow ─────────────────────────────────────────
52
+ it("POST /auth/token returns a JWT for an agent", async () => {
53
+ const res = await fetch(url("/auth/token"), {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({
57
+ sub: "test-agent",
58
+ svc: "gateway",
59
+ roles: ["agent"],
60
+ }),
61
+ });
62
+ expect(res.ok).toBe(true);
63
+ const body = await res.json();
64
+ expect(body).toHaveProperty("token");
65
+ expect(typeof body.token).toBe("string");
66
+ expect(body.token.length).toBeGreaterThan(0);
67
+
68
+ // Save for subsequent tests
69
+ agentToken = body.token;
70
+ });
71
+
72
+ // ── Test 2: JWKS ──────────────────────────────────────────────
73
+ it("GET /.well-known/jwks.json returns valid JWKS", async () => {
74
+ const res = await fetch(url("/.well-known/jwks.json"));
75
+ expect(res.ok).toBe(true);
76
+ const jwks = await res.json();
77
+ expect(jwks.keys).toHaveLength(1);
78
+ expect(jwks.keys[0]).toHaveProperty("kty");
79
+ expect(jwks.keys[0]).toHaveProperty("kid");
80
+ });
81
+
82
+ // ── Test 3: Register a service ────────────────────────────────
83
+ it("POST /registry/services registers a new service", async () => {
84
+ const skillMd = [
85
+ "---",
86
+ "name: Test API",
87
+ "description: A test service for e2e",
88
+ "version: v1",
89
+ "baseUrl: https://httpbin.org",
90
+ "---",
91
+ "## API",
92
+ "### Get anything",
93
+ "`GET /anything`",
94
+ "",
95
+ ].join("\n");
96
+
97
+ const res = await fetch(url("/registry/services?domain=test-api.example.com"), {
98
+ method: "POST",
99
+ headers: {
100
+ Authorization: `Bearer ${adminToken}`,
101
+ "Content-Type": "application/json",
102
+ },
103
+ body: JSON.stringify({ skillMd }),
104
+ });
105
+
106
+ expect(res.status).toBe(201);
107
+ const body = await res.json();
108
+ expect(body.ok).toBe(true);
109
+ expect(body.domain).toBe("test-api.example.com");
110
+ expect(body.name).toBe("Test API");
111
+ });
112
+
113
+ // ── Test 4: Browse via /fs/ ───────────────────────────────────
114
+ it("GET /fs/ lists registered services", async () => {
115
+ const res = await fetch(url("/fs/"), {
116
+ headers: { Authorization: `Bearer ${agentToken}` },
117
+ });
118
+ expect(res.ok).toBe(true);
119
+ const body = await res.json();
120
+ // The response should contain our registered domain
121
+ // AgentFs ls at root returns directory listings
122
+ expect(body.ok).toBe(true);
123
+ const entries = body.result ?? body.data;
124
+ const names = Array.isArray(entries)
125
+ ? entries.map((e: { name?: string }) => e.name ?? e)
126
+ : [];
127
+ // Directory entries may include a trailing slash
128
+ const normalized = names.map((n: string) => n.replace(/\/$/, ""));
129
+ expect(normalized).toContain("test-api.example.com");
130
+ });
131
+
132
+ // ── Test 5: Set credential and call API ───────────────────────
133
+ it("PUT /credentials/:domain sets a pool credential", async () => {
134
+ const res = await fetch(url("/credentials/test-api.example.com"), {
135
+ method: "PUT",
136
+ headers: {
137
+ Authorization: `Bearer ${adminToken}`,
138
+ "Content-Type": "application/json",
139
+ },
140
+ body: JSON.stringify({
141
+ auth: { type: "bearer", token: "test-token-xyz" },
142
+ }),
143
+ });
144
+ expect(res.ok).toBe(true);
145
+ const body = await res.json();
146
+ expect(body.ok).toBe(true);
147
+ });
148
+
149
+ it("GET /fs/test-api.example.com/anything proxies to the backend", async () => {
150
+ const res = await fetch(url("/fs/test-api.example.com/anything"), {
151
+ headers: { Authorization: `Bearer ${agentToken}` },
152
+ });
153
+ // The gateway should attempt to proxy to httpbin.org/anything.
154
+ // Even if the external call fails (e.g. in CI without internet),
155
+ // we verify the gateway accepted the request and tried to proxy it.
156
+ // A 200 means httpbin responded; a 502/500 means the gateway tried but
157
+ // the upstream was unreachable. Both prove the routing works.
158
+ expect([200, 502, 500]).toContain(res.status);
159
+ });
160
+
161
+ // ── Test 6: Proxy tools endpoint ──────────────────────────────
162
+ it("GET /proxy/tools lists available CLI tools", async () => {
163
+ const res = await fetch(url("/proxy/tools"), {
164
+ headers: { Authorization: `Bearer ${agentToken}` },
165
+ });
166
+ expect(res.ok).toBe(true);
167
+ const body = await res.json();
168
+ expect(body.tools.length).toBeGreaterThan(0);
169
+ // Should contain well-known tools
170
+ const toolNames = body.tools.map((t: { name: string }) => t.name);
171
+ expect(toolNames).toContain("gh");
172
+ expect(toolNames).toContain("stripe");
173
+ });
174
+
175
+ // ── Test 7: Federation admin (peers + rules) ──────────────────
176
+ describe("Federation admin", () => {
177
+ it("PUT /admin/federation/peers/:id creates a peer", async () => {
178
+ const res = await fetch(url("/admin/federation/peers/peer-1"), {
179
+ method: "PUT",
180
+ headers: {
181
+ Authorization: `Bearer ${adminToken}`,
182
+ "Content-Type": "application/json",
183
+ },
184
+ body: JSON.stringify({
185
+ name: "Test Peer",
186
+ url: "http://localhost:9999",
187
+ sharedSecret: "secret-abc",
188
+ }),
189
+ });
190
+ expect(res.ok).toBe(true);
191
+ const body = await res.json();
192
+ expect(body.ok).toBe(true);
193
+ });
194
+
195
+ it("GET /admin/federation/peers lists peers without sharedSecret", async () => {
196
+ const res = await fetch(url("/admin/federation/peers"), {
197
+ headers: { Authorization: `Bearer ${adminToken}` },
198
+ });
199
+ expect(res.ok).toBe(true);
200
+ const body = await res.json();
201
+ expect(body.peers).toHaveLength(1);
202
+ expect(body.peers[0].id).toBe("peer-1");
203
+ expect(body.peers[0].name).toBe("Test Peer");
204
+ // sharedSecret should NOT be exposed in the list response
205
+ expect(body.peers[0].sharedSecret).toBeUndefined();
206
+ });
207
+
208
+ it("PUT /admin/federation/rules/:domain sets a lending rule", async () => {
209
+ const res = await fetch(url("/admin/federation/rules/test-api.example.com"), {
210
+ method: "PUT",
211
+ headers: {
212
+ Authorization: `Bearer ${adminToken}`,
213
+ "Content-Type": "application/json",
214
+ },
215
+ body: JSON.stringify({
216
+ allow: true,
217
+ peers: "*",
218
+ pricing: { mode: "free" },
219
+ }),
220
+ });
221
+ expect(res.ok).toBe(true);
222
+ const body = await res.json();
223
+ expect(body.ok).toBe(true);
224
+ });
225
+
226
+ it("GET /admin/federation/rules lists lending rules", async () => {
227
+ const res = await fetch(url("/admin/federation/rules"), {
228
+ headers: { Authorization: `Bearer ${adminToken}` },
229
+ });
230
+ expect(res.ok).toBe(true);
231
+ const body = await res.json();
232
+ expect(body.rules).toHaveLength(1);
233
+ expect(body.rules[0].domain).toBe("test-api.example.com");
234
+ expect(body.rules[0].allow).toBe(true);
235
+ });
236
+ });
237
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createExec } from "../src/exec.js";
3
+
4
+ describe("createExec()", () => {
5
+ const exec = createExec({ timeout: 10_000 });
6
+
7
+ it("executes a simple command and returns stdout", async () => {
8
+ const result = await exec("echo", ["hello"], {});
9
+ expect(result.stdout.trim()).toBe("hello");
10
+ expect(result.exitCode).toBe(0);
11
+ });
12
+
13
+ it("injects env vars into the spawned process", async () => {
14
+ const result = await exec(
15
+ "node",
16
+ ["-e", "process.stdout.write(process.env.TEST_VAR)"],
17
+ { TEST_VAR: "injected-value" },
18
+ );
19
+ expect(result.stdout).toBe("injected-value");
20
+ expect(result.exitCode).toBe(0);
21
+ });
22
+
23
+ it("returns exitCode=0 on success", async () => {
24
+ const result = await exec("node", ["-e", "process.exit(0)"], {});
25
+ expect(result.exitCode).toBe(0);
26
+ });
27
+
28
+ it("returns non-zero exitCode on failure", async () => {
29
+ const result = await exec("node", ["-e", "process.exit(42)"], {});
30
+ expect(result.exitCode).toBe(42);
31
+ });
32
+
33
+ it("captures stderr output", async () => {
34
+ const result = await exec(
35
+ "node",
36
+ ["-e", 'process.stderr.write("oops")'],
37
+ {},
38
+ );
39
+ expect(result.stderr).toBe("oops");
40
+ });
41
+
42
+ it("captures both stdout and stderr together", async () => {
43
+ const result = await exec(
44
+ "node",
45
+ ["-e", 'process.stdout.write("out"); process.stderr.write("err")'],
46
+ {},
47
+ );
48
+ expect(result.stdout).toBe("out");
49
+ expect(result.stderr).toBe("err");
50
+ expect(result.exitCode).toBe(0);
51
+ });
52
+
53
+ it("returns error when command does not exist", async () => {
54
+ const result = await exec("nonexistent-binary-xyz", [], {});
55
+ expect(result.exitCode).not.toBe(0);
56
+ expect(result.stderr.length).toBeGreaterThan(0);
57
+ });
58
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, afterAll } from "vitest";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { startServer, type ServerHandle } from "../src/server.js";
7
+ import type { ServerConfig } from "../src/config.js";
8
+
9
+ /**
10
+ * Integration tests for startServer().
11
+ *
12
+ * Uses a random high port and an isolated temp data directory so
13
+ * tests never collide with a real instance.
14
+ */
15
+ describe("startServer() integration", () => {
16
+ const tmpDir = join(tmpdir(), `nkmc-server-test-${randomUUID()}`);
17
+ let handle: ServerHandle;
18
+
19
+ // Pick a random port in the ephemeral range to avoid collisions
20
+ const randomPort = 10_000 + Math.floor(Math.random() * 50_000);
21
+
22
+ const config: ServerConfig = {
23
+ port: randomPort,
24
+ host: "127.0.0.1",
25
+ dataDir: tmpDir,
26
+ adminToken: `test-admin-${randomUUID()}`,
27
+ };
28
+
29
+ // Start the server once for all tests in this suite
30
+ // We use a manual setup instead of beforeAll because startServer is async
31
+ // and we need to ensure sequential test execution anyway.
32
+ let startPromise: Promise<void> | undefined;
33
+
34
+ function ensureStarted(): Promise<void> {
35
+ if (!startPromise) {
36
+ startPromise = (async () => {
37
+ mkdirSync(tmpDir, { recursive: true });
38
+ handle = await startServer({ config, silent: true });
39
+ })();
40
+ }
41
+ return startPromise;
42
+ }
43
+
44
+ afterAll(() => {
45
+ try {
46
+ handle?.close();
47
+ } catch {
48
+ // ignore
49
+ }
50
+ rmSync(tmpDir, { recursive: true, force: true });
51
+ });
52
+
53
+ function baseUrl(): string {
54
+ return `http://127.0.0.1:${handle.port}`;
55
+ }
56
+
57
+ it("starts and exposes the correct port", async () => {
58
+ await ensureStarted();
59
+ expect(handle.port).toBe(randomPort);
60
+ });
61
+
62
+ it("GET /.well-known/jwks.json returns valid JWKS", async () => {
63
+ await ensureStarted();
64
+ const res = await fetch(`${baseUrl()}/.well-known/jwks.json`);
65
+ expect(res.status).toBe(200);
66
+ const json = await res.json();
67
+ expect(json).toHaveProperty("keys");
68
+ expect(Array.isArray(json.keys)).toBe(true);
69
+ expect(json.keys.length).toBeGreaterThan(0);
70
+ // Each key should have standard JWK fields
71
+ const key = json.keys[0];
72
+ expect(key).toHaveProperty("kty");
73
+ expect(key).toHaveProperty("kid");
74
+ });
75
+
76
+ it("POST /auth/token with valid body returns a token", async () => {
77
+ await ensureStarted();
78
+ const res = await fetch(`${baseUrl()}/auth/token`, {
79
+ method: "POST",
80
+ headers: {
81
+ "Content-Type": "application/json",
82
+ Authorization: `Bearer ${config.adminToken}`,
83
+ },
84
+ body: JSON.stringify({ sub: "test-agent", svc: "example.com" }),
85
+ });
86
+ expect(res.status).toBe(200);
87
+ const json = await res.json();
88
+ expect(json).toHaveProperty("token");
89
+ expect(typeof json.token).toBe("string");
90
+ expect(json.token.length).toBeGreaterThan(0);
91
+ });
92
+
93
+ it("POST /auth/token with missing fields returns 400", async () => {
94
+ await ensureStarted();
95
+ const res = await fetch(`${baseUrl()}/auth/token`, {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ Authorization: `Bearer ${config.adminToken}`,
100
+ },
101
+ body: JSON.stringify({}),
102
+ });
103
+ expect(res.status).toBe(400);
104
+ });
105
+
106
+ it("close() cleanly shuts down the server", async () => {
107
+ await ensureStarted();
108
+ // Verify the server is responding
109
+ const res = await fetch(`${baseUrl()}/.well-known/jwks.json`);
110
+ expect(res.ok).toBe(true);
111
+
112
+ // Close the server
113
+ handle.close();
114
+
115
+ // After closing, requests should fail
116
+ await expect(
117
+ fetch(`${baseUrl()}/.well-known/jwks.json`).then((r) => r.json()),
118
+ ).rejects.toThrow();
119
+ });
120
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "declaration": true,
11
+ "resolveJsonModule": true
12
+ },
13
+ "include": ["src"]
14
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig([
4
+ // CLI binary entry
5
+ {
6
+ entry: { index: "src/index.ts" },
7
+ format: ["esm"],
8
+ banner: { js: "#!/usr/bin/env node" },
9
+ clean: true,
10
+ external: ["better-sqlite3"],
11
+ },
12
+ // Library exports (for embedding in CLI)
13
+ {
14
+ entry: { server: "src/server.ts", config: "src/config.ts" },
15
+ format: ["esm"],
16
+ dts: false,
17
+ external: ["better-sqlite3"],
18
+ },
19
+ ]);