@flink-app/flink 2.0.0-alpha.97 → 2.0.0-alpha.99

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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @flink-app/flink
2
2
 
3
+ ## 2.0.0-alpha.99
4
+
5
+ ### Minor Changes
6
+
7
+ - ed9cd2c: Extend `onError` callback to also fire for request validation (400 Bad request) and response validation (500 Bad response) failures, in addition to handler-thrown errors. Streaming handler errors continue to be delivered via `stream.error()` and do not trigger `onError`.
8
+
9
+ The callback context now also includes the app context as `ctx`, so handlers can use repos/plugins (e.g. `context.ctx.repos.errorLogRepo`) from within `onError`. `FlinkOptions` is now generic over the app context (`FlinkOptions<C>`) with a default of `FlinkContext`, keeping existing usages backward compatible.
10
+
11
+ ## 2.0.0-alpha.98
12
+
13
+ ### Minor Changes
14
+
15
+ - 2f4a132: feat: expose verified token on the request
16
+
17
+ The JWT auth plugin now populates `req.token` (the decoded, signature-verified
18
+ payload) and `req.rawToken` (the original token string) after successful
19
+ authentication. Handlers can read token claims such as `jti` directly instead of
20
+ re-extracting and re-decoding the bearer token with an unverified decode.
21
+
22
+ ```ts
23
+ // Before
24
+ const authHeader = req.headers.authorization;
25
+ const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
26
+ const decoded = bearer ? (jsonwebtoken.decode(bearer) as { jti?: string }) : null;
27
+ const sessionId = decoded?.jti ?? legacySessionId(req.user._id);
28
+
29
+ // After
30
+ const { jti } = (req.token as { jti?: string }) ?? {};
31
+ const sessionId = jti ?? legacySessionId(req.user._id);
32
+ ```
33
+
34
+ `token` / `rawToken` are added as optional fields on the core `FlinkRequest`
35
+ type, populated by auth plugins that have a token concept.
36
+
3
37
  ## 2.0.0-alpha.97
4
38
 
5
39
  ### Minor Changes
@@ -64,7 +64,7 @@ export declare const autoRegisteredServices: {
64
64
  serviceInstanceName: string;
65
65
  Service: any;
66
66
  }[];
