keryx 0.29.11 → 0.31.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 +1 -1
- package/classes/API.ts +2 -2
- package/classes/Connection.ts +24 -4
- package/index.ts +2 -1
- package/initializers/channels.ts +1 -1
- package/initializers/resque.ts +2 -1
- package/package.json +10 -3
- package/servers/web.ts +22 -6
- package/templates/generate/test.ts.mustache +1 -1
- package/templates/scaffold/index.ts.mustache +1 -1
- package/templates/scaffold/vitest.config.ts.mustache +18 -0
- package/testing/index.ts +1 -1
- package/testing/websocket.ts +1 -1
- package/util/cli.ts +2 -2
- package/util/componentRegistry.ts +2 -2
- package/util/mcpServer.ts +2 -2
- package/util/oauthHandlers/authorize.ts +2 -2
- package/util/oauthTemplates.ts +1 -1
- package/util/safeCompare.ts +24 -0
- package/util/scaffold.ts +6 -3
- package/util/webBasicAuth.ts +3 -15
- package/util/webStackLeakWarning.ts +23 -0
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/API.ts
CHANGED
|
@@ -26,7 +26,7 @@ export enum RUN_MODE {
|
|
|
26
26
|
export class API {
|
|
27
27
|
/** The root directory of the user's application. Set this before calling `initialize()`. */
|
|
28
28
|
rootDir: string;
|
|
29
|
-
/** The root directory of the keryx package itself (auto-resolved from `import.meta.
|
|
29
|
+
/** The root directory of the keryx package itself (auto-resolved from `import.meta.filename`). */
|
|
30
30
|
packageDir: string;
|
|
31
31
|
/** Whether `initialize()` has completed successfully. */
|
|
32
32
|
initialized: boolean;
|
|
@@ -50,7 +50,7 @@ export class API {
|
|
|
50
50
|
|
|
51
51
|
constructor() {
|
|
52
52
|
this.bootTime = new Date().getTime();
|
|
53
|
-
this.packageDir = path.join(import.meta.
|
|
53
|
+
this.packageDir = path.join(import.meta.filename, "..", "..");
|
|
54
54
|
this.rootDir = this.packageDir;
|
|
55
55
|
this.logger = new Logger(config.logger);
|
|
56
56
|
|
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/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/channels.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { globLoader } from "../util/glob";
|
|
|
10
10
|
|
|
11
11
|
const namespace = "channels";
|
|
12
12
|
const PRESENCE_KEY_PREFIX = "presence:";
|
|
13
|
-
const LUA_DIR = join(import.meta.
|
|
13
|
+
const LUA_DIR = join(import.meta.dirname, "..", "lua");
|
|
14
14
|
|
|
15
15
|
const ADD_PRESENCE_LUA = await Bun.file(
|
|
16
16
|
join(LUA_DIR, "add-presence.lua"),
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keryx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"start": "bun keryx.ts start",
|
|
78
78
|
"dev": "bun --watch keryx.ts start",
|
|
79
79
|
"migrations": "bun run migrations.ts",
|
|
80
|
-
"test": "tsc && bun
|
|
80
|
+
"test": "tsc && bunx --bun vitest run",
|
|
81
81
|
"compile": "bun build keryx.ts --compile --outfile keryx",
|
|
82
82
|
"lint": "tsc && biome check .",
|
|
83
83
|
"format": "tsc && biome check --write .",
|
|
@@ -100,11 +100,18 @@
|
|
|
100
100
|
"peerDependencies": {
|
|
101
101
|
"drizzle-orm": "^0.45.2",
|
|
102
102
|
"drizzle-zod": "^0.8.3",
|
|
103
|
+
"vitest": "^4.1.9",
|
|
103
104
|
"zod": "^4.3.6"
|
|
104
105
|
},
|
|
106
|
+
"peerDependenciesMeta": {
|
|
107
|
+
"vitest": {
|
|
108
|
+
"optional": true
|
|
109
|
+
}
|
|
110
|
+
},
|
|
105
111
|
"devDependencies": {
|
|
106
112
|
"@types/bun": "^1.3.12",
|
|
107
113
|
"drizzle-kit": "^0.31.10",
|
|
108
|
-
"drizzle-zod": "^0.8.3"
|
|
114
|
+
"drizzle-zod": "^0.8.3",
|
|
115
|
+
"vitest": "^4.1.9"
|
|
109
116
|
}
|
|
110
117
|
}
|
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 &&
|
|
@@ -4,7 +4,7 @@ import { api } from "keryx";
|
|
|
4
4
|
// actions, initializers, channels, etc. from this directory.
|
|
5
5
|
// Every entry point (keryx.ts, migrations.ts, test setup) should
|
|
6
6
|
// `import "./index"` to ensure rootDir is set before anything runs.
|
|
7
|
-
api.rootDir = import.meta.
|
|
7
|
+
api.rootDir = import.meta.dirname;
|
|
8
8
|
|
|
9
9
|
// Re-export everything from keryx for convenience
|
|
10
10
|
export * from "keryx";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
include: ["__tests__/**/*.test.ts"],
|
|
6
|
+
globals: false,
|
|
7
|
+
// Run on the Bun runtime via `bunx --bun vitest`. The forks pool keeps
|
|
8
|
+
// Node/Bun process APIs available; file parallelism is disabled so test
|
|
9
|
+
// files that boot the server don't race on the shared database/Redis.
|
|
10
|
+
pool: "forks",
|
|
11
|
+
fileParallelism: false,
|
|
12
|
+
testTimeout: 15_000,
|
|
13
|
+
hookTimeout: 60_000,
|
|
14
|
+
// zod (and other CJS deps consumed via named imports) must be transformed
|
|
15
|
+
// by Vite so the named-export interop works under the Bun runtime.
|
|
16
|
+
server: { deps: { inline: ["zod"] } },
|
|
17
|
+
},
|
|
18
|
+
});
|
package/testing/index.ts
CHANGED
package/testing/websocket.ts
CHANGED
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);
|
|
@@ -24,13 +24,13 @@ export interface ComponentDef {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const generateTemplatesDir = path.join(
|
|
27
|
-
import.meta.
|
|
27
|
+
import.meta.dirname,
|
|
28
28
|
"..",
|
|
29
29
|
"templates",
|
|
30
30
|
"generate",
|
|
31
31
|
);
|
|
32
32
|
const scaffoldTemplatesDir = path.join(
|
|
33
|
-
import.meta.
|
|
33
|
+
import.meta.dirname,
|
|
34
34
|
"..",
|
|
35
35
|
"templates",
|
|
36
36
|
"scaffold",
|
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
|
-
|
|
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
|
-
|
|
81
|
+
CONNECTION_TYPE.OAUTH,
|
|
82
82
|
isSignup ? "oauth-signup" : "oauth-login",
|
|
83
83
|
);
|
|
84
84
|
try {
|
package/util/oauthTemplates.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type OAuthTemplates = {
|
|
|
20
20
|
lionSvg: string;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
const frameworkTemplatesDir = import.meta.
|
|
23
|
+
const frameworkTemplatesDir = import.meta.dirname + "/../templates";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Resolve a template file, checking the user's rootDir first, then falling back
|
|
@@ -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/scaffold.ts
CHANGED
|
@@ -66,7 +66,7 @@ export async function generateOAuthTemplateContents(): Promise<
|
|
|
66
66
|
"oauth-common.css",
|
|
67
67
|
"lion.svg",
|
|
68
68
|
];
|
|
69
|
-
const sourceDir = path.join(import.meta.
|
|
69
|
+
const sourceDir = path.join(import.meta.dirname, "..", "templates");
|
|
70
70
|
|
|
71
71
|
for (const file of oauthTemplates) {
|
|
72
72
|
const content = await Bun.file(path.join(sourceDir, file)).text();
|
|
@@ -85,7 +85,7 @@ export async function generateConfigFileContents(): Promise<
|
|
|
85
85
|
Map<string, string>
|
|
86
86
|
> {
|
|
87
87
|
const result = new Map<string, string>();
|
|
88
|
-
const configDir = path.join(import.meta.
|
|
88
|
+
const configDir = path.join(import.meta.dirname, "..", "config");
|
|
89
89
|
const glob = new Glob("**/*.ts");
|
|
90
90
|
|
|
91
91
|
for await (const file of glob.scan(configDir)) {
|
|
@@ -140,7 +140,7 @@ export async function generateBuiltinActionContents(): Promise<
|
|
|
140
140
|
> {
|
|
141
141
|
const result = new Map<string, string>();
|
|
142
142
|
const builtinActions = ["status.ts", "swagger.ts"];
|
|
143
|
-
const actionsDir = path.join(import.meta.
|
|
143
|
+
const actionsDir = path.join(import.meta.dirname, "..", "actions");
|
|
144
144
|
|
|
145
145
|
for (const file of builtinActions) {
|
|
146
146
|
let content = await Bun.file(path.join(actionsDir, file)).text();
|
|
@@ -318,6 +318,7 @@ export async function scaffoldProject(
|
|
|
318
318
|
start: "bun keryx.ts start",
|
|
319
319
|
dev: "bun --watch keryx.ts start",
|
|
320
320
|
...(options.includeDb ? { migrations: "bun run migrations.ts" } : {}),
|
|
321
|
+
test: "tsc && bunx --bun vitest run",
|
|
321
322
|
lint: "tsc && biome check .",
|
|
322
323
|
format: "tsc && biome check --write .",
|
|
323
324
|
},
|
|
@@ -334,6 +335,7 @@ export async function scaffoldProject(
|
|
|
334
335
|
devDependencies: {
|
|
335
336
|
"@biomejs/biome": "^2.4.8",
|
|
336
337
|
"@types/bun": "latest",
|
|
338
|
+
vitest: "^4.1.9",
|
|
337
339
|
...(options.includeDb ? { "drizzle-kit": "^0.20.18" } : {}),
|
|
338
340
|
},
|
|
339
341
|
},
|
|
@@ -343,6 +345,7 @@ export async function scaffoldProject(
|
|
|
343
345
|
);
|
|
344
346
|
|
|
345
347
|
await write("tsconfig.json", generateTsconfigContents());
|
|
348
|
+
await writeTemplate("vitest.config.ts", "vitest.config.ts.mustache");
|
|
346
349
|
await write(
|
|
347
350
|
"biome.json",
|
|
348
351
|
JSON.stringify(
|
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
|
}
|
|
@@ -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
|
+
}
|