keryx 0.29.10 → 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/actions/swagger.ts +23 -17
- package/api.ts +1 -1
- package/classes/Connection.ts +24 -4
- package/config/server/web.ts +1 -1
- package/index.ts +2 -1
- package/initializers/resque.ts +2 -1
- package/package.json +1 -1
- package/servers/web.ts +22 -6
- package/util/cli.ts +2 -2
- package/util/config.ts +13 -8
- package/util/mcpServer.ts +11 -3
- package/util/oauth.ts +3 -12
- package/util/oauthHandlers/authorize.ts +2 -2
- package/util/safeCompare.ts +24 -0
- package/util/webBasicAuth.ts +3 -15
- package/util/webCompression.ts +22 -36
- package/util/webStackLeakWarning.ts +23 -0
- package/util/zodMixins.ts +5 -8
package/actions/swagger.ts
CHANGED
|
@@ -62,10 +62,10 @@ export class Swagger implements Action {
|
|
|
62
62
|
web = { route: "/swagger", method: HTTP_METHOD.GET };
|
|
63
63
|
|
|
64
64
|
async run() {
|
|
65
|
-
const paths: Record<string,
|
|
65
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
66
66
|
const components: {
|
|
67
|
-
schemas: Record<string,
|
|
68
|
-
securitySchemes?: Record<string,
|
|
67
|
+
schemas: Record<string, unknown>;
|
|
68
|
+
securitySchemes?: Record<string, unknown>;
|
|
69
69
|
} = {
|
|
70
70
|
schemas: {},
|
|
71
71
|
securitySchemes: {
|
|
@@ -94,21 +94,23 @@ export class Swagger implements Action {
|
|
|
94
94
|
const description = action.description;
|
|
95
95
|
|
|
96
96
|
// Extract path parameters from the original route
|
|
97
|
-
const parameters:
|
|
97
|
+
const parameters: Array<Record<string, unknown>> = [];
|
|
98
98
|
const pathParamMatches = action.web.route.match(/:\w+/g) || [];
|
|
99
99
|
const pathParamNames = new Set<string>();
|
|
100
100
|
|
|
101
101
|
// Pre-compute Zod JSON Schema for enriching path param types
|
|
102
|
-
let zodProperties: Record<string,
|
|
102
|
+
let zodProperties: Record<string, Record<string, unknown>> = {};
|
|
103
103
|
let zodDescriptions: Record<string, string> = {};
|
|
104
104
|
if (action.inputs && typeof action.inputs.parse === "function") {
|
|
105
105
|
const jsonSchema = z.toJSONSchema(action.inputs, {
|
|
106
106
|
io: "input",
|
|
107
107
|
unrepresentable: "any",
|
|
108
|
-
}) as
|
|
109
|
-
zodProperties =
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
}) as Record<string, unknown>;
|
|
109
|
+
zodProperties =
|
|
110
|
+
(jsonSchema.properties as Record<string, Record<string, unknown>>) ??
|
|
111
|
+
{};
|
|
112
|
+
for (const [name, propSchema] of Object.entries(zodProperties)) {
|
|
113
|
+
if (typeof propSchema.description === "string") {
|
|
112
114
|
zodDescriptions[name] = propSchema.description;
|
|
113
115
|
}
|
|
114
116
|
}
|
|
@@ -135,16 +137,18 @@ export class Swagger implements Action {
|
|
|
135
137
|
const fullSchema = z.toJSONSchema(action.inputs!, {
|
|
136
138
|
io: "input",
|
|
137
139
|
unrepresentable: "any",
|
|
138
|
-
}) as
|
|
139
|
-
const requiredFields = new Set<string>(
|
|
140
|
-
|
|
140
|
+
}) as Record<string, unknown>;
|
|
141
|
+
const requiredFields = new Set<string>(
|
|
142
|
+
(fullSchema.required as string[] | undefined) ?? [],
|
|
143
|
+
);
|
|
144
|
+
for (const [name, propSchema] of Object.entries(zodProperties)) {
|
|
141
145
|
if (pathParamNames.has(name)) continue; // already a path param
|
|
142
146
|
parameters.push({
|
|
143
147
|
name,
|
|
144
148
|
in: "query",
|
|
145
149
|
required: requiredFields.has(name),
|
|
146
150
|
schema: propSchema,
|
|
147
|
-
...(propSchema.description
|
|
151
|
+
...(typeof propSchema.description === "string"
|
|
148
152
|
? { description: propSchema.description }
|
|
149
153
|
: {}),
|
|
150
154
|
});
|
|
@@ -152,7 +156,7 @@ export class Swagger implements Action {
|
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
// Build requestBody if Zod inputs exist and method supports body
|
|
155
|
-
let requestBody:
|
|
159
|
+
let requestBody: Record<string, unknown> | undefined = undefined;
|
|
156
160
|
if (
|
|
157
161
|
action.inputs &&
|
|
158
162
|
typeof action.inputs.parse === "function" &&
|
|
@@ -166,9 +170,9 @@ export class Swagger implements Action {
|
|
|
166
170
|
const jsonSchema = z.toJSONSchema(zodSchema, {
|
|
167
171
|
io: "input",
|
|
168
172
|
unrepresentable: "any",
|
|
169
|
-
})
|
|
173
|
+
}) as Record<string, unknown>;
|
|
170
174
|
// Remove $schema from component schemas (not needed in OpenAPI)
|
|
171
|
-
const { $schema, ...schemaWithout$schema } = jsonSchema
|
|
175
|
+
const { $schema, ...schemaWithout$schema } = jsonSchema;
|
|
172
176
|
components.schemas[schemaName] = schemaWithout$schema;
|
|
173
177
|
requestBody = {
|
|
174
178
|
required: true,
|
|
@@ -181,7 +185,9 @@ export class Swagger implements Action {
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
// Build responses - use generated schema if available
|
|
184
|
-
const responses = JSON.parse(
|
|
188
|
+
const responses: Record<string, unknown> = JSON.parse(
|
|
189
|
+
JSON.stringify(swaggerResponses),
|
|
190
|
+
);
|
|
185
191
|
|
|
186
192
|
if (action.web?.streaming) {
|
|
187
193
|
// Streaming endpoints return SSE
|
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";
|
package/classes/Connection.ts
CHANGED
|
@@ -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
|
|
79
|
-
type:
|
|
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
|
|
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:
|
|
133
|
+
type: CONNECTION_TYPE,
|
|
114
134
|
identifier: string,
|
|
115
135
|
id = randomUUID() as string,
|
|
116
136
|
rawConnection: any = undefined,
|
package/config/server/web.ts
CHANGED
|
@@ -70,7 +70,7 @@ export const configServerWeb = {
|
|
|
70
70
|
compression: {
|
|
71
71
|
enabled: await loadFromEnvIfSet("WEB_COMPRESSION_ENABLED", true),
|
|
72
72
|
threshold: await loadFromEnvIfSet("WEB_COMPRESSION_THRESHOLD", 1024),
|
|
73
|
-
encodings: ["
|
|
73
|
+
encodings: ["gzip"] as "gzip"[],
|
|
74
74
|
},
|
|
75
75
|
correlationId: {
|
|
76
76
|
header: await loadFromEnvIfSet("WEB_CORRELATION_ID_HEADER", "X-Request-Id"),
|
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,
|
package/initializers/resque.ts
CHANGED
|
@@ -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
|
-
|
|
341
|
+
CONNECTION_TYPE.TASK,
|
|
341
342
|
`job:${api.process.name}:${SERVER_JOB_COUNTER++}`,
|
|
342
343
|
);
|
|
343
344
|
if (propagatedCorrelationId) {
|
package/package.json
CHANGED
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 (
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
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(
|
|
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(
|
|
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/config.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
type MergeMode = "overwrite" | "defaults";
|
|
2
2
|
|
|
3
|
-
function mergeWith<T extends Record<string,
|
|
3
|
+
function mergeWith<T extends Record<string, unknown>>(
|
|
4
4
|
target: T,
|
|
5
|
-
source: Record<string,
|
|
5
|
+
source: Record<string, unknown>,
|
|
6
6
|
mode: MergeMode,
|
|
7
7
|
): T {
|
|
8
|
+
const writable = target as Record<string, unknown>;
|
|
8
9
|
for (const key of Object.keys(source)) {
|
|
9
10
|
const targetVal = target[key];
|
|
10
11
|
const sourceVal = source[key];
|
|
@@ -17,9 +18,13 @@ function mergeWith<T extends Record<string, any>>(
|
|
|
17
18
|
!Array.isArray(sourceVal);
|
|
18
19
|
|
|
19
20
|
if (bothPlainObjects) {
|
|
20
|
-
mergeWith(
|
|
21
|
+
mergeWith(
|
|
22
|
+
targetVal as Record<string, unknown>,
|
|
23
|
+
sourceVal as Record<string, unknown>,
|
|
24
|
+
mode,
|
|
25
|
+
);
|
|
21
26
|
} else if (mode === "overwrite" || !(key in target)) {
|
|
22
|
-
|
|
27
|
+
writable[key] = sourceVal;
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -30,9 +35,9 @@ function mergeWith<T extends Record<string, any>>(
|
|
|
30
35
|
Deep-merges source into target, mutating target in place.
|
|
31
36
|
Only plain objects are recursively merged; arrays and primitives are overwritten.
|
|
32
37
|
*/
|
|
33
|
-
export function deepMerge<T extends Record<string,
|
|
38
|
+
export function deepMerge<T extends Record<string, unknown>>(
|
|
34
39
|
target: T,
|
|
35
|
-
source: Record<string,
|
|
40
|
+
source: Record<string, unknown>,
|
|
36
41
|
): T {
|
|
37
42
|
return mergeWith(target, source, "overwrite");
|
|
38
43
|
}
|
|
@@ -41,9 +46,9 @@ export function deepMerge<T extends Record<string, any>>(
|
|
|
41
46
|
* Like `deepMerge`, but only sets values that don't already exist in target.
|
|
42
47
|
* Useful for applying plugin config defaults without overwriting user-set values.
|
|
43
48
|
*/
|
|
44
|
-
export function deepMergeDefaults<T extends Record<string,
|
|
49
|
+
export function deepMergeDefaults<T extends Record<string, unknown>>(
|
|
45
50
|
target: T,
|
|
46
|
-
source: Record<string,
|
|
51
|
+
source: Record<string, unknown>,
|
|
47
52
|
): T {
|
|
48
53
|
return mergeWith(target, source, "defaults");
|
|
49
54
|
}
|
package/util/mcpServer.ts
CHANGED
|
@@ -3,12 +3,17 @@ import {
|
|
|
3
3
|
ResourceTemplate,
|
|
4
4
|
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6
|
+
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
7
|
+
import type {
|
|
8
|
+
ServerNotification,
|
|
9
|
+
ServerRequest,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
6
11
|
import { randomUUID } from "crypto";
|
|
7
12
|
import * as z4mini from "zod/v4-mini";
|
|
8
13
|
import { api, logger } from "../api";
|
|
9
14
|
import type { Action } from "../classes/Action";
|
|
10
15
|
import { MCP_RESPONSE_FORMAT } from "../classes/Action";
|
|
11
|
-
import { Connection } from "../classes/Connection";
|
|
16
|
+
import { CONNECTION_TYPE, Connection } from "../classes/Connection";
|
|
12
17
|
import { StreamingResponse } from "../classes/StreamingResponse";
|
|
13
18
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
14
19
|
import { config } from "../config";
|
|
@@ -55,7 +60,7 @@ export async function createMcpConnection(extra: {
|
|
|
55
60
|
const authInfo = extra.authInfo;
|
|
56
61
|
const clientIp = (authInfo?.extra?.ip as string) || "unknown";
|
|
57
62
|
const connection = new Connection(
|
|
58
|
-
|
|
63
|
+
CONNECTION_TYPE.MCP,
|
|
59
64
|
clientIp,
|
|
60
65
|
randomUUID(),
|
|
61
66
|
undefined,
|
|
@@ -159,7 +164,10 @@ function registerTools(mcpServer: McpServer) {
|
|
|
159
164
|
mcpServer.registerTool(
|
|
160
165
|
toolName,
|
|
161
166
|
toolConfig,
|
|
162
|
-
async (
|
|
167
|
+
async (
|
|
168
|
+
args: Record<string, unknown>,
|
|
169
|
+
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
|
170
|
+
) => {
|
|
163
171
|
const mcpSessionId = extra.sessionId || "";
|
|
164
172
|
const connection = await createMcpConnection(extra);
|
|
165
173
|
|
package/util/oauth.ts
CHANGED
|
@@ -40,23 +40,14 @@ export function validateRedirectUri(uri: string): {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Compare two redirect URIs
|
|
44
|
-
*
|
|
43
|
+
* Compare two redirect URIs with exact string matching, as required by
|
|
44
|
+
* RFC 6749 §3.1.2.3 and RFC 8252 §8.4.
|
|
45
45
|
*/
|
|
46
46
|
export function redirectUrisMatch(
|
|
47
47
|
registeredUri: string,
|
|
48
48
|
requestedUri: string,
|
|
49
49
|
): boolean {
|
|
50
|
-
|
|
51
|
-
const registered = new URL(registeredUri);
|
|
52
|
-
const requested = new URL(requestedUri);
|
|
53
|
-
return (
|
|
54
|
-
registered.origin === requested.origin &&
|
|
55
|
-
registered.pathname === requested.pathname
|
|
56
|
-
);
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
50
|
+
return registeredUri === requestedUri;
|
|
60
51
|
}
|
|
61
52
|
|
|
62
53
|
/** Encode a byte array as a URL-safe base64 string (no padding). Used for PKCE code challenges. */
|
|
@@ -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
|
-
|
|
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
|
+
}
|
package/util/webBasicAuth.ts
CHANGED
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
51
|
-
const passOk =
|
|
38
|
+
const userOk = safeCompare(user, expectedUsername);
|
|
39
|
+
const passOk = safeCompare(pass, expectedPassword);
|
|
52
40
|
return userOk && passOk;
|
|
53
41
|
}
|
package/util/webCompression.ts
CHANGED
|
@@ -37,7 +37,7 @@ function parseAcceptEncoding(header: string): Set<string> {
|
|
|
37
37
|
/**
|
|
38
38
|
* Pick the best encoding based on server preference order and client support.
|
|
39
39
|
*/
|
|
40
|
-
function selectEncoding(clientEncodings: Set<string>): "
|
|
40
|
+
function selectEncoding(clientEncodings: Set<string>): "gzip" | null {
|
|
41
41
|
for (const encoding of config.server.web.compression.encodings) {
|
|
42
42
|
if (clientEncodings.has(encoding)) return encoding;
|
|
43
43
|
}
|
|
@@ -53,6 +53,24 @@ function isIncompressible(contentType: string | null): boolean {
|
|
|
53
53
|
return INCOMPRESSIBLE_TYPES.has(mimeType);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Pipe a body through a gzip `CompressionStream` and build a new `Response` carrying the
|
|
58
|
+
* compression headers (`Content-Encoding`, appended `Vary`, removed `Content-Length`).
|
|
59
|
+
*/
|
|
60
|
+
function compressBody(body: ReadableStream, response: Response): Response {
|
|
61
|
+
const compressionStream = new CompressionStream("gzip");
|
|
62
|
+
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
63
|
+
const stream = body.pipeThrough(compressionStream);
|
|
64
|
+
|
|
65
|
+
const headers = new Headers(response.headers);
|
|
66
|
+
headers.set("Content-Encoding", "gzip");
|
|
67
|
+
headers.append("Vary", "Accept-Encoding");
|
|
68
|
+
headers.delete("Content-Length");
|
|
69
|
+
|
|
70
|
+
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
71
|
+
return new Response(stream, { status: response.status, headers });
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* Conditionally compress an HTTP response based on the client's `Accept-Encoding` header,
|
|
58
76
|
* the response content type, and the configured compression threshold.
|
|
@@ -86,8 +104,7 @@ export async function compressResponse(
|
|
|
86
104
|
if (!acceptEncoding) return response;
|
|
87
105
|
|
|
88
106
|
const clientEncodings = parseAcceptEncoding(acceptEncoding);
|
|
89
|
-
|
|
90
|
-
if (!encoding) return response;
|
|
107
|
+
if (!selectEncoding(clientEncodings)) return response;
|
|
91
108
|
|
|
92
109
|
// Skip incompressible content types
|
|
93
110
|
if (isIncompressible(response.headers.get("Content-Type"))) return response;
|
|
@@ -112,40 +129,9 @@ export async function compressResponse(
|
|
|
112
129
|
});
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
|
|
116
|
-
const format: Bun.CompressionFormat = encoding === "br" ? "brotli" : "gzip";
|
|
117
|
-
// @ts-ignore Bun supports "brotli" as CompressionFormat but DOM lib does not
|
|
118
|
-
const compressionStream = new CompressionStream(format);
|
|
119
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
120
|
-
const stream = new Blob([body]).stream().pipeThrough(compressionStream);
|
|
121
|
-
|
|
122
|
-
const headers = new Headers(response.headers);
|
|
123
|
-
headers.set("Content-Encoding", encoding);
|
|
124
|
-
headers.append("Vary", "Accept-Encoding");
|
|
125
|
-
headers.delete("Content-Length");
|
|
126
|
-
|
|
127
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
128
|
-
return new Response(stream, {
|
|
129
|
-
status: response.status,
|
|
130
|
-
headers,
|
|
131
|
-
});
|
|
132
|
+
return compressBody(new Blob([body]).stream(), response);
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
// Content-Length is present and above threshold — stream-compress
|
|
135
|
-
|
|
136
|
-
// @ts-ignore Bun supports "brotli" as CompressionFormat but DOM lib does not
|
|
137
|
-
const compressionStream = new CompressionStream(format);
|
|
138
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
139
|
-
const stream = response.body.pipeThrough(compressionStream);
|
|
140
|
-
|
|
141
|
-
const headers = new Headers(response.headers);
|
|
142
|
-
headers.set("Content-Encoding", encoding);
|
|
143
|
-
headers.append("Vary", "Accept-Encoding");
|
|
144
|
-
headers.delete("Content-Length");
|
|
145
|
-
|
|
146
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
147
|
-
return new Response(stream, {
|
|
148
|
-
status: response.status,
|
|
149
|
-
headers,
|
|
150
|
-
});
|
|
136
|
+
return compressBody(response.body, response);
|
|
151
137
|
}
|
|
@@ -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
|
+
}
|
package/util/zodMixins.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq } from "drizzle-orm";
|
|
1
|
+
import { type AnyColumn, eq, type Table } from "drizzle-orm";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
3
|
import { api } from "../api";
|
|
4
4
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
@@ -79,9 +79,6 @@ export function paginationInputs(options?: {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Type for Drizzle tables with an id column
|
|
83
|
-
type TableWithId = { id: any; $inferSelect: any };
|
|
84
|
-
|
|
85
82
|
/**
|
|
86
83
|
* Generic factory to create a Zod schema that accepts either an ID or a model object.
|
|
87
84
|
* If an ID is provided, it resolves to the full model via database lookup.
|
|
@@ -91,8 +88,8 @@ type TableWithId = { id: any; $inferSelect: any };
|
|
|
91
88
|
* @param isModel - Type guard function to check if value is already a model
|
|
92
89
|
* @param entityName - Human-readable name for error messages
|
|
93
90
|
*/
|
|
94
|
-
export function zIdOrModel<
|
|
95
|
-
table:
|
|
91
|
+
export function zIdOrModel<TModel>(
|
|
92
|
+
table: Table & { id: AnyColumn },
|
|
96
93
|
modelSchema: z.ZodType<TModel>,
|
|
97
94
|
isModel: (val: unknown) => val is TModel,
|
|
98
95
|
entityName: string,
|
|
@@ -105,8 +102,8 @@ export function zIdOrModel<TTable extends TableWithId, TModel>(
|
|
|
105
102
|
}
|
|
106
103
|
const [record] = await api.db.db
|
|
107
104
|
.select()
|
|
108
|
-
.from(table
|
|
109
|
-
.where(eq(
|
|
105
|
+
.from(table)
|
|
106
|
+
.where(eq(table.id, val))
|
|
110
107
|
.limit(1);
|
|
111
108
|
|
|
112
109
|
if (!record) {
|