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/index.ts CHANGED
@@ -1,127 +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
- DispatchRoutes,
32
- IdentityContext,
33
- RouteMatch,
34
- NormalizedEvent,
35
- OrchestratorConfig,
36
- JwtVerificationPoolConfig,
37
- } from './types/routes.js';
38
-
39
- // HTTP utilities
40
- export {
41
- HttpStatus,
42
- StandardResponse,
43
- HttpResponse,
44
- DefaultResponseCode,
45
- createStandardResponse,
46
- successResponse,
47
- createdResponse,
48
- badRequestResponse,
49
- unauthorizedResponse,
50
- forbiddenResponse,
51
- notFoundResponse,
52
- conflictResponse,
53
- validationErrorResponse,
54
- internalErrorResponse,
55
- customErrorResponse,
56
- } from './http/response.js';
57
-
58
- export {
59
- parseJsonBody,
60
- parseQueryParams,
61
- withJsonBodyParser,
62
- } from './http/body-parser.js';
63
-
64
- export {
65
- isPreflightRequest,
66
- createPreflightResponse,
67
- applyCorsHeaders,
68
- withCors,
69
- } from './http/cors.js';
70
-
71
- // Identity utilities
72
- export {
73
- extractIdentity,
74
- extractUserPoolId,
75
- validateIssuer,
76
- hasAnyGroup,
77
- hasAllGroups,
78
- } from './identity/extractor.js';
79
-
80
- export type { ExtractIdentityOptions } from './identity/extractor.js';
81
-
82
- export { verifyJwt } from './identity/jwt-verifier.js';
83
-
84
- // Path utilities
85
- export {
86
- matchPath,
87
- patternToRegex,
88
- hasPathParameters,
89
- normalizePath,
90
- } from './utils/path-matcher.js';
91
-
92
- // Header utilities
93
- export {
94
- normalizeHeaders,
95
- getHeader,
96
- getCorsHeaders,
97
- } from './utils/headers.js';
98
-
99
- // Tenant context
100
- export { TenantContext } from './tenant/TenantContext.js';
101
-
102
- export type {
103
- TenantInfo,
104
- TenantType,
105
- Plan,
106
- TenantFeatures,
107
- TenantClaims,
108
- } from './tenant/types.js';
109
-
110
- export {
111
- isTenantType,
112
- isPlan,
113
- TENANT_HEADERS,
114
- } from './tenant/types.js';
115
-
116
- export {
117
- tenantInfoFromClaims,
118
- tenantInfoFromHeaders,
119
- tenantInfoToHeaders,
120
- } from './tenant/helpers.js';
121
-
122
- // Tenant middleware
123
- export {
124
- initTenantContext,
125
- tenantGuard,
126
- crmGuard,
127
- } 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
+ };