shadow-claw 1.24.1 → 1.25.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 (38) hide show
  1. package/README.md +2 -1
  2. package/bin/cli.mjs +111 -2
  3. package/bin/commands/webrtc-listen.mjs +21 -1
  4. package/bin/utils/control-client.mjs +44 -30
  5. package/bin/utils/control-client.test.mjs +10 -0
  6. package/bin/utils/webrtc-control-client.mjs +23 -4
  7. package/dist/public/AGENTS.md +1 -1
  8. package/dist/public/README.md +2 -1
  9. package/dist/public/docs/README.md +1 -1
  10. package/dist/public/docs/example/article/index.html +1 -1
  11. package/dist/public/docs/guides/server-development-configuration.md +28 -0
  12. package/dist/public/docs/publishing/index.html +1 -1
  13. package/dist/public/docs/skill-creator/index.html +1 -1
  14. package/dist/public/docs/subsystems/cli.md +25 -19
  15. package/dist/public/index.html +1 -1
  16. package/dist/public/index.js +1 -1
  17. package/dist/public/{initControlPlane-DKNht5ph.js → initControlPlane-D5kHKcB5.js} +1 -1
  18. package/dist/public/llms.txt +1 -1
  19. package/dist/public/main/index.html +1 -1
  20. package/dist/public/main/memory/index.html +1 -1
  21. package/dist/public/{orchestrator-8w9-exfN.js → orchestrator-CnwkoBpe.js} +1 -1
  22. package/dist/public/{peerjs-D8hck8Ih.js → peerjs-CuSJUH-r.js} +1 -1
  23. package/dist/public/service-worker.js +1 -1
  24. package/dist/public/{shadow-claw-Cs8wbk3d.js → shadow-claw-CUbhMmcB.js} +2 -2
  25. package/dist/public/{shadow-claw-chat-DGPKpmU5.js → shadow-claw-chat-BN85a1xk.js} +1 -1
  26. package/dist/public/{shadow-claw-control-plane-B1F0jnPk.js → shadow-claw-control-plane-C0lSxLpW.js} +1 -1
  27. package/dist/public/{shadow-claw-settings-D6lPBn6m.js → shadow-claw-settings-BZWrAoQA.js} +1 -1
  28. package/dist/server.js +203 -203
  29. package/package.json +1 -1
  30. package/src/core/utils/initControlPlane.ts +2 -0
  31. package/src/server/config.test.ts +49 -0
  32. package/src/server/config.ts +47 -0
  33. package/src/server/control-plane.ts +2 -1
  34. package/src/server/peer.ts +6 -2
  35. package/src/server/server.test.ts +118 -0
  36. package/src/server/server.ts +20 -5
  37. package/src/server/tls.test.ts +266 -0
  38. package/src/server/tls.ts +178 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadow-claw",
3
- "version": "1.24.1",
3
+ "version": "1.25.0",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0",
6
6
  "description": "Browser-native personal AI assistant.",
@@ -334,9 +334,11 @@ export async function executeClientControlCommand(
334
334
  }
335
335
 
336
336
  const { BackupController } = await import("../backup-controller.js");
337
+
337
338
  const backupCtrl = new BackupController({
338
339
  clientId,
339
340
  token: args.token,
341
+ serverBaseUrl: getControlPlaneServerUrl().httpUrl,
340
342
  fileEnumerator: () => collectFiles(groupDir),
341
343
  fileReader: (filePath) => readFile(filePath),
342
344
  });
@@ -111,4 +111,53 @@ describe("config", () => {
111
111
 
112
112
  expect(config.rootPath).toContain("public");
113
113
  });
