keryx 0.29.11 → 0.30.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/api.ts CHANGED
@@ -17,7 +17,7 @@ export {
17
17
  type ChannelConstructorInputs,
18
18
  type ChannelMiddleware,
19
19
  } from "./classes/Channel";
20
- export { Connection } from "./classes/Connection";
20
+ export { CONNECTION_TYPE, Connection } from "./classes/Connection";
21
21
  export { Initializer } from "./classes/Initializer";
22
22
  export { LogFormat, Logger } from "./classes/Logger";
23
23
  export { Server } from "./classes/Server";
@@ -63,6 +63,26 @@ type ActionParamsState = {
63
63
  value: Record<string, unknown>;
64
64
  };
65
65
 
66
+ /**
67
+ * The transport that originated a {@link Connection}. Use these constants
68
+ * instead of bare strings when checking `connection.type` so the value is
69
+ * consistent across the framework, plugins, and middleware.
70
+ */
71
+ export enum CONNECTION_TYPE {
72
+ /** HTTP request handled by the web server. */
73
+ WEB = "web",
74
+ /** WebSocket message handled by the web server's `Bun.serve` upgrade. */
75
+ WEBSOCKET = "websocket",
76
+ /** Action invoked from the CLI runner. */
77
+ CLI = "cli",
78
+ /** Action invoked through the MCP transport. */
79
+ MCP = "mcp",
80
+ /** Action running as a Resque background task. */
81
+ TASK = "task",
82
+ /** Action invoked from the OAuth login/signup flow. */
83
+ OAUTH = "oauth",
84
+ }
85
+
66
86
  /**
67
87
  * Represents a client connection to the server — HTTP request, WebSocket, or internal caller.
68
88
  * Each connection tracks its own session, channel subscriptions, and rate-limit state.
@@ -75,8 +95,8 @@ export class Connection<
75
95
  T extends Record<string, any> = Record<string, any>,
76
96
  TMeta extends Record<string, any> = Record<string, any>,
77
97
  > {
78
- /** Transport type identifier (e.g., `"web"`, `"websocket"`). */
79
- type: string;
98
+ /** Transport that originated this connection. */
99
+ type: CONNECTION_TYPE;
80
100
  /** A human-readable identifier for the connection, typically the remote IP or a session key. */
81
101
  identifier: string;
82
102
  /** Unique connection ID (UUID by default). Used as the key in `api.connections`. */
@@ -103,14 +123,14 @@ export class Connection<
103
123
  /**
104
124
  * Create a new connection and register it in `api.connections`.
105
125
  *
106
- * @param type - Transport type (e.g., `"web"`, `"websocket"`).
126
+ * @param type - Transport that originated this connection.
107
127
  * @param identifier - Human-readable identifier, typically the remote IP address.
108
128
  * @param id - Unique connection ID. Defaults to a random UUID.
109
129
  * @param rawConnection - The underlying transport handle (e.g., Bun `ServerWebSocket`).
110
130
  * @param sessionId - Session ID for Redis session lookup. Defaults to `id`. Use a different value when the connection map key should differ from the session cookie (e.g., WebSocket connections).
111
131
  */
