@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.
package/src/config.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ export interface ServerConfig {
6
+ port: number;
7
+ host: string;
8
+ dataDir: string;
9
+ adminToken?: string;
10
+ encryptionKey?: string;
11
+ gatewayPrivateKey?: string;
12
+ gatewayPublicKey?: string;
13
+ }
14
+
15
+ const DEFAULT_DATA_DIR = join(homedir(), ".nkmc", "server");
16
+
17
+ export function loadConfig(): ServerConfig {
18
+ const dataDir = process.env.NKMC_DATA_DIR ?? DEFAULT_DATA_DIR;
19
+
20
+ // Load config file if it exists
21
+ const configPath = join(dataDir, "config.json");
22
+ let fileConfig: Record<string, unknown> = {};
23
+ if (existsSync(configPath)) {
24
+ try {
25
+ fileConfig = JSON.parse(readFileSync(configPath, "utf-8"));
26
+ } catch {
27
+ // Ignore malformed config files
28
+ }
29
+ }
30
+
31
+ function get(envKey: string, fileKey: string): string | undefined {
32
+ return process.env[envKey] ?? (fileConfig[fileKey] as string | undefined);
33
+ }
34
+
35
+ return {
36
+ port: parseInt(process.env.NKMC_PORT ?? (fileConfig.port as string | undefined) ?? "9090", 10),
37
+ host: process.env.NKMC_HOST ?? (fileConfig.host as string | undefined) ?? "0.0.0.0",
38
+ dataDir,
39
+ adminToken: get("NKMC_ADMIN_TOKEN", "adminToken"),
40
+ encryptionKey: get("NKMC_ENCRYPTION_KEY", "encryptionKey"),
41
+ gatewayPrivateKey: get("NKMC_GATEWAY_PRIVATE_KEY", "gatewayPrivateKey"),
42
+ gatewayPublicKey: get("NKMC_GATEWAY_PUBLIC_KEY", "gatewayPublicKey"),
43
+ };
44
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export interface ExecResult {
4
+ stdout: string;
5
+ stderr: string;
6
+ exitCode: number;
7
+ }
8
+
9
+ export function createExec(opts?: {
10
+ timeout?: number;
11
+ }): (
12
+ tool: string,
13
+ args: string[],
14
+ env: Record<string, string>,
15
+ ) => Promise<ExecResult> {
16
+ const timeout = opts?.timeout ?? 30_000;
17
+
18
+ return (tool, args, env) => {
19
+ return new Promise((resolve) => {
20
+ const child = spawn(tool, args, {
21
+ env: { ...process.env, ...env },
22
+ timeout,
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ });
25
+
26
+ let stdout = "";
27
+ let stderr = "";
28
+ child.stdout.on("data", (data: Buffer) => {
29
+ stdout += data;
30
+ });
31
+ child.stderr.on("data", (data: Buffer) => {
32
+ stderr += data;
33
+ });
34
+
35
+ child.on("close", (code) => {
36
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
37
+ });
38
+
39
+ child.on("error", (err) => {
40
+ resolve({ stdout: "", stderr: err.message, exitCode: 1 });
41
+ });
42
+ });
43
+ };
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { startServer } from "./server.js";
3
+
4
+ async function main() {
5
+ const config = loadConfig();
6
+ await startServer({ config });
7
+ }
8
+
9
+ main().catch((err) => {
10
+ console.error("[nkmc] Fatal error:", err);
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,76 @@
1
+ -- Services registry
2
+ CREATE TABLE IF NOT EXISTS services (
3
+ domain TEXT NOT NULL,
4
+ version TEXT NOT NULL,
5
+ name TEXT NOT NULL,
6
+ description TEXT,
7
+ roles TEXT,
8
+ skill_md TEXT NOT NULL,
9
+ endpoints TEXT,
10
+ is_first_party INTEGER DEFAULT 0,
11
+ status TEXT DEFAULT 'active',
12
+ is_default INTEGER DEFAULT 1,
13
+ source TEXT,
14
+ sunset_date INTEGER,
15
+ created_at INTEGER NOT NULL,
16
+ updated_at INTEGER NOT NULL,
17
+ PRIMARY KEY (domain, version)
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_services_default ON services(domain, is_default);
21
+
22
+ -- Credentials vault
23
+ CREATE TABLE IF NOT EXISTS credentials (
24
+ domain TEXT NOT NULL,
25
+ scope TEXT NOT NULL DEFAULT 'pool',
26
+ developer_id TEXT NOT NULL DEFAULT '',
27
+ auth_encrypted TEXT NOT NULL,
28
+ created_at INTEGER NOT NULL,
29
+ updated_at INTEGER NOT NULL,
30
+ PRIMARY KEY (domain, scope, developer_id)
31
+ );
32
+
33
+ -- Metering records
34
+ CREATE TABLE IF NOT EXISTS meter_records (
35
+ id TEXT PRIMARY KEY,
36
+ timestamp INTEGER NOT NULL,
37
+ domain TEXT NOT NULL,
38
+ version TEXT NOT NULL,
39
+ endpoint TEXT NOT NULL,
40
+ agent_id TEXT NOT NULL,
41
+ developer_id TEXT,
42
+ cost REAL NOT NULL,
43
+ currency TEXT NOT NULL DEFAULT 'USDC'
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_meter_domain ON meter_records(domain, timestamp);
47
+ CREATE INDEX IF NOT EXISTS idx_meter_agent ON meter_records(agent_id, timestamp);
48
+
49
+ -- Domain verification challenges
50
+ CREATE TABLE IF NOT EXISTS domain_challenges (
51
+ domain TEXT PRIMARY KEY,
52
+ challenge_code TEXT NOT NULL,
53
+ status TEXT NOT NULL DEFAULT 'pending',
54
+ created_at INTEGER NOT NULL,
55
+ verified_at INTEGER,
56
+ expires_at INTEGER NOT NULL
57
+ );
58
+
59
+ -- Developer-Agent binding
60
+ CREATE TABLE IF NOT EXISTS developer_agents (
61
+ user_id TEXT NOT NULL,
62
+ agent_id TEXT NOT NULL,
63
+ label TEXT,
64
+ created_at INTEGER NOT NULL,
65
+ PRIMARY KEY (user_id, agent_id)
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_da_agent ON developer_agents(agent_id);
69
+
70
+ -- Claim tokens (for agent-first onboarding)
71
+ CREATE TABLE IF NOT EXISTS claim_tokens (
72
+ token TEXT PRIMARY KEY,
73
+ agent_id TEXT NOT NULL,
74
+ expires_at INTEGER NOT NULL,
75
+ created_at INTEGER NOT NULL
76
+ );
@@ -0,0 +1,2 @@
1
+ -- Add auth_mode column for nkmc-jwt proxy authentication
2
+ ALTER TABLE services ADD COLUMN auth_mode TEXT;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Embedded migration SQL statements.
3
+ * These are copied from migrations/ (repo root) and kept in sync manually.
4
+ * Each migration is idempotent (uses IF NOT EXISTS patterns).
5
+ */
6
+
7
+ export const migrations: { name: string; sql: string }[] = [
8
+ {
9
+ name: "0001_init",
10
+ sql: `
11
+ -- Services registry
12
+ CREATE TABLE IF NOT EXISTS services (
13
+ domain TEXT NOT NULL,
14
+ version TEXT NOT NULL,
15
+ name TEXT NOT NULL,
16
+ description TEXT,
17
+ roles TEXT,
18
+ skill_md TEXT NOT NULL,
19
+ endpoints TEXT,
20
+ is_first_party INTEGER DEFAULT 0,
21
+ status TEXT DEFAULT 'active',
22
+ is_default INTEGER DEFAULT 1,
23
+ source TEXT,
24
+ sunset_date INTEGER,
25
+ created_at INTEGER NOT NULL,
26
+ updated_at INTEGER NOT NULL,
27
+ PRIMARY KEY (domain, version)
28
+ );
29
+
30
+ CREATE INDEX IF NOT EXISTS idx_services_default ON services(domain, is_default);
31
+
32
+ -- Credentials vault
33
+ CREATE TABLE IF NOT EXISTS credentials (
34
+ domain TEXT NOT NULL,
35
+ scope TEXT NOT NULL DEFAULT 'pool',
36
+ developer_id TEXT NOT NULL DEFAULT '',
37
+ auth_encrypted TEXT NOT NULL,
38
+ created_at INTEGER NOT NULL,
39
+ updated_at INTEGER NOT NULL,
40
+ PRIMARY KEY (domain, scope, developer_id)
41
+ );
42
+
43
+ -- Metering records
44
+ CREATE TABLE IF NOT EXISTS meter_records (
45
+ id TEXT PRIMARY KEY,
46
+ timestamp INTEGER NOT NULL,
47
+ domain TEXT NOT NULL,
48
+ version TEXT NOT NULL,
49
+ endpoint TEXT NOT NULL,
50
+ agent_id TEXT NOT NULL,
51
+ developer_id TEXT,
52
+ cost REAL NOT NULL,
53
+ currency TEXT NOT NULL DEFAULT 'USDC'
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_meter_domain ON meter_records(domain, timestamp);
57
+ CREATE INDEX IF NOT EXISTS idx_meter_agent ON meter_records(agent_id, timestamp);
58
+
59
+ -- Domain verification challenges
60
+ CREATE TABLE IF NOT EXISTS domain_challenges (
61
+ domain TEXT PRIMARY KEY,
62
+ challenge_code TEXT NOT NULL,
63
+ status TEXT NOT NULL DEFAULT 'pending',
64
+ created_at INTEGER NOT NULL,
65
+ verified_at INTEGER,
66
+ expires_at INTEGER NOT NULL
67
+ );
68
+
69
+ -- Developer-Agent binding
70
+ CREATE TABLE IF NOT EXISTS developer_agents (
71
+ user_id TEXT NOT NULL,
72
+ agent_id TEXT NOT NULL,
73
+ label TEXT,
74
+ created_at INTEGER NOT NULL,
75
+ PRIMARY KEY (user_id, agent_id)
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_da_agent ON developer_agents(agent_id);
79
+
80
+ -- Claim tokens (for agent-first onboarding)
81
+ CREATE TABLE IF NOT EXISTS claim_tokens (
82
+ token TEXT PRIMARY KEY,
83
+ agent_id TEXT NOT NULL,
84
+ expires_at INTEGER NOT NULL,
85
+ created_at INTEGER NOT NULL
86
+ );
87
+ `,
88
+ },
89
+ {
90
+ name: "0002_auth_mode",
91
+ sql: `ALTER TABLE services ADD COLUMN auth_mode TEXT`,
92
+ },
93
+ {
94
+ name: "0003_federation",
95
+ sql: `
96
+ -- Federation: peer gateways and lending rules
97
+
98
+ CREATE TABLE IF NOT EXISTS peers (
99
+ id TEXT PRIMARY KEY,
100
+ name TEXT NOT NULL,
101
+ url TEXT NOT NULL,
102
+ shared_secret TEXT NOT NULL,
103
+ status TEXT NOT NULL DEFAULT 'active',
104
+ advertised_domains TEXT NOT NULL DEFAULT '[]',
105
+ last_seen INTEGER NOT NULL,
106
+ created_at INTEGER NOT NULL
107
+ );
108
+
109
+ CREATE TABLE IF NOT EXISTS lending_rules (
110
+ domain TEXT PRIMARY KEY,
111
+ allow INTEGER NOT NULL DEFAULT 1,
112
+ peers TEXT NOT NULL DEFAULT '"*"',
113
+ pricing TEXT NOT NULL DEFAULT '{"mode":"free"}',
114
+ rate_limit TEXT,
115
+ created_at INTEGER NOT NULL,
116
+ updated_at INTEGER NOT NULL
117
+ );
118
+ `,
119
+ },
120
+ ];
package/src/server.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID, randomBytes } from "node:crypto";
4
+ import Database from "better-sqlite3";
5
+ import { serve } from "@hono/node-server";
6
+ import { generateKeyPair, exportJWK } from "jose";
7
+ import { createSqliteD1, D1RegistryStore, D1CredentialVault, D1PeerStore } from "@nkmc/gateway";
8
+ import { createGateway } from "@nkmc/gateway/http";
9
+ import { createDefaultToolRegistry } from "@nkmc/gateway/proxy";
10
+ import { nanoid } from "nanoid";
11
+ import { createExec } from "./exec.js";
12
+ import { migrations } from "./migrations.js";
13
+ import type { ServerConfig } from "./config.js";
14
+
15
+ export interface StartServerOptions {
16
+ config: ServerConfig;
17
+ /** If true, suppress banner output */
18
+ silent?: boolean;
19
+ }
20
+
21
+ export interface ServerHandle {
22
+ /** The port the server is listening on */
23
+ port: number;
24
+ /** Close the server and database */
25
+ close: () => void;
26
+ }
27
+
28
+ export async function startServer(options: StartServerOptions): Promise<ServerHandle> {
29
+ const { config, silent } = options;
30
+ const log = silent ? () => {} : console.log.bind(console);
31
+
32
+ // Ensure data directory exists
33
+ mkdirSync(config.dataDir, { recursive: true });
34
+
35
+ // ── SQLite ────────────────────────────────────────────────
36
+ const dbPath = join(config.dataDir, "nkmc.db");
37
+ const sqlite = new Database(dbPath);
38
+ sqlite.pragma("journal_mode = WAL");
39
+ sqlite.pragma("foreign_keys = ON");
40
+
41
+ const db = createSqliteD1(sqlite);
42
+
43
+ // ── Migrations ────────────────────────────────────────────
44
+ sqlite.exec(`CREATE TABLE IF NOT EXISTS _nkmc_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL DEFAULT (unixepoch()))`);
45
+
46
+ for (const m of migrations) {
47
+ const applied = sqlite.prepare("SELECT 1 FROM _nkmc_migrations WHERE name = ?").get(m.name);
48
+ if (applied) continue;
49
+ try {
50
+ sqlite.exec(m.sql);
51
+ sqlite.prepare("INSERT OR IGNORE INTO _nkmc_migrations (name) VALUES (?)").run(m.name);
52
+ } catch (err: unknown) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ if (msg.includes("duplicate column")) {
55
+ sqlite.prepare("INSERT OR IGNORE INTO _nkmc_migrations (name) VALUES (?)").run(m.name);
56
+ } else {
57
+ throw err;
58
+ }
59
+ }
60
+ }
61
+
62
+ log("[nkmc] Migrations applied");
63
+
64
+ // ── Gateway Key Pair (EdDSA / Ed25519) ───────────────────
65
+ let privateKey: Record<string, unknown>;
66
+ let publicKey: Record<string, unknown>;
67
+ const keysPath = join(config.dataDir, "keys.json");
68
+
69
+ if (config.gatewayPrivateKey && config.gatewayPublicKey) {
70
+ privateKey = JSON.parse(config.gatewayPrivateKey);
71
+ publicKey = JSON.parse(config.gatewayPublicKey);
72
+ log("[nkmc] Loaded gateway keys from config/env");
73
+ } else if (existsSync(keysPath)) {
74
+ const keys = JSON.parse(readFileSync(keysPath, "utf-8"));
75
+ privateKey = keys.privateKey;
76
+ publicKey = keys.publicKey;
77
+ log("[nkmc] Loaded gateway keys from", keysPath);
78
+ } else {
79
+ const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true });
80
+ privateKey = { ...(await exportJWK(pair.privateKey)), kty: "OKP", crv: "Ed25519" };
81
+ publicKey = { ...(await exportJWK(pair.publicKey)), kty: "OKP", crv: "Ed25519" };
82
+ const kid = nanoid(12);
83
+ privateKey.kid = kid;
84
+ publicKey.kid = kid;
85
+ writeFileSync(keysPath, JSON.stringify({ privateKey, publicKey }, null, 2), "utf-8");
86
+ chmodSync(keysPath, 0o600);
87
+ log("[nkmc] Generated new gateway key pair ->", keysPath);
88
+ }
89
+ try { chmodSync(keysPath, 0o600); } catch {}
90
+
91
+ // ── Encryption Key (AES-GCM) ─────────────────────────────
92
+ const encKeyPath = join(config.dataDir, "encryption.key");
93
+ let rawKeyB64: string;
94
+
95
+ if (config.encryptionKey) {
96
+ rawKeyB64 = config.encryptionKey;
97
+ log("[nkmc] Using encryption key from config/env");
98
+ } else if (existsSync(encKeyPath)) {
99
+ rawKeyB64 = readFileSync(encKeyPath, "utf-8").trim();
100
+ log("[nkmc] Loaded encryption key from", encKeyPath);
101
+ } else {
102
+ const buf = randomBytes(32);
103
+ rawKeyB64 = buf.toString("base64");
104
+ writeFileSync(encKeyPath, rawKeyB64, "utf-8");
105
+ chmodSync(encKeyPath, 0o600);
106
+ log("[nkmc] Generated new encryption key ->", encKeyPath);
107
+ }
108
+ // Ensure encryption key file is always owner-only
109
+ try { chmodSync(encKeyPath, 0o600); } catch {}
110
+
111
+ const rawKey = Uint8Array.from(atob(rawKeyB64), (c) => c.charCodeAt(0));
112
+ const encryptionKey = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, [
113
+ "encrypt",
114
+ "decrypt",
115
+ ]);
116
+
117
+ // ── Admin Token ───────────────────────────────────────────
118
+ const adminTokenPath = join(config.dataDir, "admin-token");
119
+ let adminToken = config.adminToken;
120
+ if (!adminToken) {
121
+ if (existsSync(adminTokenPath)) {
122
+ adminToken = readFileSync(adminTokenPath, "utf-8").trim();
123
+ log("[nkmc] Loaded admin token from", adminTokenPath);
124
+ } else {
125
+ adminToken = randomUUID();
126
+ writeFileSync(adminTokenPath, adminToken, "utf-8");
127
+ chmodSync(adminTokenPath, 0o600);
128
+ log("[nkmc] Generated admin token ->", adminTokenPath);
129
+ }
130
+ }
131
+ try { chmodSync(adminTokenPath, 0o600); } catch {}
132
+
133
+ // ── Stores ────────────────────────────────────────────────
134
+ const store = new D1RegistryStore(db);
135
+ const vault = new D1CredentialVault(db, encryptionKey);
136
+ const peerStore = new D1PeerStore(db);
137
+
138
+ // ── Create Gateway ────────────────────────────────────────
139
+ const toolRegistry = createDefaultToolRegistry();
140
+ const exec = createExec();
141
+
142
+ const gateway = createGateway({
143
+ store,
144
+ vault,
145
+ db,
146
+ gatewayPrivateKey: privateKey,
147
+ gatewayPublicKey: publicKey,
148
+ adminToken,
149
+ peerStore,
150
+ proxy: { toolRegistry, exec },
151
+ });
152
+
153
+ // ── Start Server ──────────────────────────────────────────
154
+ return new Promise<ServerHandle>((resolve) => {
155
+ const server = serve(
156
+ {
157
+ fetch: gateway.fetch,
158
+ port: config.port,
159
+ hostname: config.host,
160
+ },
161
+ (info) => {
162
+ log();
163
+ log(" ┌──────────────────────────────────────────┐");
164
+ log(" │ nakamichi gateway (standalone) │");
165
+ log(" └──────────────────────────────────────────┘");
166
+ log();
167
+ log(` Port: ${info.port}`);
168
+ log(` Host: ${config.host}`);
169
+ log(` Data dir: ${config.dataDir}`);
170
+ log(` Database: ${dbPath}`);
171
+ log();
172
+
173
+ resolve({
174
+ port: info.port,
175
+ close: () => {
176
+ server.close();
177
+ sqlite.close();
178
+ },
179
+ });
180
+ },
181
+ );
182
+ });
183
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { loadConfig } from "../src/config.js";
7
+
8
+ /**
9
+ * Save and restore env vars touched by loadConfig() so tests are isolated.
10
+ */
11
+ const ENV_KEYS = [
12
+ "NKMC_PORT",
13
+ "NKMC_HOST",
14
+ "NKMC_DATA_DIR",
15
+ "NKMC_ADMIN_TOKEN",
16
+ "NKMC_ENCRYPTION_KEY",
17
+ "NKMC_GATEWAY_PRIVATE_KEY",
18
+ "NKMC_GATEWAY_PUBLIC_KEY",
19
+ ] as const;
20
+
21
+ describe("loadConfig()", () => {
22
+ const saved = new Map<string, string | undefined>();
23
+
24
+ beforeEach(() => {
25
+ for (const key of ENV_KEYS) {
26
+ saved.set(key, process.env[key]);
27
+ delete process.env[key];
28
+ }
29
+ });
30
+
31
+ afterEach(() => {
32
+ for (const key of ENV_KEYS) {
33
+ const v = saved.get(key);
34
+ if (v === undefined) {
35
+ delete process.env[key];
36
+ } else {
37
+ process.env[key] = v;
38
+ }
39
+ }
40
+ });
41
+
42
+ it("returns sensible defaults when no env vars or config file are present", () => {
43
+ const cfg = loadConfig();
44
+ expect(cfg.port).toBe(9090);
45
+ expect(cfg.host).toBe("0.0.0.0");
46
+ expect(cfg.dataDir).toMatch(/\.nkmc[/\\]server$/);
47
+ expect(cfg.adminToken).toBeUndefined();
48
+ });
49
+
50
+ it("NKMC_PORT overrides default port", () => {
51
+ process.env.NKMC_PORT = "4321";
52
+ const cfg = loadConfig();
53
+ expect(cfg.port).toBe(4321);
54
+ });
55
+
56
+ it("NKMC_DATA_DIR overrides default data directory", () => {
57
+ const tmp = join(tmpdir(), `nkmc-test-${randomUUID()}`);
58
+ mkdirSync(tmp, { recursive: true });
59
+ try {
60
+ process.env.NKMC_DATA_DIR = tmp;
61
+ const cfg = loadConfig();
62
+ expect(cfg.dataDir).toBe(tmp);
63
+ } finally {
64
+ rmSync(tmp, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ it("NKMC_ADMIN_TOKEN env var is picked up", () => {
69
+ process.env.NKMC_ADMIN_TOKEN = "super-secret";
70
+ const cfg = loadConfig();
71
+ expect(cfg.adminToken).toBe("super-secret");
72
+ });
73
+
74
+ it("reads values from config.json when env vars are absent", () => {
75
+ const tmp = join(tmpdir(), `nkmc-test-${randomUUID()}`);
76
+ mkdirSync(tmp, { recursive: true });
77
+ try {
78
+ writeFileSync(
79
+ join(tmp, "config.json"),
80
+ JSON.stringify({ port: "7777", adminToken: "from-file" }),
81
+ );
82
+ process.env.NKMC_DATA_DIR = tmp;
83
+ const cfg = loadConfig();
84
+ expect(cfg.port).toBe(7777);
85
+ expect(cfg.adminToken).toBe("from-file");
86
+ } finally {
87
+ rmSync(tmp, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ it("env vars take precedence over config.json values", () => {
92
+ const tmp = join(tmpdir(), `nkmc-test-${randomUUID()}`);
93
+ mkdirSync(tmp, { recursive: true });
94
+ try {
95
+ writeFileSync(
96
+ join(tmp, "config.json"),
97
+ JSON.stringify({ port: "7777", adminToken: "from-file" }),
98
+ );
99
+ process.env.NKMC_DATA_DIR = tmp;
100
+ process.env.NKMC_PORT = "8888";
101
+ process.env.NKMC_ADMIN_TOKEN = "from-env";
102
+ const cfg = loadConfig();
103
+ expect(cfg.port).toBe(8888);
104
+ expect(cfg.adminToken).toBe("from-env");
105
+ } finally {
106
+ rmSync(tmp, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ it("ignores a malformed config.json", () => {
111
+ const tmp = join(tmpdir(), `nkmc-test-${randomUUID()}`);
112
+ mkdirSync(tmp, { recursive: true });
113
+ try {
114
+ writeFileSync(join(tmp, "config.json"), "NOT VALID JSON {{{");
115
+ process.env.NKMC_DATA_DIR = tmp;
116
+ const cfg = loadConfig();
117
+ // Should fall back to defaults without throwing
118
+ expect(cfg.port).toBe(9090);
119
+ } finally {
120
+ rmSync(tmp, { recursive: true, force: true });
121
+ }
122
+ });
123
+ });