keryx 0.29.6 → 0.29.8

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.
@@ -59,6 +59,10 @@ export type AfterActHook = (
59
59
  outcome: ActOutcome,
60
60
  ) => Promise<void> | void;
61
61
 
62
+ type ActionParamsState = {
63
+ value: Record<string, unknown>;
64
+ };
65
+
62
66
  /**
63
67
  * Represents a client connection to the server — HTTP request, WebSocket, or internal caller.
64
68
  * Each connection tracks its own session, channel subscriptions, and rate-limit state.
@@ -156,61 +160,19 @@ export class Connection<
156
160
 
157
161
  let action: Action | undefined;
158
162
  let formattedParams: Record<string, unknown> | undefined;
159
- // Cross-transport action hooks (api.hooks.actions.beforeAct / afterAct).
160
- // beforeActRan guards afterAct so we only fire after-hooks for invocations
161
- // where before-hooks also fired (i.e. action found + params validated).
163
+ let paramsState: ActionParamsState | undefined;
162
164
  const actCtx: ActContext = { metadata: {} };
163
165
  let beforeActRan = false;
164
166
  try {
165
- action = this.findAction(actionName);
166
- if (!action) {
167
- throw new TypedError({
168
- message: `Action not found${actionName ? `: ${actionName}` : ""}`,
169
- type: ErrorType.CONNECTION_ACTION_NOT_FOUND,
170
- });
171
- }
172
-
173
- // load the session once, if it hasn't been loaded yet
167
+ action = this.resolveAction(actionName);
174
168
  if (!this.sessionLoaded) await this.loadSession();
175
-
176
169
  formattedParams = await this.formatParams(params, action);
177
-
178
- for (const hook of api.hooks.actions.beforeActHooks) {
179
- await hook(action.name, formattedParams, this, actCtx);
180
- }
170
+ paramsState = { value: formattedParams };
171
+ await this.runBeforeActHooks(action, paramsState.value, actCtx);
181
172
  beforeActRan = true;
182
-
183
- for (const middleware of action.middleware ?? []) {
184
- if (middleware.runBefore) {
185
- const middlewareResponse = await middleware.runBefore(
186
- formattedParams,
187
- this,
188
- );
189
- if (middlewareResponse && middlewareResponse?.updatedParams)
190
- formattedParams = middlewareResponse.updatedParams;
191
- }
192
- }
193
-
194
- const timeoutMs = action.timeout ?? config.actions.timeout;
195
- if (timeoutMs > 0) {
196
- const controller = new AbortController();
197
- const timeoutError = new TypedError({
198
- message: `Action '${action.name}' timed out after ${timeoutMs}ms`,
199
- type: ErrorType.CONNECTION_ACTION_TIMEOUT,
200
- });
201
- const timeoutPromise = new Promise<never>((_, reject) => {
202
- setTimeout(() => {
203
- controller.abort();
204
- reject(timeoutError);
205
- }, timeoutMs);
206
- });
207
- response = await Promise.race([
208
- action.run(formattedParams, this, controller.signal),
209
- timeoutPromise,
210
- ]);
211
- } else {
212
- response = await action.run(formattedParams, this);
213
- }
173
+ await this.runMiddlewareBefore(action, paramsState);
174
+ formattedParams = paramsState.value;
175
+ response = await this.executeWithTimeout(action, formattedParams);
214
176
  } catch (e) {
215
177
  loggerResponsePrefix = "ERROR";
216
178
  error =
@@ -222,34 +184,23 @@ export class Connection<
222
184
  cause: e,
223
185
  });
224
186
  } finally {
225
- if (action && formattedParams) {
226
- for (const middleware of action.middleware ?? []) {
227
- if (middleware.runAfter) {
228
- const middlewareResponse = await middleware.runAfter(
229
- formattedParams,
230
- this,
231
- error,
232
- );
233
- if (middlewareResponse && middlewareResponse?.updatedResponse) {
234
- if (response instanceof StreamingResponse) {
235
- logger.warn(
236
- `Middleware cannot replace a StreamingResponse for action '${actionName}'`,
237
- );
238
- } else {
239
- response = middlewareResponse.updatedResponse;
240
- }
241
- }
242
- }
243
- }
244
- }
187
+ if (paramsState) formattedParams = paramsState.value;
188
+ if (action && formattedParams)
189
+ response = await this.runMiddlewareAfter(
190
+ action,
191
+ formattedParams,
192
+ error,
193
+ response,
194
+ );
245
195
  if (beforeActRan && action && formattedParams) {
246
- const actDuration = new Date().getTime() - reqStartTime;
247
- const outcome: ActOutcome = error
248
- ? { success: false, error, duration: actDuration }
249
- : { success: true, response, duration: actDuration };
250
- for (const hook of api.hooks.actions.afterActHooks) {
251
- await hook(action.name, formattedParams, this, actCtx, outcome);
252
- }
196
+ await this.runAfterActHooks(
197
+ action,
198
+ formattedParams,
199
+ actCtx,
200
+ reqStartTime,
201
+ response,
202
+ error,
203
+ );
253
204
  }
254
205
  this._actDepth--;
255
206
  }
@@ -379,6 +330,115 @@ export class Connection<
379
330
  return api.actions.actions.find((a: Action) => a.name === actionName);
380
331
  }
381
332
 
333
+ private resolveAction(actionName: string | undefined) {
334
+ const action = this.findAction(actionName);
335
+ if (!action) {
336
+ throw new TypedError({
337
+ message: `Action not found${actionName ? `: ${actionName}` : ""}`,
338
+ type: ErrorType.CONNECTION_ACTION_NOT_FOUND,
339
+ });
340
+ }
341
+
342
+ return action;
343
+ }
344
+
345
+ private async runBeforeActHooks(
346
+ action: Action,
347
+ params: Record<string, unknown>,
348
+ actCtx: ActContext,
349
+ ) {
350
+ for (const hook of api.hooks.actions.beforeActHooks) {
351
+ await hook(action.name, params, this, actCtx);
352
+ }
353
+ }
354
+
355
+ private async runAfterActHooks(
356
+ action: Action,
357
+ params: Record<string, unknown>,
358
+ actCtx: ActContext,
359
+ reqStartTime: number,
360
+ response: Object,
361
+ error: TypedError | undefined,
362
+ ) {
363
+ const actDuration = new Date().getTime() - reqStartTime;
364
+ const outcome: ActOutcome = error
365
+ ? { success: false, error, duration: actDuration }
366
+ : { success: true, response, duration: actDuration };
367
+
368
+ for (const hook of api.hooks.actions.afterActHooks) {
369
+ await hook(action.name, params, this, actCtx, outcome);
370
+ }
371
+ }
372
+
373
+ private async runMiddlewareBefore(
374
+ action: Action,
375
+ paramsState: ActionParamsState,
376
+ ) {
377
+ for (const middleware of action.middleware ?? []) {
378
+ if (middleware.runBefore) {
379
+ const middlewareResponse = await middleware.runBefore(
380
+ paramsState.value,
381
+ this,
382
+ );
383
+ if (middlewareResponse && middlewareResponse?.updatedParams)
384
+ paramsState.value = middlewareResponse.updatedParams;
385
+ }
386
+ }
387
+ }
388
+
389
+ private async runMiddlewareAfter(
390
+ action: Action,
391
+ params: Record<string, unknown>,
392
+ error: TypedError | undefined,
393
+ response: Object,
394
+ ) {
395
+ let updatedResponse = response;
396
+ for (const middleware of action.middleware ?? []) {
397
+ if (middleware.runAfter) {
398
+ const middlewareResponse = await middleware.runAfter(
399
+ params,
400
+ this,
401
+ error,
402
+ );
403
+ if (middlewareResponse && middlewareResponse?.updatedResponse) {
404
+ if (updatedResponse instanceof StreamingResponse) {
405
+ logger.warn(
406
+ `Middleware cannot replace a StreamingResponse for action '${action.name}'`,
407
+ );
408
+ } else {
409
+ updatedResponse = middlewareResponse.updatedResponse;
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ return updatedResponse;
416
+ }
417
+
418
+ private async executeWithTimeout(
419
+ action: Action,
420
+ params: Record<string, unknown>,
421
+ ): Promise<Object> {
422
+ const timeoutMs = action.timeout ?? config.actions.timeout;
423
+ if (timeoutMs <= 0) return action.run(params, this);
424
+
425
+ const controller = new AbortController();
426
+ const timeoutError = new TypedError({
427
+ message: `Action '${action.name}' timed out after ${timeoutMs}ms`,
428
+ type: ErrorType.CONNECTION_ACTION_TIMEOUT,
429
+ });
430
+ const timeoutPromise = new Promise<never>((_, reject) => {
431
+ setTimeout(() => {
432
+ controller.abort();
433
+ reject(timeoutError);
434
+ }, timeoutMs);
435
+ });
436
+
437
+ return Promise.race([
438
+ action.run(params, this, controller.signal),
439
+ timeoutPromise,
440
+ ]);
441
+ }
382
442
  private async formatParams(params: Record<string, unknown>, action: Action) {
383
443
  if (!action.inputs) return {} as ActionParams<Action>;
384
444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.29.6",
3
+ "version": "0.29.8",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/util/glob.ts CHANGED
@@ -13,34 +13,32 @@ import { ErrorType, TypedError } from "../classes/TypedError";
13
13
  */
