serverless-event-orchestrator 2.0.1 → 2.3.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.
Files changed (52) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +489 -434
  3. package/dist/dispatcher.d.ts +6 -1
  4. package/dist/dispatcher.d.ts.map +1 -1
  5. package/dist/dispatcher.js +66 -7
  6. package/dist/dispatcher.js.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/types/event-type.enum.d.ts +1 -0
  11. package/dist/types/event-type.enum.d.ts.map +1 -1
  12. package/dist/types/event-type.enum.js +1 -0
  13. package/dist/types/event-type.enum.js.map +1 -1
  14. package/dist/types/routes.d.ts +6 -0
  15. package/dist/types/routes.d.ts.map +1 -1
  16. package/jest.config.js +32 -32
  17. package/package.json +82 -81
  18. package/src/dispatcher.ts +586 -519
  19. package/src/http/body-parser.ts +60 -60
  20. package/src/http/cors.ts +76 -76
  21. package/src/http/index.ts +3 -3
  22. package/src/http/response.ts +209 -209
  23. package/src/identity/extractor.ts +207 -207
  24. package/src/identity/index.ts +2 -2
  25. package/src/identity/jwt-verifier.ts +41 -41
  26. package/src/index.ts +128 -127
  27. package/src/middleware/crm-guard.ts +51 -51
  28. package/src/middleware/index.ts +3 -3
  29. package/src/middleware/init-tenant-context.ts +59 -59
  30. package/src/middleware/tenant-guard.ts +54 -54
  31. package/src/tenant/TenantContext.ts +115 -115
  32. package/src/tenant/helpers.ts +112 -112
  33. package/src/tenant/index.ts +21 -21
  34. package/src/tenant/types.ts +101 -101
  35. package/src/types/event-type.enum.ts +21 -20
  36. package/src/types/index.ts +2 -2
  37. package/src/types/routes.ts +218 -211
  38. package/src/utils/headers.ts +72 -72
  39. package/src/utils/index.ts +2 -2
  40. package/src/utils/path-matcher.ts +84 -84
  41. package/tests/cors.test.ts +133 -133
  42. package/tests/dispatcher.test.ts +795 -715
  43. package/tests/headers.test.ts +99 -99
  44. package/tests/identity.test.ts +301 -301
  45. package/tests/middleware/crm-guard.test.ts +69 -69
  46. package/tests/middleware/init-tenant-context.test.ts +147 -147
  47. package/tests/middleware/tenant-guard.test.ts +100 -100
  48. package/tests/path-matcher.test.ts +102 -102
  49. package/tests/response.test.ts +155 -155
  50. package/tests/tenant/TenantContext.test.ts +134 -134
  51. package/tests/tenant/helpers.test.ts +187 -187
  52. package/tsconfig.json +24 -24