67
- export interface FlinkOptions {
67
+ export interface FlinkOptions<C extends FlinkContext = FlinkContext> {
68
68
  /**
69
69
  * Name of application, will only show in logs and in HTTP header.
70
70
  */
@@ -215,17 +215,26 @@ export interface FlinkOptions {
215
215
  format?: string;
216
216
  };
217
217
  /**
218
- * Optional callback invoked when an error occurs in a handler.
218
+ * Optional callback invoked when an error occurs while serving a request.
219
219
  * The error response and request context are passed for custom
220
220
  * error logging or monitoring. This is a side-effect only and
221
221
  * will not modify the response flow.
222
222
  *
223
+ * Invoked for:
224
+ * - Handler-thrown errors (FlinkErrors and unhandled exceptions)
225
+ * - Request validation failures (400 Bad request)
226
+ * - Response validation failures (500 Bad response)
227
+ *
228
+ * Not invoked for errors in streaming (SSE/NDJSON) handlers, which are
229
+ * delivered to the client via `stream.error()` instead.
230
+ *
223
231
  * Supports both synchronous and asynchronous callbacks. Any errors
224
232
  * thrown or rejected by the callback will be caught and logged
225
- * without affecting the error response to the client.
233
+ * without affecting the error response to the client. Async callbacks
234
+ * are fire-and-forget — they are not awaited before the response is sent.
226
235
  *
227
236
  * @param error - The error response with status and error details
228
- * @param context - Request context including method, path, and request ID
237
+ * @param context - Request context including method, path, request ID and the app context
229
238
  *
230
239
  * @example
231
240
  * ```ts
@@ -241,12 +250,14 @@ export interface FlinkOptions {
241
250
  * }
242
251
  * }
243
252
  *
244
- * // Asynchronous callback
253
+ * // Asynchronous callback using the app context
245
254
  * onError: async (error, context) => {
246
255
  * if (error.status >= 500) {
247
- * await monitoringService.reportError({
248
- * error,
249
- * context
256
+ * await context.ctx.repos.errorLogRepo.create({
257
+ * status: error.status,
258
+ * detail: error.error?.detail,
259
+ * route: `${context.method} ${context.path}`,
260
+ * reqId: context.reqId,
250
261
  * });
251
262
  * }
252
263
  * }
@@ -257,6 +268,7 @@ export interface FlinkOptions {
257
268
  method: HttpMethod;
258
269
  path: string;
259
270
  reqId: string;
271
+ ctx: C;
260
272
  }) => void | Promise<void>;
261
273
  }
262
274
  export interface HandlerConfig {
@@ -317,7 +329,7 @@ export declare class FlinkApp<C extends FlinkContext> {
317
329
  private allInstanceScheduler?;
318
330
  private leaderElection?;
319
331
  private accessLog;
320
- constructor(opts: FlinkOptions);
332
+ constructor(opts: FlinkOptions<C>);
321
333
  get ctx(): C;
322
334
  start(): Promise<this>;
323
335
  stop(): Promise<void>;
@@ -400,6 +412,12 @@ export declare class FlinkApp<C extends FlinkContext> {
400
412
  */
401
413
  private initPluginDb;
402
414
  private authenticate;
415
+ /**
416
+ * Invokes the optional onError callback in a fire-and-forget manner.
417
+ * Any error thrown or rejected by the callback is caught and logged so
418
+ * it never affects the error response sent to the client.
419
+ */
420
+ private invokeOnError;
403
421
  getRegisteredRoutes(): string[];
404
422
  private get isSchedulingEnabled();
405
423
  private get leaderElectionConfig();
@@ -451,7 +451,7 @@ var FlinkApp = /** @class */ (function () {
451
451
  }
452
452
  }
453
453
  this.expressApp[method](routeProps.path, function (req, res) { return __awaiter(_this, void 0, void 0, function () {
454
- var valid, formattedErrors, data, normalizedQuery, _i, _a, _b, key, value, stream, handlerRes, flinkReq_1, err_1, errorResponse, result, detail, valid, formattedErrors;
454
+ var valid, formattedErrors, errorResponse, data, normalizedQuery, _i, _a, _b, key, value, stream, handlerRes, flinkReq_1, err_1, errorResponse, detail, errorResponse, valid, formattedErrors, errorResponse;
455
455
  var _this = this;
456
456
  var _c;
457
457
  return __generator(this, function (_d) {
@@ -470,14 +470,16 @@ var FlinkApp = /** @class */ (function () {
470
470
  if (!valid) {
471
471
  formattedErrors = (0, utils_1.formatValidationErrors)(validateReq_1.errors, req.body);
472
472
  FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad request\n").concat(formattedErrors));
473
- return [2 /*return*/, res.status(400).json({
474
- status: 400,
475
- error: {
476
- id: (0, uuid_1.v4)(),
477
- title: "Bad request",
478
- detail: formattedErrors,
479
- },
480
- })];
473
+ errorResponse = {
474
+ status: 400,
475
+ error: {
476
+ id: (0, uuid_1.v4)(),
477
+ title: "Bad request",
478
+ detail: formattedErrors,
479
+ },
480
+ };
481
+ this.invokeOnError(errorResponse, req, method, routeProps.path);
482
+ return [2 /*return*/, res.status(400).json(errorResponse)];
481
483
  }
482
484
  }
483
485
  // Skip mock API for streaming handlers
@@ -568,26 +570,7 @@ var FlinkApp = /** @class */ (function () {
568
570
  console.error(err_1);
569
571
  errorResponse = (0, FlinkErrors_1.internalServerError)(err_1);
570
572
  }
571
- // Invoke onError callback if provided
572
- if (this.onError) {
573
- try {
574
- result = this.onError(errorResponse, {
575
- req: req,
576
- method: method,
577
- path: routeProps.path,
578
- reqId: req.reqId,
579
- });
580
- // Handle async callbacks - don't wait for them
581
- if (result instanceof Promise) {
582
- result.catch(function (callbackErr) {
583
- FlinkLog_1.log.error("onError callback rejected with: ".concat(callbackErr));
584
- });
585
- }
586
- }
587
- catch (callbackErr) {
588
- FlinkLog_1.log.error("onError callback threw an exception: ".concat(callbackErr));
589
- }
590
- }
573
+ this.invokeOnError(errorResponse, req, method, routeProps.path);
591
574
  return [2 /*return*/, res.status(errorResponse.status || 500).json(errorResponse)];
592
575
  case 6:
593
576
  // Skip response handling for streaming handlers (stream controls response lifecycle)
@@ -603,10 +586,12 @@ var FlinkApp = /** @class */ (function () {
603
586
  if (handlerRes.status !== 204) {
604
587
  detail = "Response schema is defined but handler returned no data";
605
588
  FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad response - ").concat(detail));
606
- return [2 /*return*/, res.status(500).json({
607
- status: 500,
608
- error: { id: (0, uuid_1.v4)(), title: "Bad response", detail: detail },
609
- })];
589
+ errorResponse = {
590
+ status: 500,
591
+ error: { id: (0, uuid_1.v4)(), title: "Bad response", detail: detail },
592
+ };
593
+ this.invokeOnError(errorResponse, req, method, routeProps.path);
594
+ return [2 /*return*/, res.status(500).json(errorResponse)];
610
595
  }
611
596
  }
612
597
  else {
@@ -614,14 +599,16 @@ var FlinkApp = /** @class */ (function () {
614
599
  if (!valid) {
615
600
  formattedErrors = (0, utils_1.formatValidationErrors)(validateRes_1.errors, handlerRes.data);
616
601
  FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad response\n").concat(formattedErrors));
617
- return [2 /*return*/, res.status(500).json({
618
- status: 500,
619
- error: {
620
- id: (0, uuid_1.v4)(),
621
- title: "Bad response",
622
- detail: formattedErrors,
623
- },
624
- })];
602
+ errorResponse = {
603
+ status: 500,
604
+ error: {
605
+ id: (0, uuid_1.v4)(),
606
+ title: "Bad response",
607
+ detail: formattedErrors,
608
+ },
609
+ };
610
+ this.invokeOnError(errorResponse, req, method, routeProps.path);
611
+ return [2 /*return*/, res.status(500).json(errorResponse)];
625
612
  }
626
613
  }
627
614
  }
@@ -1306,6 +1293,34 @@ var FlinkApp = /** @class */ (function () {
1306
1293
  });
1307
1294
  });
1308
1295
  };
1296
+ /**
1297
+ * Invokes the optional onError callback in a fire-and-forget manner.
1298
+ * Any error thrown or rejected by the callback is caught and logged so
1299
+ * it never affects the error response sent to the client.
1300
+ */
1301
+ FlinkApp.prototype.invokeOnError = function (errorResponse, req, method, path) {
1302
+ if (!this.onError) {
1303
+ return;
1304
+ }
1305
+ try {
1306
+ var result = this.onError(errorResponse, {
1307
+ req: req,
1308
+ method: method,
1309
+ path: path,
1310
+ reqId: req.reqId,
1311
+ ctx: this.ctx,
1312
+ });
1313
+ // Handle async callbacks - don't wait for them
1314
+ if (result instanceof Promise) {
1315
+ result.catch(function (callbackErr) {
1316
+ FlinkLog_1.log.error("onError callback rejected with: ".concat(callbackErr));
1317
+ });
1318
+ }
1319
+ }
1320
+ catch (callbackErr) {
1321
+ FlinkLog_1.log.error("onError callback threw an exception: ".concat(callbackErr));
1322
+ }
1323
+ };
1309
1324
  FlinkApp.prototype.getRegisteredRoutes = function () {
1310
1325
  return Array.from(this.handlerRouteCache.values());
1311
1326
  };
@@ -93,11 +93,18 @@ export interface StreamWriter<T = any> {
93
93
  * userPermissions is populated by auth plugins during authentication and contains
94
94
  * the resolved permissions array based on the plugin's configuration (roles, dynamic
95
95
  * roles, custom permissions, etc.)
96
+ *
97
+ * token / rawToken are populated by auth plugins that have a token concept (e.g. JWT).
98
+ * `token` is the decoded, signature-verified payload; `rawToken` is the original token
99
+ * string that authenticated the request (Bearer header or custom tokenExtractor output).
100
+ * Both are optional and only set once authentication succeeds.
96
101
  */
97
102
  export type FlinkRequest<T = any, P = Params, Q = Query> = Request<P, any, T, Q> & {
98
103
  reqId: string;
99
104
  user?: any;
100
105
  userPermissions?: string[];
106
+ token?: any;
107
+ rawToken?: string;
101
108
  };
102
109
  /**
103
110
  * Route props to control routing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "2.0.0-alpha.97",
3
+ "version": "2.0.0-alpha.99",
4
4
  "description": "Typescript only framework for creating REST-like APIs on top of Express and mongodb",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "main": "dist/src/index.js",
@@ -0,0 +1,151 @@
1
+ import { FlinkApp } from "../src/FlinkApp";
2
+ import { FlinkContext } from "../src/FlinkContext";
3
+ import { FlinkError } from "../src/FlinkErrors";
4
+ import { GetHandler, Handler, HttpMethod } from "../src/FlinkHttpHandler";
5
+ import { FlinkResponse } from "../src/FlinkResponse";
6
+
7
+ const request = require("supertest");
8
+
9
+ interface TestContext extends FlinkContext {}
10
+
11
+ const reqSchema = {
12
+ type: "object",
13
+ properties: {
14
+ name: { type: "string" },
15
+ },
16
+ required: ["name"],
17
+ additionalProperties: false,
18
+ };
19
+
20
+ const resSchema = {
21
+ type: "object",
22
+ properties: {
23
+ id: { type: "string" },
24
+ },
25
+ required: ["id"],
26
+ };
27
+
28
+ describe("FlinkApp onError invocation", () => {
29
+ let app: FlinkApp<TestContext>;
30
+ let calls: { error: FlinkResponse<FlinkError>; context: any }[];
31
+
32
+ const onError = (error: FlinkResponse<FlinkError>, context: any) => {
33
+ calls.push({ error, context });
34
+ };
35
+
36
+ beforeEach(() => {
37
+ calls = [];
38
+ });
39
+
40
+ afterEach(async () => {
41
+ if (app && app.started) {
42
+ await app.stop();
43
+ }
44
+ });
45
+
46
+ it("should invoke onError for request validation 400s", async () => {
47
+ const handler: Handler<TestContext, any, any> = async () => {
48
+ return { data: { id: "ok" } };
49
+ };
50
+
51
+ app = new FlinkApp<TestContext>({ name: "test-onerror-req", port: 4210, onError });
52
+ await app.start();
53
+
54
+ app.addHandler({
55
+ default: handler,
56
+ Route: { method: HttpMethod.post, path: "/test", reqSchema },
57
+ });
58
+
59
+ const response = await request(app.expressApp).post("/test").send({ wrong: "field" });
60
+
61
+ expect(response.status).toBe(400);
62
+ expect(calls.length).toBe(1);
63
+ expect(calls[0].error.status).toBe(400);
64
+ expect(calls[0].error.error?.title).toBe("Bad request");
65
+ expect(calls[0].context.method).toBe(HttpMethod.post);
66
+ expect(calls[0].context.path).toBe("/test");
67
+ expect(calls[0].context.reqId).toBeDefined();
68
+ });
69
+
70
+ it("should invoke onError for response validation 500s (invalid shape)", async () => {
71
+ const handler: GetHandler<TestContext, any> = async () => {
72
+ return { data: { wrongField: 123 } }; // missing required "id"
73
+ };
74
+
75
+ app = new FlinkApp<TestContext>({ name: "test-onerror-res", port: 4211, onError });
76
+ await app.start();
77
+
78
+ app.addHandler({
79
+ default: handler,
80
+ Route: { method: HttpMethod.get, path: "/test", resSchema },
81
+ });
82
+
83
+ const response = await request(app.expressApp).get("/test");
84
+
85
+ expect(response.status).toBe(500);
86
+ expect(calls.length).toBe(1);
87
+ expect(calls[0].error.status).toBe(500);
88
+ expect(calls[0].error.error?.title).toBe("Bad response");
89
+ });
90
+
91
+ it("should invoke onError for response validation 500s (no data)", async () => {
92
+ const handler: GetHandler<TestContext, any> = async () => {
93
+ return { status: 200 } as any; // no data
94
+ };
95
+
96
+ app = new FlinkApp<TestContext>({ name: "test-onerror-nodata", port: 4212, onError });
97
+ await app.start();
98
+
99
+ app.addHandler({
100
+ default: handler,
101
+ Route: { method: HttpMethod.get, path: "/test", resSchema },
102
+ });
103
+
104
+ const response = await request(app.expressApp).get("/test");
105
+
106
+ expect(response.status).toBe(500);
107
+ expect(calls.length).toBe(1);
108
+ expect(calls[0].error.status).toBe(500);
109
+ expect(calls[0].error.error?.title).toBe("Bad response");
110
+ });
111
+
112
+ it("should still invoke onError for handler-thrown errors", async () => {
113
+ const handler: GetHandler<TestContext, any> = async () => {
114
+ throw new Error("boom");
115
+ };
116
+
117
+ app = new FlinkApp<TestContext>({ name: "test-onerror-throw", port: 4213, onError });
118
+ await app.start();
119
+
120
+ app.addHandler({
121
+ default: handler,
122
+ Route: { method: HttpMethod.get, path: "/test" },
123
+ });
124
+
125
+ const response = await request(app.expressApp).get("/test");
126
+
127
+ expect(response.status).toBe(500);
128
+ expect(calls.length).toBe(1);
129
+ expect(calls[0].error.status).toBe(500);
130
+ });
131
+
132
+ it("should pass the app context (ctx) to the callback", async () => {
133
+ const handler: GetHandler<TestContext, any> = async () => {
134
+ throw new Error("boom");
135
+ };
136
+
137
+ app = new FlinkApp<TestContext>({ name: "test-onerror-ctx", port: 4214, onError });
138
+ await app.start();
139
+
140
+ app.addHandler({
141
+ default: handler,
142
+ Route: { method: HttpMethod.get, path: "/test" },
143
+ });
144
+
145
+ await request(app.expressApp).get("/test");
146
+
147
+ expect(calls.length).toBe(1);
148
+ expect(calls[0].context.ctx).toBeDefined();
149
+ expect(calls[0].context.ctx).toBe(app.ctx);
150
+ });
151
+ });
package/src/FlinkApp.ts CHANGED
@@ -99,7 +99,7 @@ export const autoRegisteredServices: {
99
99
  Service: any;
100
100
  }[] = [];
101
101
 
102
- export interface FlinkOptions {
102
+ export interface FlinkOptions<C extends FlinkContext = FlinkContext> {
103
103
  /**
104
104
  * Name of application, will only show in logs and in HTTP header.
105
105
  */
@@ -269,17 +269,26 @@ export interface FlinkOptions {
269
269
  };
270
270
 
271
271
  /**
272
- * Optional callback invoked when an error occurs in a handler.
272
+ * Optional callback invoked when an error occurs while serving a request.
273
273
  * The error response and request context are passed for custom
274
274
  * error logging or monitoring. This is a side-effect only and
275
275
  * will not modify the response flow.
276
276
  *
277
+ * Invoked for:
278
+ * - Handler-thrown errors (FlinkErrors and unhandled exceptions)
279
+ * - Request validation failures (400 Bad request)
280
+ * - Response validation failures (500 Bad response)
281
+ *
282
+ * Not invoked for errors in streaming (SSE/NDJSON) handlers, which are
283
+ * delivered to the client via `stream.error()` instead.
284
+ *
277
285
  * Supports both synchronous and asynchronous callbacks. Any errors
278
286
  * thrown or rejected by the callback will be caught and logged
279
- * without affecting the error response to the client.
287
+ * without affecting the error response to the client. Async callbacks
288
+ * are fire-and-forget — they are not awaited before the response is sent.
280
289
  *
281
290
  * @param error - The error response with status and error details
282
- * @param context - Request context including method, path, and request ID
291
+ * @param context - Request context including method, path, request ID and the app context
283
292
  *
284
293
  * @example
285
294
  * ```ts
@@ -295,12 +304,14 @@ export interface FlinkOptions {
295
304
  * }
296
305
  * }
297
306
  *
298
- * // Asynchronous callback
307
+ * // Asynchronous callback using the app context
299
308
  * onError: async (error, context) => {
300
309
  * if (error.status >= 500) {
301
- * await monitoringService.reportError({
302
- * error,
303
- * context
310
+ * await context.ctx.repos.errorLogRepo.create({
311
+ * status: error.status,
312
+ * detail: error.error?.detail,
313
+ * route: `${context.method} ${context.path}`,
314
+ * reqId: context.reqId,
304
315
  * });
305
316
  * }
306
317
  * }
@@ -313,6 +324,7 @@ export interface FlinkOptions {
313
324
  method: HttpMethod;
314
325
  path: string;
315
326
  reqId: string;
327
+ ctx: C;
316
328
  }
317
329
  ) => void | Promise<void>;
318
330
  }
@@ -362,7 +374,7 @@ export class FlinkApp<C extends FlinkContext> {
362
374
  private schedulingOptions?: FlinkOptions["scheduling"];
363
375
  private disableHttpServer = false;
364
376
  private expressServer: any; // for simplicity, we don't want to import types from express/node here
365
- private onError?: FlinkOptions["onError"];
377
+ private onError?: FlinkOptions<C>["onError"];
366
378
 
367
379
  private repos: { [x: string]: FlinkRepo<C, any> } = {};
368
380
  private services: { [x: string]: FlinkService<C> } = {};
@@ -383,7 +395,7 @@ export class FlinkApp<C extends FlinkContext> {
383
395
 
384
396
  private accessLog: { enabled: boolean; format: string };
385
397
 
386
- constructor(opts: FlinkOptions) {
398
+ constructor(opts: FlinkOptions<C>) {
387
399
  // Load config file and initialize logging
388
400
  const { loadFlinkConfig } = require("./utils/loadFlinkConfig");
389
401
  const flinkConfig = loadFlinkConfig();
@@ -713,14 +725,18 @@ export class FlinkApp<C extends FlinkContext> {
713
725
  const formattedErrors = formatValidationErrors(validateReq.errors, req.body);
714
726
  log.warn(`[${req.reqId}] ${methodAndRoute}: Bad request\n${formattedErrors}`);
715
727
 
716
- return res.status(400).json({
728
+ const errorResponse: FlinkResponse<FlinkError> = {
717
729
  status: 400,
718
730
  error: {
719
731
  id: v4(),
720
732
  title: "Bad request",
721
733
  detail: formattedErrors,
722
734
  },
723
- });
735
+ };
736
+
737
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
738
+
739
+ return res.status(400).json(errorResponse);
724
740
  }
725
741
  }
726
742
 
@@ -813,26 +829,7 @@ export class FlinkApp<C extends FlinkContext> {
813
829
  errorResponse = internalServerError(err as any);
814
830
  }
815
831
 
816
- // Invoke onError callback if provided
817
- if (this.onError) {
818
- try {
819
- const result = this.onError(errorResponse, {
820
- req: req as FlinkRequest,
821
- method: method!,
822
- path: routeProps.path,
823
- reqId: req.reqId,
824
- });
825
-
826
- // Handle async callbacks - don't wait for them
827
- if (result instanceof Promise) {
828
- result.catch((callbackErr) => {
829
- log.error(`onError callback rejected with: ${callbackErr}`);
830
- });
831
- }
832
- } catch (callbackErr) {
833
- log.error(`onError callback threw an exception: ${callbackErr}`);
834
- }
835
- }
832
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
836
833
 
837
834
  return res.status(errorResponse.status || 500).json(errorResponse);
838
835
  }
@@ -853,10 +850,15 @@ export class FlinkApp<C extends FlinkContext> {
853
850
  const detail =
854
851
  "Response schema is defined but handler returned no data";
855
852
  log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response - ${detail}`);
856
- return res.status(500).json({
853
+
854
+ const errorResponse: FlinkResponse<FlinkError> = {
857
855
  status: 500,
858
856
  error: { id: v4(), title: "Bad response", detail },
859
- });
857
+ };
858
+
859
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
860
+
861
+ return res.status(500).json(errorResponse);
860
862
  }
861
863
  } else {
862
864
  const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
@@ -865,14 +867,18 @@ export class FlinkApp<C extends FlinkContext> {
865
867
  const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
866
868
  log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
867
869
 
868
- return res.status(500).json({
870
+ const errorResponse: FlinkResponse<FlinkError> = {
869
871
  status: 500,
870
872
  error: {
871
873
  id: v4(),
872
874
  title: "Bad response",
873
875
  detail: formattedErrors,
874
876
  },
875
- });
877
+ };
878
+
879
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
880
+
881
+ return res.status(500).json(errorResponse);
876
882
  }
877
883
  }
878
884
  }
@@ -1583,6 +1589,36 @@ export class FlinkApp<C extends FlinkContext> {
1583
1589
  return await this.auth.authenticateRequest(req as FlinkRequest, permissions, this._ctx);
1584
1590
  }
1585
1591
 
1592
+ /**
1593
+ * Invokes the optional onError callback in a fire-and-forget manner.
1594
+ * Any error thrown or rejected by the callback is caught and logged so
1595
+ * it never affects the error response sent to the client.
1596
+ */
1597
+ private invokeOnError(errorResponse: FlinkResponse<FlinkError>, req: FlinkRequest, method: HttpMethod, path: string) {
1598
+ if (!this.onError) {
1599
+ return;
1600
+ }
1601
+
1602
+ try {
1603
+ const result = this.onError(errorResponse, {
1604
+ req,
1605
+ method,
1606
+ path,
1607
+ reqId: req.reqId,
1608
+ ctx: this.ctx,
1609
+ });
1610
+
1611
+ // Handle async callbacks - don't wait for them
1612
+ if (result instanceof Promise) {
1613
+ result.catch((callbackErr) => {
1614
+ log.error(`onError callback rejected with: ${callbackErr}`);
1615
+ });
1616
+ }
1617
+ } catch (callbackErr) {
1618
+ log.error(`onError callback threw an exception: ${callbackErr}`);
1619
+ }
1620
+ }
1621
+
1586
1622
  public getRegisteredRoutes() {
1587
1623
  return Array.from(this.handlerRouteCache.values());
1588
1624
  }
@@ -104,11 +104,18 @@ export interface StreamWriter<T = any> {
104
104
  * userPermissions is populated by auth plugins during authentication and contains
105
105
  * the resolved permissions array based on the plugin's configuration (roles, dynamic
106
106
  * roles, custom permissions, etc.)
107
+ *
108
+ * token / rawToken are populated by auth plugins that have a token concept (e.g. JWT).
109
+ * `token` is the decoded, signature-verified payload; `rawToken` is the original token
110
+ * string that authenticated the request (Bearer header or custom tokenExtractor output).
111
+ * Both are optional and only set once authentication succeeds.
107
112
  */
108
113
  export type FlinkRequest<T = any, P = Params, Q = Query> = Request<P, any, T, Q> & {
109
114
  reqId: string;
110
115
  user?: any;
111
116
  userPermissions?: string[]; // Resolved permissions from auth plugin
117
+ token?: any; // Decoded, verified token payload (set by auth plugin)
118
+ rawToken?: string; // Raw token string that authenticated the request
112
119
  };
113
120
 
114
121
  /**