112
132
  constructor(
113
- type: string,
133
+ type: CONNECTION_TYPE,
114
134
  identifier: string,
115
135
  id = randomUUID() as string,
116
136
  rawConnection: any = undefined,
package/index.ts CHANGED
@@ -29,7 +29,7 @@ export type {
29
29
  AfterActHook,
30
30
  BeforeActHook,
31
31
  } from "./classes/Connection";
32
- export { Connection } from "./classes/Connection";
32
+ export { CONNECTION_TYPE, Connection } from "./classes/Connection";
33
33
  export { LogLevel } from "./classes/Logger";
34
34
  export type { KeryxPlugin, PluginGenerator } from "./classes/Plugin";
35
35
  export { SSEResponse, StreamingResponse } from "./classes/StreamingResponse";
@@ -65,6 +65,7 @@ export { deepMerge, deepMergeDefaults, loadFromEnvIfSet } from "./util/config";
65
65
  export { getValidTypes } from "./util/generate";
66
66
  export { globLoader } from "./util/glob";
67
67
  export { type PaginatedResult, paginate } from "./util/pagination";
68
+ export { safeCompare } from "./util/safeCompare";
68
69
  export type { JSONSchema } from "./util/swaggerSchemaGenerator";
69
70
  export {
70
71
  computeActionsHash,
@@ -10,6 +10,7 @@ import {
10
10
  Action,
11
11
  type ActionParams,
12
12
  api,
13
+ CONNECTION_TYPE,
13
14
  Connection,
14
15
  config,
15
16
  logger,
@@ -337,7 +338,7 @@ export class Resque extends Initializer {
337
338
  | undefined;
338
339
 
339
340
  const connection = new Connection(
340
- "task",
341
+ CONNECTION_TYPE.TASK,
341
342
  `job:${api.process.name}:${SERVER_JOB_COUNTER++}`,
342
343
  );
343
344
  if (propagatedCorrelationId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.29.11",
3
+ "version": "0.30.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/servers/web.ts CHANGED
@@ -3,7 +3,7 @@ import { parse } from "node:url";
3
3
  import type { ServerWebSocket } from "bun";
4
4
  import { api, logger } from "../api";
5
5
  import { type HTTP_METHOD } from "../classes/Action";
6
- import { Connection } from "../classes/Connection";
6
+ import { CONNECTION_TYPE, Connection } from "../classes/Connection";
7
7
  import { Server } from "../classes/Server";
8
8
  import { StreamingResponse } from "../classes/StreamingResponse";
9
9
  import { ErrorStatusCodes, ErrorType, TypedError } from "../classes/TypedError";
@@ -28,6 +28,7 @@ import {
28
28
  handleWebsocketSubscribe,
29
29
  handleWebsocketUnsubscribe,
30
30
  } from "../util/webSocket";
31
+ import { shouldWarnStackLeak } from "../util/webStackLeakWarning";
31
32
  import { handleStaticFile } from "../util/webStaticFiles";
32
33
 
33
34
  /**
@@ -151,6 +152,12 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
151
152
  this.url = `http://${config.server.web.host}:${this.port}`;
152
153
  const startMessage = `started server @ ${this.url}`;
153
154
  logger.info(logger.colorize ? ansi.bgBlue(startMessage) : startMessage);
155
+
156
+ const stackLeakWarning = shouldWarnStackLeak(
157
+ config.server.web.host,
158
+ config.server.web.includeStackInErrors,
159
+ );
160
+ if (stackLeakWarning) logger.warn(stackLeakWarning);
154
161
  } catch (e) {
155
162
  await Bun.sleep(1000);
156
163
  startupAttempts++;
@@ -164,7 +171,10 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
164
171
  // Send close frame to all WebSocket connections
165
172
  const wsConnections: ServerWebSocket[] = [];
166
173
  for (const connection of api.connections.connections.values()) {
167
- if (connection.type === "websocket" && connection.rawConnection) {
174
+ if (
175
+ connection.type === CONNECTION_TYPE.WEBSOCKET &&
176
+ connection.rawConnection
177
+ ) {
168
178
  wsConnections.push(connection.rawConnection);
169
179
  }
170
180
  }
@@ -187,7 +197,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
187
197
  const deadline = Date.now() + drainTimeout;
188
198
  while (Date.now() < deadline) {
189
199
  const remaining = [...api.connections.connections.values()].filter(
190
- (c) => c.type === "websocket",
200
+ (c) => c.type === CONNECTION_TYPE.WEBSOCKET,
191
201
  );
192
202
  if (remaining.length === 0) break;
193
203
  await Bun.sleep(50);
@@ -195,7 +205,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
195
205
 
196
206
  // Force-destroy any lingering WebSocket connections
197
207
  const lingering = [...api.connections.connections.values()].filter(
198
- (c) => c.type === "websocket",
208
+ (c) => c.type === CONNECTION_TYPE.WEBSOCKET,
199
209
  );
200
210
  for (const connection of lingering) {
201
211
  connection.destroy();
@@ -367,7 +377,13 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
367
377
  async handleWebSocketConnectionOpen(ws: ServerWebSocket) {
368
378
  //@ts-expect-error (ws.data is not defined in the bun types)
369
379
  const { ip, id, wsConnectionId } = ws.data;
370
- const connection = new Connection("websocket", ip, wsConnectionId, ws, id);
380
+ const connection = new Connection(
381
+ CONNECTION_TYPE.WEBSOCKET,
382
+ ip,
383
+ wsConnectionId,
384
+ ws,
385
+ id,
386
+ );
371
387
  connection.onBroadcastMessageReceived = function (payload: PubSubMessage) {
372
388
  ws.send(JSON.stringify({ message: payload }));
373
389
  };
@@ -513,7 +529,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
513
529
  let errorStatusCode = 500;
514
530
  const httpMethod = req.method?.toUpperCase() as HTTP_METHOD;
515
531
 
516
- const connection = new Connection("web", ip, id);
532
+ const connection = new Connection(CONNECTION_TYPE.WEB, ip, id);
517
533
 
518
534
  if (
519
535
  config.server.web.correlationId.header &&
package/util/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import os from "node:os";
2
2
  import { Command } from "commander";
3
3
  import path from "path";
4
- import { Action, api, Connection, RUN_MODE } from "../api";
4
+ import { Action, api, CONNECTION_TYPE, Connection, RUN_MODE } from "../api";
5
5
  import { ExitCode } from "./../classes/ExitCode";
6
6
  import { TypedError } from "./../classes/TypedError";
7
7
  import { config } from "../config";
@@ -274,7 +274,7 @@ async function runActionViaCLI(options: Record<string, string>, command: any) {
274
274
  await api.start(RUN_MODE.CLI);
275
275
 
276
276
  const id = "cli:" + os.userInfo().username;
277
- const connection = new Connection("cli", id);
277
+ const connection = new Connection(CONNECTION_TYPE.CLI, id);
278
278
  const params: Record<string, unknown> = { ...options };
279
279
 
280
280
  const { response, error } = await connection.act(actionName, params);
package/util/mcpServer.ts CHANGED
@@ -13,7 +13,7 @@ import * as z4mini from "zod/v4-mini";
13
13
  import { api, logger } from "../api";
14
14
  import type { Action } from "../classes/Action";
15
15
  import { MCP_RESPONSE_FORMAT } from "../classes/Action";
16
- import { Connection } from "../classes/Connection";
16
+ import { CONNECTION_TYPE, Connection } from "../classes/Connection";
17
17
  import { StreamingResponse } from "../classes/StreamingResponse";
18
18
  import { ErrorType, TypedError } from "../classes/TypedError";
19
19
  import { config } from "../config";
@@ -60,7 +60,7 @@ export async function createMcpConnection(extra: {
60
60
  const authInfo = extra.authInfo;
61
61
  const clientIp = (authInfo?.extra?.ip as string) || "unknown";
62
62
  const connection = new Connection(
63
- "mcp",
63
+ CONNECTION_TYPE.MCP,
64
64
  clientIp,
65
65
  randomUUID(),
66
66
  undefined,
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { api } from "../../api";
3
3
  import type { Action, OAuthActionResponse } from "../../classes/Action";
4
- import { Connection } from "../../classes/Connection";
4
+ import { CONNECTION_TYPE, Connection } from "../../classes/Connection";
5
5
  import { config } from "../../config";
6
6
  import { redirectUrisMatch } from "../oauth";
7
7
  import {
@@ -78,7 +78,7 @@ async function runAuthAction(
78
78
  }
79
79
 
80
80
  const connection = new Connection(
81
- "oauth",
81
+ CONNECTION_TYPE.OAUTH,
82
82
  isSignup ? "oauth-signup" : "oauth-login",
83
83
  );
84
84
  try {
@@ -0,0 +1,24 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Constant-time UTF-8 string comparison. Pads both inputs to a common length
5
+ * before delegating to `crypto.timingSafeEqual` so the comparison time does
6
+ * not leak the expected length via early-return, then verifies the original
7
+ * lengths matched. Use this whenever you compare a user-supplied secret
8
+ * (password, API key, CSRF token, signed cookie value, …) against an expected
9
+ * value — naive `===` leaks bytes via short-circuit timing.
10
+ *
11
+ * @param a - First string.
12
+ * @param b - Second string.
13
+ * @returns `true` when the strings are byte-identical, `false` otherwise.
14
+ */
15
+ export function safeCompare(a: string, b: string): boolean {
16
+ const aBuf = Buffer.from(a, "utf8");
17
+ const bBuf = Buffer.from(b, "utf8");
18
+ const len = Math.max(aBuf.length, bBuf.length, 1);
19
+ const aPadded = Buffer.alloc(len);
20
+ const bPadded = Buffer.alloc(len);
21
+ aBuf.copy(aPadded);
22
+ bBuf.copy(bPadded);
23
+ return timingSafeEqual(aPadded, bPadded) && aBuf.length === bBuf.length;
24
+ }
@@ -1,16 +1,4 @@
1
- import { timingSafeEqual } from "node:crypto";
2
-
3
- // Pads to a common length so we don't leak the expected length via early-return.
4
- function timingSafeStringEqual(a: string, b: string): boolean {
5
- const aBuf = Buffer.from(a, "utf8");
6
- const bBuf = Buffer.from(b, "utf8");
7
- const len = Math.max(aBuf.length, bBuf.length, 1);
8
- const aPadded = Buffer.alloc(len);
9
- const bPadded = Buffer.alloc(len);
10
- aBuf.copy(aPadded);
11
- bBuf.copy(bPadded);
12
- return timingSafeEqual(aPadded, bPadded) && aBuf.length === bBuf.length;
13
- }
1
+ import { safeCompare } from "./safeCompare";
14
2
 
15
3
  /**
16
4
  * Verifies an HTTP Basic auth `Authorization` header against expected credentials
@@ -47,7 +35,7 @@ export function verifyBasicAuth(
47
35
  const user = decoded.slice(0, idx);
48
36
  const pass = decoded.slice(idx + 1);
49
37
 
50
- const userOk = timingSafeStringEqual(user, expectedUsername);
51
- const passOk = timingSafeStringEqual(pass, expectedPassword);
38
+ const userOk = safeCompare(user, expectedUsername);
39
+ const passOk = safeCompare(pass, expectedPassword);
52
40
  return userOk && passOk;
53
41
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Returns a warning message when the web server is configured to leak stack
3
+ * traces to remote callers, otherwise `null`. Stack traces in error responses
4
+ * leak deployment paths and code structure, which is fine on a developer's
5
+ * laptop but a footgun on a publicly reachable host.
6
+ */
7
+ export function shouldWarnStackLeak(
8
+ host: string,
9
+ includeStackInErrors: boolean,
10
+ ): string | null {
11
+ if (!includeStackInErrors) return null;
12
+ const isLocalBind =
13
+ host === "localhost" ||
14
+ host === "127.0.0.1" ||
15
+ host === "::1" ||
16
+ host === "[::1]";
17
+ if (isLocalBind) return null;
18
+ return (
19
+ `⚠️ Stack traces are enabled in error responses (host=${host}). ` +
20
+ `This leaks internal paths and code structure. ` +
21
+ `Set NODE_ENV=production or WEB_SERVER_INCLUDE_STACK_IN_ERRORS=false before exposing this server publicly.`
22
+ );
23
+ }