package/src/dispatcher.ts CHANGED
@@ -1,519 +1,586 @@
1
- import { EventType, RouteSegment } from './types/event-type.enum.js';
2
- import {
3
- DispatchRoutes,
4
- HttpMethod,
5
- HttpRouter,
6
- SegmentedHttpRouter,
7
- AdvancedSegmentedRouter,
8
- SegmentConfig,
9
- RouteConfig,
10
- RouteMatch,
11
- NormalizedEvent,
12
- OrchestratorConfig,
13
- MiddlewareFn,
14
- } from './types/routes.js';
15
- import { matchPath, normalizePath } from './utils/path-matcher.js';
16
- import { normalizeHeaders } from './utils/headers.js';
17
- import { parseJsonBody, parseQueryParams } from './http/body-parser.js';
18
- import { extractIdentity, validateIssuer } from './identity/extractor.js';
19
- import type { JwtVerificationPoolConfig } from './types/routes.js';
20
- import { forbiddenResponse, badRequestResponse, notFoundResponse, unauthorizedResponse } from './http/response.js';
21
-
22
- /**
23
- * Detects the type of AWS event
24
- */
25
- export function detectEventType(event: any): EventType {
26
- if (event.source === 'EVENT_BRIDGE') return EventType.EventBridge;
27
- if (event.requestContext && event.httpMethod) return EventType.ApiGateway;
28
- if (event.Records && Array.isArray(event.Records) && event.Records[0]?.eventSource === 'aws:sqs') return EventType.Sqs;
29
- if (event.awsRequestId) return EventType.Lambda;
30
- return EventType.Unknown;
31
- }
32
-
33
- /**
34
- * Checks if a router is segmented (has public/private/backoffice/internal keys)
35
- */
36
- function isSegmentedRouter(router: any): router is SegmentedHttpRouter | AdvancedSegmentedRouter {
37
- if (!router || typeof router !== 'object') return false;
38
- const segmentKeys = ['public', 'private', 'backoffice', 'internal'];
39
- const routerKeys = Object.keys(router);
40
- return routerKeys.some(key => segmentKeys.includes(key));
41
- }
42
-
43
- /**
44
- * Checks if a segment config has middleware
45
- */
46
- function isSegmentConfig(config: any): config is SegmentConfig {
47
- return config && typeof config === 'object' && 'routes' in config;
48
- }
49
-
50
- /**
51
- * Gets the HttpRouter from a segment (handles both simple and advanced config)
52
- */
53
- function getRouterFromSegment(segment: HttpRouter | SegmentConfig | undefined): HttpRouter | undefined {
54
- if (!segment) return undefined;
55
- if (isSegmentConfig(segment)) return segment.routes;
56
- return segment;
57
- }
58
-
59
- /**
60
- * Gets middleware from a segment config
61
- */
62
- function getMiddlewareFromSegment(segment: HttpRouter | SegmentConfig | undefined): MiddlewareFn[] {
63
- if (!segment) return [];
64
- if (isSegmentConfig(segment)) return segment.middleware ?? [];
65
- return [];
66
- }
67
-
68
-
69
- /**
70
- * Finds a matching route using pattern for lookup and actualPath for params extraction
71
- */
72
- function findRouteInRouterWithActualPath(
73
- router: HttpRouter | undefined,
74
- method: HttpMethod,
75
- routePattern: string,
76
- actualPath: string
77
- ): { config: RouteConfig; params: Record<string, string> } | null {
78
- if (!router) return null;
79
-
80
- const methodRoutes = router[method];
81
- if (!methodRoutes) return null;
82
-
83
- const normalizedPattern = normalizePath(routePattern);
84
- const normalizedActualPath = normalizePath(actualPath);
85
-
86
- // First, try exact match with the pattern
87
- if (methodRoutes[normalizedPattern]) {
88
- // Extract params from the actual path using the pattern
89
- const params = matchPath(normalizedPattern, normalizedActualPath) ?? {};
90
- return { config: methodRoutes[normalizedPattern], params };
91
- }
92
-
93
- // Then, try pattern matching
94
- for (const [pattern, config] of Object.entries(methodRoutes)) {
95
- const params = matchPath(pattern, normalizedActualPath);
96
- if (params !== null) {
97
- return { config, params };
98
- }
99
- }
100
-
101
- return null;
102
- }
103
-
104
- /**
105
- * Finds a route across all segments with actual path for params
106
- */
107
- function findRouteInSegmentsWithActualPath(
108
- router: SegmentedHttpRouter | AdvancedSegmentedRouter,
109
- method: HttpMethod,
110
- routePattern: string,
111
- actualPath: string
112
- ): RouteMatch | null {
113
- const segments: RouteSegment[] = [
114
- RouteSegment.Public,
115
- RouteSegment.Private,
116
- RouteSegment.Backoffice,
117
- RouteSegment.Internal,
118
- ];
119
-
120
- for (const segment of segments) {
121
- const segmentRouter = router[segment];
122
- const httpRouter = getRouterFromSegment(segmentRouter);
123
- const result = findRouteInRouterWithActualPath(httpRouter, method, routePattern, actualPath);
124
-
125
- if (result) {
126
- return {
127
- handler: result.config.handler,
128
- params: result.params,
129
- segment,
130
- middleware: getMiddlewareFromSegment(segmentRouter),
131
- config: result.config,
132
- };
133
- }
134
- }
135
-
136
- return null;
137
- }
138
-
139
-
140
- /**
141
- * Normalizes an API Gateway event into a standard format
142
- */
143
- async function normalizeApiGatewayEvent(
144
- event: any,
145
- segment: RouteSegment,
146
- extractedParams: Record<string, string>,
147
- autoExtract: boolean = false,
148
- jwtVerificationConfig?: JwtVerificationPoolConfig
149
- ): Promise<NormalizedEvent> {
150
- const identity = await extractIdentity(event, { autoExtract, jwtVerificationConfig });
151
-
152
- // Safely extract pathParameters from event (handle null/undefined)
153
- const eventPathParams: Record<string, string> = event.pathParameters && typeof event.pathParameters === 'object'
154
- ? { ...event.pathParameters }
155
- : {};
156
-
157
- // Merge: pathParameters from original event + extracted params (extractedParams takes priority)
158
- const params = { ...eventPathParams, ...extractedParams };
159
-
160
- return {
161
- eventRaw: event,
162
- eventType: EventType.ApiGateway,
163
- payload: {
164
- body: parseJsonBody(event.body, event.isBase64Encoded),
165
- pathParameters: params,
166
- queryStringParameters: parseQueryParams(event.queryStringParameters),
167
- headers: normalizeHeaders(event.headers),
168
- },
169
- params,
170
- context: {
171
- segment,
172
- identity,
173
- requestId: event.requestContext?.requestId,
174
- },
175
- };
176
- }
177
-
178
- /**
179
- * Normalizes an EventBridge event
180
- */
181
- function normalizeEventBridgeEvent(event: any): NormalizedEvent {
182
- return {
183
- eventRaw: event,
184
- eventType: EventType.EventBridge,
185
- payload: {
186
- body: event.detail,
187
- },
188
- params: {},
189
- context: {
190
- segment: RouteSegment.Internal,
191
- requestId: event.id,
192
- },
193
- };
194
- }
195
-
196
- /**
197
- * Normalizes an SQS event
198
- */
199
- function normalizeSqsEvent(event: any): NormalizedEvent {
200
- const record = event.Records[0];
201
- let body: Record<string, any> = {};
202
-
203
- try {
204
- body = JSON.parse(record.body) as Record<string, any>;
205
- } catch {
206
- body = { rawBody: record.body };
207
- }
208
-
209
- return {
210
- eventRaw: event,
211
- eventType: EventType.Sqs,
212
- payload: {
213
- body,
214
- },
215
- params: {},
216
- context: {
217
- segment: RouteSegment.Internal,
218
- requestId: record.messageId,
219
- },
220
- };
221
- }
222
-
223
- /**
224
- * Normalizes a Lambda invocation event
225
- */
226
- function normalizeLambdaEvent(event: any): NormalizedEvent {
227
- return {
228
- eventRaw: event,
229
- eventType: EventType.Lambda,
230
- payload: {
231
- body: event,
232
- },
233
- params: {},
234
- context: {
235
- segment: RouteSegment.Internal,
236
- },
237
- };
238
- }
239
-
240
- /**
241
- * Validates User Pool for a segment
242
- */
243
- function validateSegmentUserPool(
244
- normalized: NormalizedEvent,
245
- segment: RouteSegment,
246
- config: OrchestratorConfig
247
- ): boolean {
248
- // Public routes don't require validation
249
- if (segment === RouteSegment.Public) return true;
250
-
251
- // If no user pool config, skip validation
252
- const expectedUserPoolId = config.userPools?.[segment];
253
- if (!expectedUserPoolId) return true;
254
-
255
- // Validate issuer matches expected user pool
256
- return validateIssuer(normalized.context.identity, expectedUserPoolId);
257
- }
258
-
259
- /**
260
- * Checks if a thrown value is an HTTP response (used by middleware to halt execution)
261
- */
262
- function isHttpResponse(value: unknown): value is { statusCode: number; body: string } {
263
- return (
264
- typeof value === 'object' &&
265
- value !== null &&
266
- 'statusCode' in value &&
267
- typeof (value as any).statusCode === 'number'
268
- );
269
- }
270
-
271
- /**
272
- * Executes middleware chain.
273
- * If a middleware throws an HttpResponse-like object (has statusCode),
274
- * it is treated as an early return (e.g., 403 Forbidden from tenantGuard).
275
- * If it throws a regular Error, it is re-thrown.
276
- */
277
- async function executeMiddleware(
278
- middleware: MiddlewareFn[],
279
- event: NormalizedEvent
280
- ): Promise<NormalizedEvent> {
281
- let currentEvent = event;
282
-
283
- for (const mw of middleware) {
284
- const result = await mw(currentEvent);
285
- if (result) {
286
- currentEvent = result;
287
- }
288
- }
289
-
290
- return currentEvent;
291
- }
292
-
293
- /**
294
- * Applies CORS headers to any API Gateway response
295
- * This ensures CORS works regardless of how the handler builds its response
296
- */
297
- function applyCorsToResponse(response: any): any {
298
- if (!response || typeof response !== 'object') return response;
299
-
300
- const corsOrigin = process.env.CORS_ALLOWED_ORIGINS || '*';
301
- const corsHeaders = process.env.CORS_ALLOWED_HEADERS ||
302
- 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token,appVersion,app-version,platform,geo,x-forwarded-for,x-real-ip';
303
- const corsMethods = process.env.CORS_ALLOWED_METHODS || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
304
-
305
- const existingHeaders = response.headers || {};
306
-
307
- return {
308
- ...response,
309
- headers: {
310
- 'Access-Control-Allow-Origin': corsOrigin,
311
- 'Access-Control-Allow-Headers': corsHeaders,
312
- 'Access-Control-Allow-Methods': corsMethods,
313
- ...existingHeaders,
314
- },
315
- };
316
- }
317
-
318
- /**
319
- * Main dispatch function with all improvements
320
- */
321
- export async function dispatchEvent(
322
- event: any,
323
- routes: DispatchRoutes,
324
- config: OrchestratorConfig = {}
325
- ): Promise<any> {
326
- const debug = config.debug ?? false;
327
-
328
- if (debug) {
329
- console.log('[SEO] Event received:', JSON.stringify(event, null, 2));
330
- }
331
-
332
- const type = detectEventType(event);
333
-
334
- if (debug) {
335
- console.log('[SEO] Event type:', type);
336
- }
337
-
338
- // Handle API Gateway events
339
- if (type === EventType.ApiGateway) {
340
- const method = event.httpMethod?.toLowerCase() as HttpMethod;
341
- const routePattern = event.resource || event.path;
342
- const actualPath = event.path || event.resource;
343
-
344
- // Handle CORS preflight requests automatically
345
- if (method === 'options') {
346
- if (debug) {
347
- console.log('[SEO] Handling OPTIONS preflight request');
348
- }
349
- const corsOrigin = process.env.CORS_ALLOWED_ORIGINS || '*';
350
- const corsHeaders = process.env.CORS_ALLOWED_HEADERS ||
351
- 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token,appVersion,app-version,platform,geo,x-forwarded-for,x-real-ip';
352
- const corsMethods = process.env.CORS_ALLOWED_METHODS || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
353
- const corsMaxAge = process.env.CORS_MAX_AGE || '600';
354
-
355
- return {
356
- statusCode: 204,
357
- headers: {
358
- 'Access-Control-Allow-Origin': corsOrigin,
359
- 'Access-Control-Allow-Headers': corsHeaders,
360
- 'Access-Control-Allow-Methods': corsMethods,
361
- 'Access-Control-Max-Age': corsMaxAge,
362
- },
363
- body: '',
364
- };
365
- }
366
-
367
- if (debug) {
368
- console.log('[SEO] Method:', method, 'Path:', routePattern, 'Actual:', actualPath);
369
- }
370
-
371
- const apiRoutes = routes.apigateway;
372
- if (!apiRoutes) {
373
- return applyCorsToResponse(config.responses?.notFound?.() ?? notFoundResponse('No API routes configured'));
374
- }
375
-
376
- let routeMatch: RouteMatch | null = null;
377
-
378
- // Use routePattern for finding routes, but extract params from actualPath
379
- if (isSegmentedRouter(apiRoutes)) {
380
- routeMatch = findRouteInSegmentsWithActualPath(apiRoutes, method, routePattern, actualPath);
381
- } else {
382
- // Flat router - treat as public
383
- const result = findRouteInRouterWithActualPath(apiRoutes as HttpRouter, method, routePattern, actualPath);
384
- if (result) {
385
- routeMatch = {
386
- handler: result.config.handler,
387
- params: result.params,
388
- segment: RouteSegment.Public,
389
- middleware: [],
390
- config: result.config,
391
- };
392
- }
393
- }
394
-
395
- if (!routeMatch) {
396
- if (debug) {
397
- console.log('[SEO] No route found for:', method, routePattern);
398
- }
399
- return applyCorsToResponse(config.responses?.notFound?.() ?? notFoundResponse(`Route not found: ${method.toUpperCase()} ${routePattern}`));
400
- }
401
-
402
- if (debug) {
403
- console.log('[SEO] Route matched:', routeMatch.segment, 'Params:', routeMatch.params);
404
- }
405
-
406
- // Normalize event (with JWT verification if configured for this segment)
407
- const jwtVerificationConfig = config.jwtVerification?.[routeMatch.segment];
408
- let normalized = await normalizeApiGatewayEvent(
409
- event,
410
- routeMatch.segment,
411
- routeMatch.params,
412
- config.autoExtractIdentity,
413
- jwtVerificationConfig
414
- );
415
-
416
- // If JWT verification is configured but identity is missing, return 401
417
- if (jwtVerificationConfig && !normalized.context.identity) {
418
- if (debug) {
419
- console.log('[SEO] JWT verification failed — no valid identity for segment:', routeMatch.segment);
420
- }
421
- return applyCorsToResponse(unauthorizedResponse('Invalid or missing authentication token'));
422
- }
423
-
424
- // Validate User Pool (legacy issuer-only check)
425
- if (!validateSegmentUserPool(normalized, routeMatch.segment, config)) {
426
- if (debug) {
427
- console.log('[SEO] User Pool validation failed for segment:', routeMatch.segment);
428
- }
429
- return applyCorsToResponse(config.responses?.forbidden?.() ?? forbiddenResponse('Access denied: Invalid token issuer'));
430
- }
431
-
432
- // Execute middlewares with error handling for HttpResponse throws
433
- try {
434
- // Execute global middleware
435
- if (config.globalMiddleware?.length) {
436
- normalized = await executeMiddleware(config.globalMiddleware, normalized);
437
- }
438
-
439
- // Execute segment middleware
440
- if (routeMatch.middleware?.length) {
441
- normalized = await executeMiddleware(routeMatch.middleware, normalized);
442
- }
443
- } catch (thrown) {
444
- // If middleware threw an HttpResponse (e.g., forbiddenResponse from tenantGuard), return it
445
- if (isHttpResponse(thrown)) {
446
- return applyCorsToResponse(thrown);
447
- }
448
- // Otherwise, re-throw as unhandled error
449
- throw thrown;
450
- }
451
-
452
- // Execute handler and apply CORS headers to response
453
- const handlerResponse = await routeMatch.handler(normalized);
454
- return applyCorsToResponse(handlerResponse);
455
- }
456
-
457
- // Handle EventBridge events
458
- if (type === EventType.EventBridge) {
459
- const operationName = event.detail?.operationName;
460
- const handler = operationName
461
- ? routes.eventbridge?.[operationName] ?? routes.eventbridge?.default
462
- : routes.eventbridge?.default;
463
-
464
- if (!handler) {
465
- if (debug) {
466
- console.log('[SEO] No EventBridge handler for:', operationName);
467
- }
468
- return { statusCode: 404, body: 'EventBridge handler not found' };
469
- }
470
-
471
- const normalized = normalizeEventBridgeEvent(event);
472
- return handler(normalized);
473
- }
474
-
475
- // Handle SQS events
476
- if (type === EventType.Sqs) {
477
- const queueArn = event.Records[0]?.eventSourceARN;
478
- const queueName = queueArn?.split(':').pop();
479
- const handler = routes.sqs?.[queueName] ?? routes.sqs?.default;
480
-
481
- if (!handler) {
482
- if (debug) {
483
- console.log('[SEO] No SQS handler for queue:', queueName);
484
- }
485
- return { statusCode: 404, body: 'SQS handler not found' };
486
- }
487
-
488
- const normalized = normalizeSqsEvent(event);
489
- return handler(normalized);
490
- }
491
-
492
- // Handle Lambda invocation
493
- if (type === EventType.Lambda) {
494
- const handler = routes.lambda?.default;
495
-
496
- if (!handler) {
497
- return { statusCode: 404, body: 'Lambda handler not found' };
498
- }
499
-
500
- const normalized = normalizeLambdaEvent(event);
501
- return handler(normalized);
502
- }
503
-
504
- // Unknown event type
505
- if (debug) {
506
- console.log('[SEO] Unknown event type');
507
- }
508
- return config.responses?.badRequest?.('Unknown event type') ?? badRequestResponse('Unknown event type');
509
- }
510
-
511
- /**
512
- * Creates an orchestrator instance with pre-configured options
513
- */
514
- export function createOrchestrator(config: OrchestratorConfig = {}) {
515
- return {
516
- dispatch: (event: any, routes: DispatchRoutes) => dispatchEvent(event, routes, config),
517
- config,
518
- };
519
- }
1
+ import { EventType, RouteSegment } from './types/event-type.enum.js';
2
+ import {
3
+ DispatchRoutes,
4
+ HttpMethod,
5
+ HttpRouter,
6
+ SegmentedHttpRouter,
7
+ AdvancedSegmentedRouter,
8
+ SegmentConfig,
9
+ RouteConfig,
10
+ RouteMatch,
11
+ NormalizedEvent,
12
+ OrchestratorConfig,
13
+ MiddlewareFn,
14
+ } from './types/routes.js';
15
+ import { matchPath, normalizePath } from './utils/path-matcher.js';
16
+ import { normalizeHeaders } from './utils/headers.js';
17
+ import { parseJsonBody, parseQueryParams } from './http/body-parser.js';
18
+ import { extractIdentity, validateIssuer } from './identity/extractor.js';
19
+ import type { JwtVerificationPoolConfig } from './types/routes.js';
20
+ import { forbiddenResponse, badRequestResponse, notFoundResponse, unauthorizedResponse } from './http/response.js';
21
+
22
+ /**
23
+ * Detects the type of AWS event.
24
+ *
25
+ * EventBridge detection: AWS EventBridge events tienen una estructura
26
+ * estándar con `version: '0'`, `id`, `source`, `detail-type` y `detail`.
27
+ * El `source` lo setea el productor (cualquier string), no es un magic value
28
+ * fijo por eso se detecta por estructura, no por valor del source.
29
+ */
30
+ export function detectEventType(event: any): EventType {
31
+ if (event.source === 'aws.events' && event['detail-type'] === 'Scheduled Event') return EventType.Scheduled;
32
+ if (isEventBridgeEvent(event)) return EventType.EventBridge;
33
+ if (event.requestContext && event.httpMethod) return EventType.ApiGateway;
34
+ if (event.Records && Array.isArray(event.Records) && event.Records[0]?.eventSource === 'aws:sqs') return EventType.Sqs;
35
+ if (event.awsRequestId) return EventType.Lambda;
36
+ return EventType.Unknown;
37
+ }
38
+
39
+ /**
40
+ * True if the event has the structural shape of an AWS EventBridge event.
41
+ * EventBridge events siempre tienen estos campos:
42
+ * version: '0', id, source, detail-type, detail, account, region, time
43
+ */
44
+ function isEventBridgeEvent(event: any): boolean {
45
+ return (
46
+ event &&
47
+ typeof event === 'object' &&
48
+ event.version === '0' &&
49
+ typeof event.id === 'string' &&
50
+ typeof event.source === 'string' &&
51
+ typeof event['detail-type'] === 'string' &&
52
+ event.detail !== undefined
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Checks if a router is segmented (has public/private/backoffice/internal keys)
58
+ */
59
+ function isSegmentedRouter(router: any): router is SegmentedHttpRouter | AdvancedSegmentedRouter {
60
+ if (!router || typeof router !== 'object') return false;
61
+ const segmentKeys = ['public', 'private', 'backoffice', 'internal'];
62
+ const routerKeys = Object.keys(router);
63
+ return routerKeys.some(key => segmentKeys.includes(key));
64
+ }
65
+
66
+ /**
67
+ * Checks if a segment config has middleware
68
+ */
69
+ function isSegmentConfig(config: any): config is SegmentConfig {
70
+ return config && typeof config === 'object' && 'routes' in config;
71
+ }
72
+
73
+ /**
74
+ * Gets the HttpRouter from a segment (handles both simple and advanced config)
75
+ */
76
+ function getRouterFromSegment(segment: HttpRouter | SegmentConfig | undefined): HttpRouter | undefined {
77
+ if (!segment) return undefined;
78
+ if (isSegmentConfig(segment)) return segment.routes;
79
+ return segment;
80
+ }
81
+
82
+ /**
83
+ * Gets middleware from a segment config
84
+ */
85
+ function getMiddlewareFromSegment(segment: HttpRouter | SegmentConfig | undefined): MiddlewareFn[] {
86
+ if (!segment) return [];
87
+ if (isSegmentConfig(segment)) return segment.middleware ?? [];
88
+ return [];
89
+ }
90
+
91
+
92
+ /**
93
+ * Finds a matching route using pattern for lookup and actualPath for params extraction
94
+ */
95
+ function findRouteInRouterWithActualPath(
96
+ router: HttpRouter | undefined,
97
+ method: HttpMethod,
98
+ routePattern: string,
99
+ actualPath: string
100
+ ): { config: RouteConfig; params: Record<string, string> } | null {
101
+ if (!router) return null;
102
+
103
+ const methodRoutes = router[method];
104
+ if (!methodRoutes) return null;
105
+
106
+ const normalizedPattern = normalizePath(routePattern);
107
+ const normalizedActualPath = normalizePath(actualPath);
108
+
109
+ // First, try exact match with the pattern
110
+ if (methodRoutes[normalizedPattern]) {
111
+ // Extract params from the actual path using the pattern
112
+ const params = matchPath(normalizedPattern, normalizedActualPath) ?? {};
113
+ return { config: methodRoutes[normalizedPattern], params };
114
+ }
115
+
116
+ // Then, try pattern matching
117
+ for (const [pattern, config] of Object.entries(methodRoutes)) {
118
+ const params = matchPath(pattern, normalizedActualPath);
119
+ if (params !== null) {
120
+ return { config, params };
121
+ }
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Finds a route across all segments with actual path for params
129
+ */
130
+ function findRouteInSegmentsWithActualPath(
131
+ router: SegmentedHttpRouter | AdvancedSegmentedRouter,
132
+ method: HttpMethod,
133
+ routePattern: string,
134
+ actualPath: string
135
+ ): RouteMatch | null {
136
+ const segments: RouteSegment[] = [
137
+ RouteSegment.Public,
138
+ RouteSegment.Private,
139
+ RouteSegment.Backoffice,
140
+ RouteSegment.Internal,
141
+ ];
142
+
143
+ for (const segment of segments) {
144
+ const segmentRouter = router[segment];
145
+ const httpRouter = getRouterFromSegment(segmentRouter);
146
+ const result = findRouteInRouterWithActualPath(httpRouter, method, routePattern, actualPath);
147
+
148
+ if (result) {
149
+ return {
150
+ handler: result.config.handler,
151
+ params: result.params,
152
+ segment,
153
+ middleware: getMiddlewareFromSegment(segmentRouter),
154
+ config: result.config,
155
+ };
156
+ }
157
+ }
158
+
159
+ return null;
160
+ }
161
+
162
+
163
+ /**
164
+ * Normalizes an API Gateway event into a standard format
165
+ */
166
+ async function normalizeApiGatewayEvent(
167
+ event: any,
168
+ segment: RouteSegment,
169
+ extractedParams: Record<string, string>,
170
+ autoExtract: boolean = false,
171
+ jwtVerificationConfig?: JwtVerificationPoolConfig
172
+ ): Promise<NormalizedEvent> {
173
+ const identity = await extractIdentity(event, { autoExtract, jwtVerificationConfig });
174
+
175
+ // Safely extract pathParameters from event (handle null/undefined)
176
+ const eventPathParams: Record<string, string> = event.pathParameters && typeof event.pathParameters === 'object'
177
+ ? { ...event.pathParameters }
178
+ : {};
179
+
180
+ // Merge: pathParameters from original event + extracted params (extractedParams takes priority)
181
+ const params = { ...eventPathParams, ...extractedParams };
182
+
183
+ return {
184
+ eventRaw: event,
185
+ eventType: EventType.ApiGateway,
186
+ payload: {
187
+ body: parseJsonBody(event.body, event.isBase64Encoded),
188
+ pathParameters: params,
189
+ queryStringParameters: parseQueryParams(event.queryStringParameters),
190
+ headers: normalizeHeaders(event.headers),
191
+ },
192
+ params,
193
+ context: {
194
+ segment,
195
+ identity,
196
+ requestId: event.requestContext?.requestId,
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Normalizes an EventBridge event
203
+ */
204
+ function normalizeEventBridgeEvent(event: any): NormalizedEvent {
205
+ return {
206
+ eventRaw: event,
207
+ eventType: EventType.EventBridge,
208
+ payload: {
209
+ body: event.detail,
210
+ },
211
+ params: {},
212
+ context: {
213
+ segment: RouteSegment.Internal,
214
+ requestId: event.id,
215
+ },
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Normalizes an SQS event
221
+ */
222
+ function normalizeSqsEvent(event: any): NormalizedEvent {
223
+ const record = event.Records[0];
224
+ let body: Record<string, any> = {};
225
+
226
+ try {
227
+ body = JSON.parse(record.body) as Record<string, any>;
228
+ } catch {
229
+ body = { rawBody: record.body };
230
+ }
231
+
232
+ return {
233
+ eventRaw: event,
234
+ eventType: EventType.Sqs,
235
+ payload: {
236
+ body,
237
+ },
238
+ params: {},
239
+ context: {
240
+ segment: RouteSegment.Internal,
241
+ requestId: record.messageId,
242
+ },
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Normalizes a Scheduled event (EventBridge Scheduler / CloudWatch Events rule)
248
+ */
249
+ function normalizeScheduledEvent(event: any): NormalizedEvent {
250
+ // Extract rule name from resources ARN: arn:aws:events:region:account:rule/RuleName
251
+ const ruleArn = event.resources?.[0];
252
+ const ruleName = ruleArn?.split('/')?.pop() ?? 'default';
253
+
254
+ return {
255
+ eventRaw: event,
256
+ eventType: EventType.Scheduled,
257
+ payload: {
258
+ body: event.detail ?? {},
259
+ },
260
+ params: { ruleName },
261
+ context: {
262
+ segment: RouteSegment.Internal,
263
+ requestId: event.id,
264
+ },
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Normalizes a Lambda invocation event
270
+ */
271
+ function normalizeLambdaEvent(event: any): NormalizedEvent {
272
+ return {
273
+ eventRaw: event,
274
+ eventType: EventType.Lambda,
275
+ payload: {
276
+ body: event,
277
+ },
278
+ params: {},
279
+ context: {
280
+ segment: RouteSegment.Internal,
281
+ },
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Validates User Pool for a segment
287
+ */
288
+ function validateSegmentUserPool(
289
+ normalized: NormalizedEvent,
290
+ segment: RouteSegment,
291
+ config: OrchestratorConfig
292
+ ): boolean {
293
+ // Public routes don't require validation
294
+ if (segment === RouteSegment.Public) return true;
295
+
296
+ // If no user pool config, skip validation
297
+ const expectedUserPoolId = config.userPools?.[segment];
298
+ if (!expectedUserPoolId) return true;
299
+
300
+ // Validate issuer matches expected user pool
301
+ return validateIssuer(normalized.context.identity, expectedUserPoolId);
302
+ }
303
+
304
+ /**
305
+ * Checks if a thrown value is an HTTP response (used by middleware to halt execution)
306
+ */
307
+ function isHttpResponse(value: unknown): value is { statusCode: number; body: string } {
308
+ return (
309
+ typeof value === 'object' &&
310
+ value !== null &&
311
+ 'statusCode' in value &&
312
+ typeof (value as any).statusCode === 'number'
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Executes middleware chain.
318
+ * If a middleware throws an HttpResponse-like object (has statusCode),
319
+ * it is treated as an early return (e.g., 403 Forbidden from tenantGuard).
320
+ * If it throws a regular Error, it is re-thrown.
321
+ */
322
+ async function executeMiddleware(
323
+ middleware: MiddlewareFn[],
324
+ event: NormalizedEvent
325
+ ): Promise<NormalizedEvent> {
326
+ let currentEvent = event;
327
+
328
+ for (const mw of middleware) {
329
+ const result = await mw(currentEvent);
330
+ if (result) {
331
+ currentEvent = result;
332
+ }
333
+ }
334
+
335
+ return currentEvent;
336
+ }
337
+
338
+ /**
339
+ * Applies CORS headers to any API Gateway response
340
+ * This ensures CORS works regardless of how the handler builds its response
341
+ */
342
+ function applyCorsToResponse(response: any): any {
343
+ if (!response || typeof response !== 'object') return response;
344
+
345
+ const corsOrigin = process.env.CORS_ALLOWED_ORIGINS || '*';
346
+ const corsHeaders = process.env.CORS_ALLOWED_HEADERS ||
347
+ 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token,appVersion,app-version,platform,geo,x-forwarded-for,x-real-ip';
348
+ const corsMethods = process.env.CORS_ALLOWED_METHODS || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
349
+
350
+ const existingHeaders = response.headers || {};
351
+
352
+ return {
353
+ ...response,
354
+ headers: {
355
+ 'Access-Control-Allow-Origin': corsOrigin,
356
+ 'Access-Control-Allow-Headers': corsHeaders,
357
+ 'Access-Control-Allow-Methods': corsMethods,
358
+ ...existingHeaders,
359
+ },
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Main dispatch function with all improvements
365
+ */
366
+ export async function dispatchEvent(
367
+ event: any,
368
+ routes: DispatchRoutes,
369
+ config: OrchestratorConfig = {}
370
+ ): Promise<any> {
371
+ const debug = config.debug ?? false;
372
+
373
+ if (debug) {
374
+ console.log('[SEO] Event received:', JSON.stringify(event, null, 2));
375
+ }
376
+
377
+ const type = detectEventType(event);
378
+
379
+ if (debug) {
380
+ console.log('[SEO] Event type:', type);
381
+ }
382
+
383
+ // Handle API Gateway events
384
+ if (type === EventType.ApiGateway) {
385
+ const method = event.httpMethod?.toLowerCase() as HttpMethod;
386
+ const routePattern = event.resource || event.path;
387
+ const actualPath = event.path || event.resource;
388
+
389
+ // Handle CORS preflight requests automatically
390
+ if (method === 'options') {
391
+ if (debug) {
392
+ console.log('[SEO] Handling OPTIONS preflight request');
393
+ }
394
+ const corsOrigin = process.env.CORS_ALLOWED_ORIGINS || '*';
395
+ const corsHeaders = process.env.CORS_ALLOWED_HEADERS ||
396
+ 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token,appVersion,app-version,platform,geo,x-forwarded-for,x-real-ip';
397
+ const corsMethods = process.env.CORS_ALLOWED_METHODS || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
398
+ const corsMaxAge = process.env.CORS_MAX_AGE || '600';
399
+
400
+ return {
401
+ statusCode: 204,
402
+ headers: {
403
+ 'Access-Control-Allow-Origin': corsOrigin,
404
+ 'Access-Control-Allow-Headers': corsHeaders,
405
+ 'Access-Control-Allow-Methods': corsMethods,
406
+ 'Access-Control-Max-Age': corsMaxAge,
407
+ },
408
+ body: '',
409
+ };
410
+ }
411
+
412
+ if (debug) {
413
+ console.log('[SEO] Method:', method, 'Path:', routePattern, 'Actual:', actualPath);
414
+ }
415
+
416
+ const apiRoutes = routes.apigateway;
417
+ if (!apiRoutes) {
418
+ return applyCorsToResponse(config.responses?.notFound?.() ?? notFoundResponse('No API routes configured'));
419
+ }
420
+
421
+ let routeMatch: RouteMatch | null = null;
422
+
423
+ // Use routePattern for finding routes, but extract params from actualPath
424
+ if (isSegmentedRouter(apiRoutes)) {
425
+ routeMatch = findRouteInSegmentsWithActualPath(apiRoutes, method, routePattern, actualPath);
426
+ } else {
427
+ // Flat router - treat as public
428
+ const result = findRouteInRouterWithActualPath(apiRoutes as HttpRouter, method, routePattern, actualPath);
429
+ if (result) {
430
+ routeMatch = {
431
+ handler: result.config.handler,
432
+ params: result.params,
433
+ segment: RouteSegment.Public,
434
+ middleware: [],
435
+ config: result.config,
436
+ };
437
+ }
438
+ }
439
+
440
+ if (!routeMatch) {
441
+ if (debug) {
442
+ console.log('[SEO] No route found for:', method, routePattern);
443
+ }
444
+ return applyCorsToResponse(config.responses?.notFound?.() ?? notFoundResponse(`Route not found: ${method.toUpperCase()} ${routePattern}`));
445
+ }
446
+
447
+ if (debug) {
448
+ console.log('[SEO] Route matched:', routeMatch.segment, 'Params:', routeMatch.params);
449
+ }
450
+
451
+ // Normalize event (with JWT verification if configured for this segment)
452
+ const jwtVerificationConfig = config.jwtVerification?.[routeMatch.segment];
453
+ let normalized = await normalizeApiGatewayEvent(
454
+ event,
455
+ routeMatch.segment,
456
+ routeMatch.params,
457
+ config.autoExtractIdentity,
458
+ jwtVerificationConfig
459
+ );
460
+
461
+ // If JWT verification is configured but identity is missing, return 401
462
+ if (jwtVerificationConfig && !normalized.context.identity) {
463
+ if (debug) {
464
+ console.log('[SEO] JWT verification failed — no valid identity for segment:', routeMatch.segment);
465
+ }
466
+ return applyCorsToResponse(unauthorizedResponse('Invalid or missing authentication token'));
467
+ }
468
+
469
+ // Validate User Pool (legacy issuer-only check)
470
+ if (!validateSegmentUserPool(normalized, routeMatch.segment, config)) {
471
+ if (debug) {
472
+ console.log('[SEO] User Pool validation failed for segment:', routeMatch.segment);
473
+ }
474
+ return applyCorsToResponse(config.responses?.forbidden?.() ?? forbiddenResponse('Access denied: Invalid token issuer'));
475
+ }
476
+
477
+ // Execute middlewares with error handling for HttpResponse throws
478
+ try {
479
+ // Execute global middleware
480
+ if (config.globalMiddleware?.length) {
481
+ normalized = await executeMiddleware(config.globalMiddleware, normalized);
482
+ }
483
+
484
+ // Execute segment middleware
485
+ if (routeMatch.middleware?.length) {
486
+ normalized = await executeMiddleware(routeMatch.middleware, normalized);
487
+ }
488
+ } catch (thrown) {
489
+ // If middleware threw an HttpResponse (e.g., forbiddenResponse from tenantGuard), return it
490
+ if (isHttpResponse(thrown)) {
491
+ return applyCorsToResponse(thrown);
492
+ }
493
+ // Otherwise, re-throw as unhandled error
494
+ throw thrown;
495
+ }
496
+
497
+ // Execute handler and apply CORS headers to response
498
+ const handlerResponse = await routeMatch.handler(normalized);
499
+ return applyCorsToResponse(handlerResponse);
500
+ }
501
+
502
+ // Handle Scheduled events (EventBridge Scheduler / CloudWatch Events rules)
503
+ if (type === EventType.Scheduled) {
504
+ const normalized = normalizeScheduledEvent(event);
505
+ const ruleName = normalized.params.ruleName;
506
+ const handler = routes.scheduled?.[ruleName] ?? routes.scheduled?.default;
507
+
508
+ if (!handler) {
509
+ if (debug) {
510
+ console.log('[SEO] No Scheduled handler for rule:', ruleName);
511
+ }
512
+ return { statusCode: 404, body: 'Scheduled handler not found' };
513
+ }
514
+
515
+ return handler(normalized);
516
+ }
517
+
518
+ // Handle EventBridge events.
519
+ //
520
+ // Routing prioriza el campo nativo de AWS `detail-type` (estándar EventBridge),
521
+ // con fallback a `detail.operationName` (convención legacy de versiones <2.3
522
+ // de esta librería). Si ninguno matchea, cae a `routes.eventbridge.default`.
523
+ if (type === EventType.EventBridge) {
524
+ const detailType: string | undefined = event['detail-type'];
525
+ const operationName: string | undefined = event.detail?.operationName;
526
+ const handler =
527
+ (detailType ? routes.eventbridge?.[detailType] : undefined)
528
+ ?? (operationName ? routes.eventbridge?.[operationName] : undefined)
529
+ ?? routes.eventbridge?.default;
530
+
531
+ if (!handler) {
532
+ if (debug) {
533
+ console.log('[SEO] No EventBridge handler for:', { detailType, operationName });
534
+ }
535
+ return { statusCode: 404, body: 'EventBridge handler not found' };
536
+ }
537
+
538
+ const normalized = normalizeEventBridgeEvent(event);
539
+ return handler(normalized);
540
+ }
541
+
542
+ // Handle SQS events
543
+ if (type === EventType.Sqs) {
544
+ const queueArn = event.Records[0]?.eventSourceARN;
545
+ const queueName = queueArn?.split(':').pop();
546
+ const handler = routes.sqs?.[queueName] ?? routes.sqs?.default;
547
+
548
+ if (!handler) {
549
+ if (debug) {
550
+ console.log('[SEO] No SQS handler for queue:', queueName);
551
+ }
552
+ return { statusCode: 404, body: 'SQS handler not found' };
553
+ }
554
+
555
+ const normalized = normalizeSqsEvent(event);
556
+ return handler(normalized);
557
+ }
558
+
559
+ // Handle Lambda invocation
560
+ if (type === EventType.Lambda) {
561
+ const handler = routes.lambda?.default;
562
+
563
+ if (!handler) {
564
+ return { statusCode: 404, body: 'Lambda handler not found' };
565
+ }
566
+
567
+ const normalized = normalizeLambdaEvent(event);
568
+ return handler(normalized);
569
+ }
570
+
571
+ // Unknown event type
572
+ if (debug) {
573
+ console.log('[SEO] Unknown event type');
574
+ }
575
+ return config.responses?.badRequest?.('Unknown event type') ?? badRequestResponse('Unknown event type');
576
+ }
577
+
578
+ /**
579
+ * Creates an orchestrator instance with pre-configured options
580
+ */
581
+ export function createOrchestrator(config: OrchestratorConfig = {}) {
582
+ return {
583
+ dispatch: (event: any, routes: DispatchRoutes) => dispatchEvent(event, routes, config),
584
+ config,
585
+ };
586
+ }