114
+
115
+ it("defaults https to false and provides default sslDir", async () => {
116
+ const { parseConfig } = await import("./config.js");
117
+ const config = parseConfig();
118
+
119
+ expect(config.https).toBe(false);
120
+ expect(config.certPath).toBeUndefined();
121
+ expect(config.keyPath).toBeUndefined();
122
+ expect(config.sslDir).toContain(".cache/tls");
123
+ });
124
+
125
+ it("parses HTTPS options from CLI flags", async () => {
126
+ commanderMock.Command().opts.mockReturnValue({
127
+ https: true,
128
+ cert: "/custom/cert.pem",
129
+ key: "/custom/key.pem",
130
+ sslDir: "/custom/ssl",
131
+ corsAllowOrigin: [],
132
+ });
133
+
134
+ const { parseConfig } = await import("./config.js");
135
+ const config = parseConfig();
136
+
137
+ expect(config.https).toBe(true);
138
+ expect(config.certPath).toBe("/custom/cert.pem");
139
+ expect(config.keyPath).toBe("/custom/key.pem");
140
+ expect(config.sslDir).toBe("/custom/ssl");
141
+ });
142
+
143
+ it("parses HTTPS options from environment variables", async () => {
144
+ processMock.env.SHADOWCLAW_HTTPS = "1";
145
+ processMock.env.SHADOWCLAW_TLS_CERT = "/env/cert.pem";
146
+ processMock.env.SHADOWCLAW_TLS_KEY = "/env/key.pem";
147
+ processMock.env.SHADOWCLAW_SSL_DIR = "env-ssl";
148
+
149
+ const { parseConfig } = await import("./config.js");
150
+ const config = parseConfig();
151
+
152
+ expect(config.https).toBe(true);
153
+ expect(config.certPath).toBe("/env/cert.pem");
154
+ expect(config.keyPath).toBe("/env/key.pem");
155
+ expect(config.sslDir).toBe(
156
+ (await import("node:path")).default.resolve(
157
+ config.rootPath,
158
+ "..",
159
+ "env-ssl",
160
+ ),
161
+ );
162
+ });
114
163
  });
@@ -27,6 +27,10 @@ export interface ServerConfig {
27
27
  */
28
28
  controlToken?: string;
29
29
  allowPrivateProxy: boolean;
30
+ https: boolean;
31
+ certPath?: string;
32
+ keyPath?: string;
33
+ sslDir: string;
30
34
  }
31
35
 