14
14
  export async function globLoader<T>(searchDir: string) {
15
15
  const results: T[] = [];
16
- const globs = [new Glob("**/*.{ts,tsx}")];
16
+ const glob = new Glob("**/*.{ts,tsx}");
17
17
  const dir = path.isAbsolute(searchDir)
18
18
  ? searchDir
19
19
  : path.join(api.rootDir, searchDir);
20
20
 
21
- for (const glob of globs) {
22
- for await (const file of glob.scan(dir)) {
23
- if (file.startsWith(".")) continue;
21
+ for await (const file of glob.scan(dir)) {
22
+ if (file.startsWith(".")) continue;
24
23
 
25
- const fullPath = path.join(dir, file);
26
- const modules = (await import(fullPath)) as Record<string, unknown>;
24
+ const fullPath = path.join(dir, file);
25
+ const modules = (await import(fullPath)) as Record<string, unknown>;
27
26
 
28
- for (const [name, klass] of Object.entries(modules)) {
29
- // Skip non-class exports (constants, enums, functions)
30
- if (typeof klass !== "function" || klass.prototype === undefined) {
31
- continue;
32
- }
27
+ for (const [name, klass] of Object.entries(modules)) {
28
+ // Skip non-class exports (constants, enums, functions)
29
+ if (typeof klass !== "function" || klass.prototype === undefined) {
30
+ continue;
31
+ }
33
32
 
34
- try {
35
- const instance = new (klass as new () => T)();
36
- results.push(instance);
37
- } catch (error) {
38
- throw new TypedError({
39
- message: `Error loading from ${dir} - ${name} - ${error}`,
40
- type: ErrorType.SERVER_INITIALIZATION,
41
- cause: error,
42
- });
43
- }
33
+ try {
34
+ const instance = new (klass as new () => T)();
35
+ results.push(instance);
36
+ } catch (error) {
37
+ throw new TypedError({
38
+ message: `Error loading from ${dir} - ${name} - ${error}`,
39
+ type: ErrorType.SERVER_INITIALIZATION,
40
+ cause: error,
41
+ });
44
42
  }
45
43
  }
46
44
  }
@@ -83,7 +83,10 @@ async function handleAuthorizationCodeGrant(
83
83
  if (clientId !== codeData.clientId) {
84
84
  return oauthError("invalid_grant", "client_id mismatch");
85
85
  }
86
- if (redirectUri && redirectUri !== codeData.redirectUri) {
86
+ // RFC 6749 §4.1.3: redirect_uri was part of the authorize request (and
87
+ // is always stored on the code), so it MUST also be supplied here and
88
+ // match exactly. Enforcing this prevents auth-code injection attacks.
89
+ if (!redirectUri || redirectUri !== codeData.redirectUri) {
87
90
  return oauthError("invalid_grant", "redirect_uri mismatch");
88
91
  }
89
92