serverless-event-orchestrator 2.2.0 → 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 (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +489 -489
  3. package/dist/dispatcher.d.ts +6 -1
  4. package/dist/dispatcher.d.ts.map +1 -1
  5. package/dist/dispatcher.js +31 -7
  6. package/dist/dispatcher.js.map +1 -1
  7. package/jest.config.js +32 -32
  8. package/package.json +82 -81
  9. package/src/dispatcher.ts +586 -558
  10. package/src/http/body-parser.ts +60 -60
  11. package/src/http/cors.ts +76 -76
  12. package/src/http/index.ts +3 -3
  13. package/src/http/response.ts +209 -209
  14. package/src/identity/extractor.ts +207 -207
  15. package/src/identity/index.ts +2 -2
  16. package/src/identity/jwt-verifier.ts +41 -41
  17. package/src/index.ts +128 -128
  18. package/src/middleware/crm-guard.ts +51 -51
  19. package/src/middleware/index.ts +3 -3
  20. package/src/middleware/init-tenant-context.ts +59 -59
  21. package/src/middleware/tenant-guard.ts +54 -54
  22. package/src/tenant/TenantContext.ts +115 -115
  23. package/src/tenant/helpers.ts +112 -112
  24. package/src/tenant/index.ts +21 -21
  25. package/src/tenant/types.ts +101 -101
  26. package/src/types/event-type.enum.ts +21 -21
  27. package/src/types/index.ts +2 -2
  28. package/src/types/routes.ts +218 -218
  29. package/src/utils/headers.ts +72 -72
  30. package/src/utils/index.ts +2 -2
  31. package/src/utils/path-matcher.ts +84 -84
  32. package/tests/cors.test.ts +133 -133
  33. package/tests/dispatcher.test.ts +795 -715
  34. package/tests/headers.test.ts +99 -99
  35. package/tests/identity.test.ts +301 -301
  36. package/tests/middleware/crm-guard.test.ts +69 -69
  37. package/tests/middleware/init-tenant-context.test.ts +147 -147
  38. package/tests/middleware/tenant-guard.test.ts +100 -100
  39. package/tests/path-matcher.test.ts +102 -102
  40. package/tests/response.test.ts +155 -155
  41. package/tests/tenant/TenantContext.test.ts +134 -134
  42. package/tests/tenant/helpers.test.ts +187 -187
  43. package/tsconfig.json +24 -24
package/src/index.ts CHANGED
@@ -1,128 +1,128 @@
1
- /**
2
- * serverless-event-orchestrator
3
- *
4
- * A lightweight, type-safe event dispatcher and middleware orchestrator for AWS Lambda.
5
- * Designed for hexagonal architectures with support for segmented routing,
6
- * Cognito User Pool validation, and built-in infrastructure middlewares.
7
- */
8
-
9
- // Core dispatcher
10
- export { dispatchEvent, detectEventType, createOrchestrator } from './dispatcher.js';
11
-
12
- // Types
13
- export {
14
- EventType,
15
- RouteSegment,
16
- } from './types/event-type.enum.js';
17
-
18
- export {
19
- HttpMethod,
20
- MiddlewareFn,
21
- RouteConfig,
22
- CorsConfig,
23
- RateLimitConfig,
24
- HttpRouter,
25
- SegmentedHttpRouter,
26
- SegmentConfig,
27
- AdvancedSegmentedRouter,
28
- EventBridgeRoutes,
29
- LambdaRoutes,
30
- SqsRoutes,
31
- ScheduledRoutes,
32
- DispatchRoutes,
33
- IdentityContext,
34
- RouteMatch,
35
- NormalizedEvent,
36
- OrchestratorConfig,
37
- JwtVerificationPoolConfig,
38
- } from './types/routes.js';
39
-
40
- // HTTP utilities
41
- export {
42
- HttpStatus,
43
- StandardResponse,
44
- HttpResponse,
45
- DefaultResponseCode,
46
- createStandardResponse,
47
- successResponse,
48
- createdResponse,
49
- badRequestResponse,
50
- unauthorizedResponse,
51
- forbiddenResponse,
52
- notFoundResponse,
53
- conflictResponse,
54
- validationErrorResponse,
55
- internalErrorResponse,
56
- customErrorResponse,
57
- } from './http/response.js';
58
-
59
- export {
60
- parseJsonBody,
61
- parseQueryParams,
62
- withJsonBodyParser,
63
- } from './http/body-parser.js';
64
-
65
- export {
66
- isPreflightRequest,
67
- createPreflightResponse,
68
- applyCorsHeaders,
69
- withCors,
70
- } from './http/cors.js';
71
-
72
- // Identity utilities
73
- export {
74
- extractIdentity,
75
- extractUserPoolId,
76
- validateIssuer,
77
- hasAnyGroup,
78
- hasAllGroups,
79
- } from './identity/extractor.js';
80
-
81
- export type { ExtractIdentityOptions } from './identity/extractor.js';
82
-
83
- export { verifyJwt } from './identity/jwt-verifier.js';
84
-
85
- // Path utilities
86
- export {
87
- matchPath,
88
- patternToRegex,
89
- hasPathParameters,
90
- normalizePath,
91
- } from './utils/path-matcher.js';
92
-
93
- // Header utilities
94
- export {
95
- normalizeHeaders,
96
- getHeader,
97
- getCorsHeaders,
98
- } from './utils/headers.js';
99
-
100
- // Tenant context
101
- export { TenantContext } from './tenant/TenantContext.js';
102
-
103
- export type {
104
- TenantInfo,
105
- TenantType,
106
- Plan,
107
- TenantFeatures,
108
- TenantClaims,
109
- } from './tenant/types.js';
110
-
111
- export {
112
- isTenantType,
113
- isPlan,
114
- TENANT_HEADERS,
115
- } from './tenant/types.js';
116
-
117
- export {
118
- tenantInfoFromClaims,
119
- tenantInfoFromHeaders,
120
- tenantInfoToHeaders,
121
- } from './tenant/helpers.js';
122
-
123
- // Tenant middleware
124
- export {
125
- initTenantContext,
126
- tenantGuard,
127
- crmGuard,
128
- } from './middleware/index.js';
1
+ /**
2
+ * serverless-event-orchestrator
3
+ *
4
+ * A lightweight, type-safe event dispatcher and middleware orchestrator for AWS Lambda.
5
+ * Designed for hexagonal architectures with support for segmented routing,
6
+ * Cognito User Pool validation, and built-in infrastructure middlewares.
7
+ */
8
+
9
+ // Core dispatcher
10
+ export { dispatchEvent, detectEventType, createOrchestrator } from './dispatcher.js';
11
+
12
+ // Types
13
+ export {
14
+ EventType,
15
+ RouteSegment,
16
+ } from './types/event-type.enum.js';
17
+
18
+ export {
19
+ HttpMethod,
20
+ MiddlewareFn,
21
+ RouteConfig,
22
+ CorsConfig,
23
+ RateLimitConfig,
24
+ HttpRouter,
25
+ SegmentedHttpRouter,
26
+ SegmentConfig,
27
+ AdvancedSegmentedRouter,
28
+ EventBridgeRoutes,
29
+ LambdaRoutes,
30
+ SqsRoutes,
31
+ ScheduledRoutes,
32
+ DispatchRoutes,
33
+ IdentityContext,
34
+ RouteMatch,
35
+ NormalizedEvent,
36
+ OrchestratorConfig,
37
+ JwtVerificationPoolConfig,
38
+ } from './types/routes.js';
39
+
40
+ // HTTP utilities
41
+ export {
42
+ HttpStatus,
43
+ StandardResponse,
44
+ HttpResponse,
45
+ DefaultResponseCode,
46
+ createStandardResponse,
47
+ successResponse,
48
+ createdResponse,
49
+ badRequestResponse,
50
+ unauthorizedResponse,
51
+ forbiddenResponse,
52
+ notFoundResponse,
53
+ conflictResponse,
54
+ validationErrorResponse,
55
+ internalErrorResponse,
56
+ customErrorResponse,
57
+ } from './http/response.js';
58
+
59
+ export {
60
+ parseJsonBody,
61
+ parseQueryParams,
62
+ withJsonBodyParser,
63
+ } from './http/body-parser.js';
64
+
65
+ export {
66
+ isPreflightRequest,
67
+ createPreflightResponse,
68
+ applyCorsHeaders,
69
+ withCors,
70
+ } from './http/cors.js';
71
+
72
+ // Identity utilities
73
+ export {
74
+ extractIdentity,
75
+ extractUserPoolId,
76
+ validateIssuer,
77
+ hasAnyGroup,
78
+ hasAllGroups,
79
+ } from './identity/extractor.js';
80
+
81
+ export type { ExtractIdentityOptions } from './identity/extractor.js';
82
+
83
+ export { verifyJwt } from './identity/jwt-verifier.js';
84
+
85
+ // Path utilities
86
+ export {
87
+ matchPath,
88
+ patternToRegex,
89
+ hasPathParameters,
90
+ normalizePath,
91
+ } from './utils/path-matcher.js';
92
+
93
+ // Header utilities
94
+ export {
95
+ normalizeHeaders,
96
+ getHeader,
97
+ getCorsHeaders,
98
+ } from './utils/headers.js';
99
+
100
+ // Tenant context
101
+ export { TenantContext } from './tenant/TenantContext.js';
102
+
103
+ export type {
104
+ TenantInfo,
105
+ TenantType,
106
+ Plan,
107
+ TenantFeatures,
108
+ TenantClaims,
109
+ } from './tenant/types.js';
110
+
111
+ export {
112
+ isTenantType,
113
+ isPlan,
114
+ TENANT_HEADERS,
115
+ } from './tenant/types.js';
116
+
117
+ export {
118
+ tenantInfoFromClaims,
119
+ tenantInfoFromHeaders,
120
+ tenantInfoToHeaders,
121
+ } from './tenant/helpers.js';
122
+
123
+ // Tenant middleware
124
+ export {
125
+ initTenantContext,
126
+ tenantGuard,
127
+ crmGuard,
128
+ } from './middleware/index.js';
@@ -1,51 +1,51 @@
1
- import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
2
- import { forbiddenResponse } from '../http/response.js';
3
- import { hasAnyGroup } from '../identity/extractor.js';
4
-
5
- /**
6
- * Roles that bypass CRM plan check.
7
- */
8
- const CRM_BYPASS_ROLES = ['PLATFORM_ADMIN'];
9
-
10
- /**
11
- * Middleware that enforces CRM access based on the tenant's subscription plan.
12
- *
13
- * Checks event.context.tenantInfo.hasCRM (set by initTenantContext from JWT claims).
14
- * If the tenant doesn't have CRM access, returns 403 with upgrade message.
15
- *
16
- * IMPORTANT: This middleware MUST run AFTER tenantGuard (which ensures tenantInfo exists).
17
- *
18
- * Usage: Add as route-level or segment middleware for CRM-only endpoints.
19
- *
20
- * ```typescript
21
- * private: {
22
- * middleware: [tenantGuard], // ← runs first
23
- * routes: {
24
- * get: {
25
- * '/crm/dashboard': { handler: getCRMDashboard, middleware: [crmGuard] }, // ← runs second
26
- * '/crm/leads': { handler: getLeads, middleware: [crmGuard] },
27
- * }
28
- * }
29
- * }
30
- * ```
31
- */
32
- export const crmGuard: MiddlewareFn = async (
33
- event: NormalizedEvent
34
- ): Promise<NormalizedEvent> => {
35
- // PLATFORM_ADMIN bypasses CRM check
36
- if (hasAnyGroup(event.context.identity, CRM_BYPASS_ROLES)) {
37
- return event;
38
- }
39
-
40
- const tenantInfo = event.context.tenantInfo;
41
-
42
- // Check hasCRM from tenantInfo (set from JWT claim custom:hasCRM)
43
- if (!tenantInfo?.hasCRM) {
44
- throw forbiddenResponse(
45
- 'CRM access requires a paid plan. Please upgrade your subscription.',
46
- 'CRM_ACCESS_DENIED' as any
47
- );
48
- }
49
-
50
- return event;
51
- };
1
+ import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
2
+ import { forbiddenResponse } from '../http/response.js';
3
+ import { hasAnyGroup } from '../identity/extractor.js';
4
+
5
+ /**
6
+ * Roles that bypass CRM plan check.
7
+ */
8
+ const CRM_BYPASS_ROLES = ['PLATFORM_ADMIN'];
9
+
10
+ /**
11
+ * Middleware that enforces CRM access based on the tenant's subscription plan.
12
+ *
13
+ * Checks event.context.tenantInfo.hasCRM (set by initTenantContext from JWT claims).
14
+ * If the tenant doesn't have CRM access, returns 403 with upgrade message.
15
+ *
16
+ * IMPORTANT: This middleware MUST run AFTER tenantGuard (which ensures tenantInfo exists).
17
+ *
18
+ * Usage: Add as route-level or segment middleware for CRM-only endpoints.
19
+ *
20
+ * ```typescript
21
+ * private: {
22
+ * middleware: [tenantGuard], // ← runs first
23
+ * routes: {
24
+ * get: {
25
+ * '/crm/dashboard': { handler: getCRMDashboard, middleware: [crmGuard] }, // ← runs second
26
+ * '/crm/leads': { handler: getLeads, middleware: [crmGuard] },
27
+ * }
28
+ * }
29
+ * }
30
+ * ```
31
+ */
32
+ export const crmGuard: MiddlewareFn = async (
33
+ event: NormalizedEvent
34
+ ): Promise<NormalizedEvent> => {
35
+ // PLATFORM_ADMIN bypasses CRM check
36
+ if (hasAnyGroup(event.context.identity, CRM_BYPASS_ROLES)) {
37
+ return event;
38
+ }
39
+
40
+ const tenantInfo = event.context.tenantInfo;
41
+
42
+ // Check hasCRM from tenantInfo (set from JWT claim custom:hasCRM)
43
+ if (!tenantInfo?.hasCRM) {
44
+ throw forbiddenResponse(
45
+ 'CRM access requires a paid plan. Please upgrade your subscription.',
46
+ 'CRM_ACCESS_DENIED' as any
47
+ );
48
+ }
49
+
50
+ return event;
51
+ };
@@ -1,3 +1,3 @@
1
- export { initTenantContext } from './init-tenant-context.js';
2
- export { tenantGuard } from './tenant-guard.js';
3
- export { crmGuard } from './crm-guard.js';
1
+ export { initTenantContext } from './init-tenant-context.js';
2
+ export { tenantGuard } from './tenant-guard.js';
3
+ export { crmGuard } from './crm-guard.js';
@@ -1,59 +1,59 @@
1
- import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
2
- import {
3
- TenantContext,
4
- tenantInfoFromClaims,
5
- tenantInfoFromHeaders,
6
- type TenantInfo,
7
- } from '../tenant/index.js';
8
-
9
- /**
10
- * Middleware that initializes the TenantContext from JWT claims or headers.
11
- *
12
- * Execution order: globalMiddleware (runs on ALL routes, before segment middleware).
13
- *
14
- * Resolution strategy:
15
- * 1. Private/Backoffice segments → extract from identity.claims (JWT)
16
- * 2. Internal segment → extract from headers (X-Tenant-Id, Lambda-to-Lambda)
17
- * 3. Public segment → try claims first (authenticated public), then headers, then skip
18
- *
19
- * If tenant info is found:
20
- * - Sets TenantContext via AsyncLocalStorage (for repositories)
21
- * - Adds tenantInfo to event.context (for handlers and logger)
22
- *
23
- * If tenant info is NOT found:
24
- * - Does NOT throw (public routes are allowed without tenant)
25
- * - tenantGuard (segment middleware) will enforce tenant requirement where needed
26
- */
27
- export const initTenantContext: MiddlewareFn = async (
28
- event: NormalizedEvent
29
- ): Promise<NormalizedEvent> => {
30
- let tenantInfo: TenantInfo | undefined;
31
-
32
- // Strategy 1: From JWT claims (private, backoffice, or authenticated public)
33
- if (event.context.identity?.claims) {
34
- tenantInfo = tenantInfoFromClaims(event.context.identity.claims);
35
- }
36
-
37
- // Strategy 2: From headers (internal Lambda-to-Lambda, or fallback)
38
- if (!tenantInfo && event.payload.headers) {
39
- tenantInfo = tenantInfoFromHeaders(event.payload.headers);
40
- }
41
-
42
- // If we found tenant info, set it everywhere
43
- if (tenantInfo) {
44
- // 1. Set AsyncLocalStorage (for TenantAwareDynamoRepository and ApiInvoker)
45
- TenantContext.set(tenantInfo);
46
-
47
- // 2. Enrich event.context (for handlers, logger, and downstream middleware)
48
- return {
49
- ...event,
50
- context: {
51
- ...event.context,
52
- tenantInfo,
53
- },
54
- };
55
- }
56
-
57
- // No tenant info found — this is normal for public routes
58
- return event;
59
- };
1
+ import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
2
+ import {
3
+ TenantContext,
4
+ tenantInfoFromClaims,
5
+ tenantInfoFromHeaders,
6
+ type TenantInfo,
7
+ } from '../tenant/index.js';
8
+
9
+ /**
10
+ * Middleware that initializes the TenantContext from JWT claims or headers.
11
+ *
12
+ * Execution order: globalMiddleware (runs on ALL routes, before segment middleware).
13
+ *
14
+ * Resolution strategy:
15
+ * 1. Private/Backoffice segments → extract from identity.claims (JWT)
16
+ * 2. Internal segment → extract from headers (X-Tenant-Id, Lambda-to-Lambda)
17
+ * 3. Public segment → try claims first (authenticated public), then headers, then skip
18
+ *
19
+ * If tenant info is found:
20
+ * - Sets TenantContext via AsyncLocalStorage (for repositories)
21
+ * - Adds tenantInfo to event.context (for handlers and logger)
22
+ *
23
+ * If tenant info is NOT found:
24
+ * - Does NOT throw (public routes are allowed without tenant)
25
+ * - tenantGuard (segment middleware) will enforce tenant requirement where needed
26
+ */
27
+ export const initTenantContext: MiddlewareFn = async (
28
+ event: NormalizedEvent
29
+ ): Promise<NormalizedEvent> => {
30
+ let tenantInfo: TenantInfo | undefined;
31
+
32
+ // Strategy 1: From JWT claims (private, backoffice, or authenticated public)
33
+ if (event.context.identity?.claims) {
34
+ tenantInfo = tenantInfoFromClaims(event.context.identity.claims);
35
+ }
36
+
37
+ // Strategy 2: From headers (internal Lambda-to-Lambda, or fallback)
38
+ if (!tenantInfo && event.payload.headers) {
39
+ tenantInfo = tenantInfoFromHeaders(event.payload.headers);
40
+ }
41
+
42
+ // If we found tenant info, set it everywhere
43
+ if (tenantInfo) {
44
+ // 1. Set AsyncLocalStorage (for TenantAwareDynamoRepository and ApiInvoker)
45
+ TenantContext.set(tenantInfo);
46
+
47
+ // 2. Enrich event.context (for handlers, logger, and downstream middleware)
48
+ return {
49
+ ...event,
50
+ context: {
51
+ ...event.context,
52
+ tenantInfo,
53
+ },
54
+ };
55
+ }
56
+
57
+ // No tenant info found — this is normal for public routes
58
+ return event;
59
+ };
@@ -1,54 +1,54 @@
1
- import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
2
- import { forbiddenResponse } from '../http/response.js';
3
- import { hasAnyGroup } from '../identity/extractor.js';
4
-
5
- /**
6
- * Roles that can operate cross-tenant (bypass tenant check).
7
- * PLATFORM_ADMIN = MLHolding employees in the Backoffice pool.
8
- */
9
- const CROSS_TENANT_ROLES = ['PLATFORM_ADMIN'];
10
-
11
- /**
12
- * Middleware that enforces tenant context on protected routes.
13
- * FAIL-CLOSED: If no tenant context exists and user is not PLATFORM_ADMIN, request is denied.
14
- *
15
- * Usage: Add as segment middleware for private and backoffice segments.
16
- *
17
- * ```typescript
18
- * const routes: AdvancedSegmentedRouter = {
19
- * public: { routes: { ... } }, // ← NO tenantGuard
20
- * private: { middleware: [tenantGuard], routes: { ... } }, // ← YES
21
- * backoffice: { middleware: [tenantGuard], routes: { ... } }, // ← YES
22
- * internal: { routes: { ... } }, // ← NO (uses headers, initTenantContext handles it)
23
- * };
24
- * ```
25
- *
26
- * Throws forbiddenResponse (403) if:
27
- * - No tenantInfo in context AND user is not PLATFORM_ADMIN
28
- * - tenantInfo exists but tenantId is empty or whitespace-only
29
- *
30
- * Allows through if:
31
- * - tenantInfo exists with valid non-empty tenantId
32
- * - User is PLATFORM_ADMIN (cross-tenant access)
33
- */
34
- export const tenantGuard: MiddlewareFn = async (
35
- event: NormalizedEvent
36
- ): Promise<NormalizedEvent> => {
37
- const tenantInfo = event.context.tenantInfo;
38
- const identity = event.context.identity;
39
-
40
- // PLATFORM_ADMIN can operate without tenant context (cross-tenant)
41
- if (hasAnyGroup(identity, CROSS_TENANT_ROLES)) {
42
- return event;
43
- }
44
-
45
- // Fail-closed: validate tenantInfo exists AND tenantId is not empty
46
- if (!tenantInfo?.tenantId || tenantInfo.tenantId.trim() === '') {
47
- throw forbiddenResponse(
48
- 'Tenant context required. Ensure you are authenticated with a valid tenant.',
49
- 'TENANT_CONTEXT_MISSING' as any
50
- );
51
- }
52
-
53
- return event;
54
- };
1
+ import type { NormalizedEvent, MiddlewareFn } from '../types/routes.js';
2
+ import { forbiddenResponse } from '../http/response.js';
3
+ import { hasAnyGroup } from '../identity/extractor.js';
4
+
5
+ /**
6
+ * Roles that can operate cross-tenant (bypass tenant check).
7
+ * PLATFORM_ADMIN = MLHolding employees in the Backoffice pool.
8
+ */
9
+ const CROSS_TENANT_ROLES = ['PLATFORM_ADMIN'];
10
+
11
+ /**
12
+ * Middleware that enforces tenant context on protected routes.
13
+ * FAIL-CLOSED: If no tenant context exists and user is not PLATFORM_ADMIN, request is denied.
14
+ *
15
+ * Usage: Add as segment middleware for private and backoffice segments.
16
+ *
17
+ * ```typescript
18
+ * const routes: AdvancedSegmentedRouter = {
19
+ * public: { routes: { ... } }, // ← NO tenantGuard
20
+ * private: { middleware: [tenantGuard], routes: { ... } }, // ← YES
21
+ * backoffice: { middleware: [tenantGuard], routes: { ... } }, // ← YES
22
+ * internal: { routes: { ... } }, // ← NO (uses headers, initTenantContext handles it)
23
+ * };
24
+ * ```
25
+ *
26
+ * Throws forbiddenResponse (403) if:
27
+ * - No tenantInfo in context AND user is not PLATFORM_ADMIN
28
+ * - tenantInfo exists but tenantId is empty or whitespace-only
29
+ *
30
+ * Allows through if:
31
+ * - tenantInfo exists with valid non-empty tenantId
32
+ * - User is PLATFORM_ADMIN (cross-tenant access)
33
+ */
34
+ export const tenantGuard: MiddlewareFn = async (
35
+ event: NormalizedEvent
36
+ ): Promise<NormalizedEvent> => {
37
+ const tenantInfo = event.context.tenantInfo;
38
+ const identity = event.context.identity;
39
+
40
+ // PLATFORM_ADMIN can operate without tenant context (cross-tenant)
41
+ if (hasAnyGroup(identity, CROSS_TENANT_ROLES)) {
42
+ return event;
43
+ }
44
+
45
+ // Fail-closed: validate tenantInfo exists AND tenantId is not empty
46
+ if (!tenantInfo?.tenantId || tenantInfo.tenantId.trim() === '') {
47
+ throw forbiddenResponse(
48
+ 'Tenant context required. Ensure you are authenticated with a valid tenant.',
49
+ 'TENANT_CONTEXT_MISSING' as any
50
+ );
51
+ }
52
+
53
+ return event;
54
+ };