32
36
  export function parseConfig(): ServerConfig {
@@ -67,6 +71,13 @@ export function parseConfig(): ServerConfig {
67
71
  .option(
68
72
  "--control-token <token>",
69
73
  "Secret token for control-plane authentication",
74
+ )
75
+ .option("--https", "Enable HTTPS dev server", false)
76
+ .option("--cert <path>", "Path to existing TLS certificate file")
77
+ .option("--key <path>", "Path to existing TLS private key file")
78
+ .option(
79
+ "--ssl-dir <path>",
80
+ "Directory for TLS certificate generation/storage",
70
81
  );
71
82
 
72
83
  program.parse();
@@ -178,6 +189,38 @@ export function parseConfig(): ServerConfig {
178
189
  ? options.controlToken.trim()
179
190
  : envControlToken || undefined;
180
191
 
192
+ // HTTPS & TLS logic
193
+ const https =
194
+ Boolean(options.https) ||
195
+ ["1", "true", "yes"].includes(
196
+ (env.SHADOWCLAW_HTTPS || "").toLowerCase().trim(),
197
+ );
198
+
199
+ const envCert = (env.SHADOWCLAW_TLS_CERT || env.SHADOWCLAW_CERT || "").trim();
200
+ const certPath = options.cert
201
+ ? path.resolve(options.cert)
202
+ : envCert
203
+ ? path.resolve(envCert)
204
+ : undefined;
205
+
206
+ const envKey = (env.SHADOWCLAW_TLS_KEY || env.SHADOWCLAW_KEY || "").trim();
207
+ const keyPath = options.key
208
+ ? path.resolve(options.key)
209
+ : envKey
210
+ ? path.resolve(envKey)
211
+ : undefined;
212
+
213
+ const envSslDir = (
214
+ env.SHADOWCLAW_SSL_DIR ||
215
+ env.SHADOWCLAW_TLS_DIR ||
216
+ ""
217
+ ).trim();
218
+ const sslDir = options.sslDir
219
+ ? path.resolve(options.sslDir)
220
+ : envSslDir
221
+ ? path.resolve(projectRoot, envSslDir)
222
+ : path.resolve(databaseDir, "..", ".cache", "tls");
223
+
181
224
  return {
182
225
  port,
183
226
  bindHost,
@@ -189,5 +232,9 @@ export function parseConfig(): ServerConfig {
189
232
  databaseDir,
190
233
  controlToken,
191
234
  allowPrivateProxy,
235
+ https,
236
+ certPath,
237
+ keyPath,
238
+ sslDir,
192
239
  };
193
240
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import http from "node:http";
10
+ import https from "node:https";
10
11
  import { URL } from "node:url";
11
12
  import { WebSocketServer, WebSocket } from "ws";
12
13
  import type { Express, Request, Response, NextFunction } from "express";
@@ -29,7 +30,7 @@ import type {
29
30
  } from "./control-plane-types.js";
30
31
 
31
32
  export interface ControlPlaneOptions {
32
- httpServer: http.Server;
33
+ httpServer: http.Server | https.Server;
33
34
  app?: Express;
34
35
  token?: string;
35
36
  heartbeatTimeoutMs?: number;
@@ -1,4 +1,5 @@
1
1
  import http from "node:http";
2
+ import https from "node:https";
2
3
  import { ExpressPeerServer } from "peer";
3
4
  import type { Express } from "express";
4
5
 
@@ -10,10 +11,13 @@ import type { Express } from "express";
10
11
  * We mount the middleware at "/" and set internal path to "/" so routes land
11
12
  * at /peerjs/* as expected.
12
13
  *
13
- * @param httpServer - The underlying http.Server (needed for WebSocket upgrade).
14
+ * @param httpServer - The underlying http.Server or https.Server (needed for WebSocket upgrade).
14
15
  * @param app - The Express app to mount the PeerJS REST routes on.
15
16
  */
16
- export function attachPeerServer(httpServer: http.Server, app: Express): void {
17
+ export function attachPeerServer(
18
+ httpServer: http.Server | https.Server,
19
+ app: Express,
20
+ ): void {
17
21
  const peerServer = ExpressPeerServer(httpServer, {
18
22
  path: "/",
19
23
  allow_discovery: false,
@@ -147,3 +147,121 @@ describe("src/server/server.ts proxy simulation", () => {
147
147
  expect(headers.authorization).toBe("Bearer server-key");
148
148
  });
149
149
  });
150
+
151
+ describe("startServer HTTP / HTTPS selection", () => {
152
+ let httpMock: any;
153
+ let httpsMock: any;
154
+ let appMock: any;
155
+ let tlsMock: any;
156
+ let tcpPortUsedMock: any;
157
+ let controlPlaneMock: any;
158
+
159
+ beforeEach(() => {
160
+ jest.resetModules();
161
+
162
+ const fakeServer = {
163
+ listen: jest.fn((_port: number, _host: string, cb: any) => {
164
+ if (cb) cb();
165
+ return fakeServer;
166
+ }),
167
+ };
168
+
169
+ httpMock = {
170
+ createServer: jest.fn(() => fakeServer),
171
+ };
172
+
173
+ httpsMock = {
174
+ createServer: jest.fn(() => fakeServer),
175
+ };
176
+
177
+ appMock = {
178
+ createApp: jest.fn(() => ({
179
+ app: {},
180
+ scheduler: { start: jest.fn() },
181
+ })),
182
+ };
183
+
184
+ tlsMock = {
185
+ ensureTlsCredentials: jest.fn(() => ({
186
+ key: Buffer.from("fake-key"),
187
+ cert: Buffer.from("fake-cert"),
188
+ })),
189
+ };
190
+
191
+ tcpPortUsedMock = {
192
+ check: jest.fn(() => Promise.resolve(false)),
193
+ };
194
+
195
+ controlPlaneMock = {
196
+ createControlPlane: jest.fn(() => ({
197
+ getToken: jest.fn(() => "test-token"),
198
+ })),
199
+ };
200
+
201
+ jest.spyOn(console, "log").mockImplementation(() => {});
202
+
203
+ jest.unstable_mockModule("node:http", () => ({
204
+ default: httpMock,
205
+ ...httpMock,
206
+ }));
207
+ jest.unstable_mockModule("node:https", () => ({
208
+ default: httpsMock,
209
+ ...httpsMock,
210
+ }));
211
+ jest.unstable_mockModule("./app.js", () => appMock);
212
+ jest.unstable_mockModule("./tls.js", () => tlsMock);
213
+ jest.unstable_mockModule("tcp-port-used", () => ({
214
+ default: tcpPortUsedMock,
215
+ ...tcpPortUsedMock,
216
+ }));
217
+ jest.unstable_mockModule("./control-plane.js", () => controlPlaneMock);
218
+ jest.unstable_mockModule("./peer.js", () => ({
219
+ attachPeerServer: jest.fn(),
220
+ }));
221
+ });
222
+
223
+ it("creates http.Server when config.https is false", async () => {
224
+ const { startServer } = await import("./server.js");
225
+ await startServer({
226
+ port: 8888,
227
+ bindHost: "127.0.0.1",
228
+ corsMode: "localhost",
229
+ allowedOrigins: new Set(),
230
+ verbose: false,
231
+ peerjs: false,
232
+ rootPath: "/root",
233
+ databaseDir: "/db",
234
+ allowPrivateProxy: false,
235
+ https: false,
236
+ sslDir: "/ssl",
237
+ });
238
+
239
+ expect(httpMock.createServer).toHaveBeenCalledTimes(1);
240
+ expect(httpsMock.createServer).not.toHaveBeenCalled();
241
+ expect(tlsMock.ensureTlsCredentials).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it("creates https.Server with TLS credentials when config.https is true", async () => {
245
+ const { startServer } = await import("./server.js");
246
+ await startServer({
247
+ port: 8888,
248
+ bindHost: "127.0.0.1",
249
+ corsMode: "localhost",
250
+ allowedOrigins: new Set(),
251
+ verbose: false,
252
+ peerjs: false,
253
+ rootPath: "/root",
254
+ databaseDir: "/db",
255
+ allowPrivateProxy: false,
256
+ https: true,
257
+ sslDir: "/ssl",
258
+ });
259
+
260
+ expect(tlsMock.ensureTlsCredentials).toHaveBeenCalledTimes(1);
261
+ expect(httpsMock.createServer).toHaveBeenCalledWith(
262
+ { key: Buffer.from("fake-key"), cert: Buffer.from("fake-cert") },
263
+ expect.anything(),
264
+ );
265
+ expect(httpMock.createServer).not.toHaveBeenCalled();
266
+ });
267
+ });
@@ -1,5 +1,6 @@
1
1
  import { exit } from "node:process";
2
2
  import http from "node:http";
3
+ import https from "node:https";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import tcpPortUsed from "tcp-port-used";
@@ -10,10 +11,11 @@ import { attachPeerServer } from "./peer.js";
10
11
  import { createControlPlane } from "./control-plane.js";
11
12
  import { ServerPeer } from "./server-peer.js";
12
13
  import { parseConfig, ServerConfig } from "./config.js";
14
+ import { ensureTlsCredentials } from "./tls.js";
13
15
 
14
16
  export async function startServer(
15
17
  customConfig?: ServerConfig,
16
- ): Promise<http.Server> {
18
+ ): Promise<http.Server | https.Server> {
17
19
  const config = customConfig || parseConfig();
18
20
  const { app, scheduler } = createApp(config);
19
21
 
@@ -27,7 +29,9 @@ export async function startServer(
27
29
 
28
30
  scheduler.start();
29
31
 
30
- const httpServer = http.createServer(app);
32
+ const httpServer: http.Server | https.Server = config.https
33
+ ? https.createServer(ensureTlsCredentials(config), app)
34
+ : http.createServer(app);
31
35
 
32
36
  const controlPlane = createControlPlane({
33
37
  httpServer,
@@ -49,9 +53,20 @@ export async function startServer(
49
53
  });
50
54
  }
51
55
 
52
- return new Promise<http.Server>((resolve) => {
56
+ return new Promise<http.Server | https.Server>((resolve) => {
53
57
  httpServer.listen(config.port, config.bindHost, () => {
54
- console.log(`Server running at http://${config.bindHost}:${config.port}`);
58
+ const protocol = config.https ? "https" : "http";
59
+ const wsProtocol = config.https ? "wss" : "ws";
60
+
61
+ console.log(
62
+ `Server running at ${protocol}://${config.bindHost}:${config.port}`,
63
+ );
64
+
65
+ if (config.https) {
66
+ const certLocation =
67
+ config.certPath || path.join(config.sslDir, "cert.pem");
68
+ console.log(`TLS enabled (cert: ${certLocation})`);
69
+ }
55
70
 
56
71
  if (config.bindHost === DEFAULT_DEV_IP) {
57
72
  console.log(
@@ -72,7 +87,7 @@ export async function startServer(
72
87
  }
73
88
 
74
89
  console.log(
75
- `Control plane active at http://${config.bindHost}:${config.port}/api/control/events (SSE) and ws://${config.bindHost}:${config.port}/ws/control (WebSocket)`,
90
+ `Control plane active at ${protocol}://${config.bindHost}:${config.port}/api/control/events (SSE) and ${wsProtocol}://${config.bindHost}:${config.port}/ws/control (WebSocket)`,
76
91
  );
77
92
  console.log(`Control token: ${controlPlane.getToken()}`);
78
93
 
@@ -0,0 +1,266 @@
1
+ import { jest } from "@jest/globals";
2
+ import type { ServerConfig } from "./config.js";
3
+
4
+ describe("tls credentials", () => {
5
+ let fsMock: any;
6
+ let childProcessMock: any;
7
+ let processMock: any;
8
+ let osMock: any;
9
+ let baseConfig: ServerConfig;
10
+
11
+ beforeEach(() => {
12
+ jest.resetModules();
13
+
14
+ fsMock = {
15
+ existsSync: jest.fn(),
16
+ readFileSync: jest.fn(),
17
+ mkdirSync: jest.fn(),
18
+ };
19
+
20
+ childProcessMock = {
21
+ execFileSync: jest.fn(),
22
+ };
23
+
24
+ processMock = {
25
+ exit: jest.fn(),
26
+ };
27
+
28
+ osMock = {
29
+ networkInterfaces: jest.fn(() => ({
30
+ eth0: [{ address: "192.168.1.50" }],
31
+ })),
32
+ };
33
+
34
+ baseConfig = {
35
+ port: 8888,
36
+ bindHost: "127.0.0.1",
37
+ corsMode: "localhost",
38
+ allowedOrigins: new Set(),
39
+ verbose: false,
40
+ peerjs: false,
41
+ rootPath: "/app/public",
42
+ databaseDir: "/app/database",
43
+ allowPrivateProxy: false,
44
+ https: true,
45
+ sslDir: "/app/.cache/tls",
46
+ };
47
+
48
+ jest.spyOn(console, "error").mockImplementation(() => {});
49
+ jest.spyOn(console, "warn").mockImplementation(() => {});
50
+
51
+ jest.unstable_mockModule("node:fs", () => ({
52
+ default: fsMock,
53
+ ...fsMock,
54
+ }));
55
+ jest.unstable_mockModule("node:os", () => ({
56
+ default: osMock,
57
+ ...osMock,
58
+ }));
59
+ jest.unstable_mockModule("node:child_process", () => ({
60
+ default: childProcessMock,
61
+ ...childProcessMock,
62
+ }));
63
+ jest.unstable_mockModule("node:process", () => ({
64
+ exit: processMock.exit,
65
+ default: processMock,
66
+ }));
67
+ });
68
+
69
+ it("reads explicit cert and key files when both are provided and exist", async () => {
70
+ fsMock.existsSync.mockReturnValue(true);
71
+ fsMock.readFileSync.mockImplementation((file: string) =>
72
+ Buffer.from(`content of ${file}`),
73
+ );
74
+
75
+ const { ensureTlsCredentials } = await import("./tls.js");
76
+ const credentials = ensureTlsCredentials({
77
+ ...baseConfig,
78
+ certPath: "/custom/cert.pem",
79
+ keyPath: "/custom/key.pem",
80
+ });
81
+
82
+ expect(credentials.cert.toString()).toBe("content of /custom/cert.pem");
83
+ expect(credentials.key.toString()).toBe("content of /custom/key.pem");
84
+ expect(childProcessMock.execFileSync).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("exits when certPath is provided without keyPath", async () => {
88
+ const { ensureTlsCredentials } = await import("./tls.js");
89
+ ensureTlsCredentials({
90
+ ...baseConfig,
91
+ certPath: "/custom/cert.pem",
92
+ keyPath: undefined,
93
+ });
94
+
95
+ expect(processMock.exit).toHaveBeenCalledWith(1);
96
+ expect(console.error).toHaveBeenCalledWith(
97
+ expect.stringContaining("provided without --key"),
98
+ );
99
+ });
100
+
101
+ it("exits when keyPath is provided without certPath", async () => {
102
+ const { ensureTlsCredentials } = await import("./tls.js");
103
+ ensureTlsCredentials({
104
+ ...baseConfig,
105
+ certPath: undefined,
106
+ keyPath: "/custom/key.pem",
107
+ });
108
+
109
+ expect(processMock.exit).toHaveBeenCalledWith(1);
110
+ expect(console.error).toHaveBeenCalledWith(
111
+ expect.stringContaining("provided without --cert"),
112
+ );
113
+ });
114
+
115
+ it("exits when provided cert file does not exist", async () => {
116
+ fsMock.existsSync.mockImplementation(
117
+ (file: string) => file !== "/custom/cert.pem",
118
+ );
119
+
120
+ const { ensureTlsCredentials } = await import("./tls.js");
121
+ ensureTlsCredentials({
122
+ ...baseConfig,
123
+ certPath: "/custom/cert.pem",
124
+ keyPath: "/custom/key.pem",
125
+ });
126
+
127
+ expect(processMock.exit).toHaveBeenCalledWith(1);
128
+ expect(console.error).toHaveBeenCalledWith(
129
+ expect.stringContaining("TLS certificate not found"),
130
+ );
131
+ });
132
+
133
+ it("exits when provided key file does not exist", async () => {
134
+ fsMock.existsSync.mockImplementation(
135
+ (file: string) => file !== "/custom/key.pem",
136
+ );
137
+
138
+ const { ensureTlsCredentials } = await import("./tls.js");
139
+ ensureTlsCredentials({
140
+ ...baseConfig,
141
+ certPath: "/custom/cert.pem",
142
+ keyPath: "/custom/key.pem",
143
+ });
144
+
145
+ expect(processMock.exit).toHaveBeenCalledWith(1);
146
+ expect(console.error).toHaveBeenCalledWith(
147
+ expect.stringContaining("TLS private key not found"),
148
+ );
149
+ });
150
+
151
+ it("returns cached cert and key if already present in sslDir", async () => {
152
+ fsMock.existsSync.mockReturnValue(true);
153
+ fsMock.readFileSync.mockImplementation((file: string) =>
154
+ Buffer.from(`cached ${file}`),
155
+ );
156
+
157
+ const { ensureTlsCredentials } = await import("./tls.js");
158
+ const credentials = ensureTlsCredentials(baseConfig);
159
+
160
+ expect(credentials.cert.toString()).toBe("cached /app/.cache/tls/cert.pem");
161
+ expect(credentials.key.toString()).toBe("cached /app/.cache/tls/key.pem");
162
+ expect(childProcessMock.execFileSync).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it("generates self-signed cert and key via openssl when not in sslDir", async () => {
166
+ fsMock.existsSync.mockReturnValue(false);
167
+ fsMock.readFileSync.mockImplementation((file: string) =>
168
+ Buffer.from(`generated ${file}`),
169
+ );
170
+
171
+ const { ensureTlsCredentials } = await import("./tls.js");
172
+ const credentials = ensureTlsCredentials(baseConfig);
173
+
174
+ expect(fsMock.mkdirSync).toHaveBeenCalledWith("/app/.cache/tls", {
175
+ recursive: true,
176
+ });
177
+ expect(childProcessMock.execFileSync).toHaveBeenCalledWith(
178
+ "openssl",
179
+ expect.arrayContaining([
180
+ "-addext",
181
+ expect.stringMatching(/subjectAltName=.*IP:192\.168\.1\.50/),
182
+ ]),
183
+ { stdio: "pipe" },
184
+ );
185
+ expect(console.warn).toHaveBeenCalledWith(
186
+ expect.stringContaining("Generated self-signed TLS certificate"),
187
+ );
188
+ expect(credentials.cert.toString()).toBe(
189
+ "generated /app/.cache/tls/cert.pem",
190
+ );
191
+ expect(credentials.key.toString()).toBe(
192
+ "generated /app/.cache/tls/key.pem",
193
+ );
194
+ });
195
+
196
+ it("resolves SAN entries including loopback, local interfaces, and bindHost", async () => {
197
+ const { resolveSanList } = await import("./tls.js");
198
+ const sans = resolveSanList("10.9.8.226");
199
+
200
+ expect(sans).toContain("DNS:localhost");
201
+ expect(sans).toContain("IP:127.0.0.1");
202
+ expect(sans).toContain("IP:::1");
203
+ expect(sans).toContain("IP:192.168.1.50");
204
+ expect(sans).toContain("IP:10.9.8.226");
205
+ });
206
+
207
+ it("resolves DNS hostname for bindHost if bindHost is a domain", async () => {
208
+ const { resolveSanList } = await import("./tls.js");
209
+ const sans = resolveSanList("my-laptop.local");
210
+
211
+ expect(sans).toContain("DNS:my-laptop.local");
212
+ });
213
+
214
+ it("falls back to standard openssl args when -addext fails", async () => {
215
+ fsMock.existsSync.mockReturnValue(false);
216
+ fsMock.readFileSync.mockImplementation((file: string) =>
217
+ Buffer.from(`generated ${file}`),
218
+ );
219
+
220
+ childProcessMock.execFileSync
221
+ .mockImplementationOnce(() => {
222
+ throw new Error("unknown option -addext");
223
+ })
224
+ .mockImplementationOnce(() => Buffer.from(""));
225
+
226
+ const { ensureTlsCredentials } = await import("./tls.js");
227
+ const credentials = ensureTlsCredentials(baseConfig);
228
+
229
+ expect(childProcessMock.execFileSync).toHaveBeenCalledTimes(2);
230
+ expect(credentials.cert.toString()).toBe(
231
+ "generated /app/.cache/tls/cert.pem",
232
+ );
233
+ });
234
+
235
+ it("handles ENOENT when OpenSSL binary is missing", async () => {
236
+ fsMock.existsSync.mockReturnValue(false);
237
+ const enoentError: any = new Error("spawnSync openssl ENOENT");
238
+ enoentError.code = "ENOENT";
239
+ childProcessMock.execFileSync.mockImplementation(() => {
240
+ throw enoentError;
241
+ });
242
+
243
+ const { ensureTlsCredentials } = await import("./tls.js");
244
+ ensureTlsCredentials(baseConfig);
245
+
246
+ expect(processMock.exit).toHaveBeenCalledWith(1);
247
+ expect(console.error).toHaveBeenCalledWith(
248
+ expect.stringContaining("OpenSSL must be installed"),
249
+ );
250
+ });
251
+
252
+ it("handles generic openssl execution error", async () => {
253
+ fsMock.existsSync.mockReturnValue(false);
254
+ childProcessMock.execFileSync.mockImplementation(() => {
255
+ throw new Error("Command failed");
256
+ });
257
+
258
+ const { ensureTlsCredentials } = await import("./tls.js");
259
+ ensureTlsCredentials(baseConfig);
260
+
261
+ expect(processMock.exit).toHaveBeenCalledWith(1);
262
+ expect(console.error).toHaveBeenCalledWith(
263
+ expect.stringContaining("Error generating TLS certificate with OpenSSL"),
264
+ );
265
+ });
266
+ });