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
@@ -1,115 +1,115 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
- import type { TenantInfo } from './types.js';
3
-
4
- /**
5
- * TenantContext provides thread-safe (async-safe) access to the current tenant context.
6
- *
7
- * Uses Node.js AsyncLocalStorage to maintain TenantInfo throughout the
8
- * execution of a request without needing to pass it as a parameter.
9
- *
10
- * Initialization:
11
- * - In HTTP requests: the `initTenantContext` middleware extracts the tenant
12
- * from JWT claims or headers and calls `TenantContext.set()`.
13
- * - In EventBridge/SQS: the handler extracts tenantId from event.detail and calls `TenantContext.run()`.
14
- *
15
- * Consumption:
16
- * - TenantAwareDynamoRepository: `TenantContext.current().tenantId`
17
- * - ApiInvoker: `TenantContext.currentOptional()` to propagate headers
18
- * - Use cases: `TenantContext.current()` when they need the tenant explicitly
19
- *
20
- * IMPORTANT:
21
- * - `current()` is FAIL-CLOSED: throws error if no context (prevents data leaks).
22
- * - `currentOptional()` returns undefined without error (for code that works with/without tenant).
23
- * - `run()` is for scenarios where an explicit scope is needed (EventBridge handlers).
24
- * - `set()` is for the orchestrator middleware that operates in the same async scope.
25
- */
26
- export class TenantContext {
27
- private static storage = new AsyncLocalStorage<TenantInfo>();
28
-
29
- /**
30
- * Executes a callback within a tenant context.
31
- * Useful for EventBridge/SQS handlers where there's no orchestrator middleware.
32
- *
33
- * @example
34
- * ```typescript
35
- * await TenantContext.run(tenantInfo, async () => {
36
- * const props = await propertiesRepo.findByStatus('PUBLISHED');
37
- * // propertiesRepo automatically filters by tenantInfo.tenantId
38
- * });
39
- * ```
40
- */
41
- static run<T>(tenant: TenantInfo, callback: () => T): T {
42
- return this.storage.run(tenant, callback);
43
- }
44
-
45
- /**
46
- * Sets the tenant context in the current AsyncLocalStorage store.
47
- * ONLY should be called by the initTenantContext middleware.
48
- *
49
- * NOTE: Requires an active store (created by AsyncLocalStorage.run()
50
- * or by the Lambda runtime). If called outside an async context,
51
- * the value is lost. For those cases, use `run()`.
52
- *
53
- * Internally uses enterWith() which replaces the current store.
54
- */
55
- static set(tenant: TenantInfo): void {
56
- this.storage.enterWith(tenant);
57
- }
58
-
59
- /**
60
- * Gets the current TenantInfo. FAIL-CLOSED: throws error if not initialized.
61
- * Use in code that REQUIRES a tenant (repositories, guards).
62
- *
63
- * @throws Error if TenantContext is not initialized
64
- *
65
- * @example
66
- * ```typescript
67
- * const { tenantId } = TenantContext.current();
68
- * // Safe: if we get here, tenantId exists
69
- * ```
70
- */
71
- static current(): TenantInfo {
72
- const tenant = this.storage.getStore();
73
- if (!tenant) {
74
- throw new Error(
75
- 'TenantContext not initialized. Ensure initTenantContext middleware is configured ' +
76
- 'in globalMiddleware, or use TenantContext.run() for non-HTTP triggers.'
77
- );
78
- }
79
- return tenant;
80
- }
81
-
82
- /**
83
- * Gets the current TenantInfo or undefined if not initialized.
84
- * Use in code that works with or without tenant (ApiInvoker, loggers, public routes).
85
- *
86
- * @example
87
- * ```typescript
88
- * const tenant = TenantContext.currentOptional();
89
- * if (tenant) {
90
- * headers['x-tenant-id'] = tenant.tenantId;
91
- * }
92
- * ```
93
- */
94
- static currentOptional(): TenantInfo | undefined {
95
- return this.storage.getStore();
96
- }
97
-
98
- /**
99
- * Checks if there's an active tenant context.
100
- * Useful for conditionals without getting the full object.
101
- */
102
- static isActive(): boolean {
103
- return this.storage.getStore() !== undefined;
104
- }
105
-
106
- /**
107
- * Clears the current context. Only for testing.
108
- * DO NOT use in production — the context is automatically cleaned when exiting the scope.
109
- * @internal
110
- */
111
- static _reset(): void {
112
- this.storage.disable();
113
- (this as any).storage = new AsyncLocalStorage<TenantInfo>();
114
- }
115
- }
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { TenantInfo } from './types.js';
3
+
4
+ /**
5
+ * TenantContext provides thread-safe (async-safe) access to the current tenant context.
6
+ *
7
+ * Uses Node.js AsyncLocalStorage to maintain TenantInfo throughout the
8
+ * execution of a request without needing to pass it as a parameter.
9
+ *
10
+ * Initialization:
11
+ * - In HTTP requests: the `initTenantContext` middleware extracts the tenant
12
+ * from JWT claims or headers and calls `TenantContext.set()`.
13
+ * - In EventBridge/SQS: the handler extracts tenantId from event.detail and calls `TenantContext.run()`.
14
+ *
15
+ * Consumption:
16
+ * - TenantAwareDynamoRepository: `TenantContext.current().tenantId`
17
+ * - ApiInvoker: `TenantContext.currentOptional()` to propagate headers
18
+ * - Use cases: `TenantContext.current()` when they need the tenant explicitly
19
+ *
20
+ * IMPORTANT:
21
+ * - `current()` is FAIL-CLOSED: throws error if no context (prevents data leaks).
22
+ * - `currentOptional()` returns undefined without error (for code that works with/without tenant).
23
+ * - `run()` is for scenarios where an explicit scope is needed (EventBridge handlers).
24
+ * - `set()` is for the orchestrator middleware that operates in the same async scope.
25
+ */
26
+ export class TenantContext {
27
+ private static storage = new AsyncLocalStorage<TenantInfo>();
28
+
29
+ /**
30
+ * Executes a callback within a tenant context.
31
+ * Useful for EventBridge/SQS handlers where there's no orchestrator middleware.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * await TenantContext.run(tenantInfo, async () => {
36
+ * const props = await propertiesRepo.findByStatus('PUBLISHED');
37
+ * // propertiesRepo automatically filters by tenantInfo.tenantId
38
+ * });
39
+ * ```
40
+ */
41
+ static run<T>(tenant: TenantInfo, callback: () => T): T {
42
+ return this.storage.run(tenant, callback);
43
+ }
44
+
45
+ /**
46
+ * Sets the tenant context in the current AsyncLocalStorage store.
47
+ * ONLY should be called by the initTenantContext middleware.
48
+ *
49
+ * NOTE: Requires an active store (created by AsyncLocalStorage.run()
50
+ * or by the Lambda runtime). If called outside an async context,
51
+ * the value is lost. For those cases, use `run()`.
52
+ *
53
+ * Internally uses enterWith() which replaces the current store.
54
+ */
55
+ static set(tenant: TenantInfo): void {
56
+ this.storage.enterWith(tenant);
57
+ }
58
+
59
+ /**
60
+ * Gets the current TenantInfo. FAIL-CLOSED: throws error if not initialized.
61
+ * Use in code that REQUIRES a tenant (repositories, guards).
62
+ *
63
+ * @throws Error if TenantContext is not initialized
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * const { tenantId } = TenantContext.current();
68
+ * // Safe: if we get here, tenantId exists
69
+ * ```
70
+ */
71
+ static current(): TenantInfo {
72
+ const tenant = this.storage.getStore();
73
+ if (!tenant) {
74
+ throw new Error(
75
+ 'TenantContext not initialized. Ensure initTenantContext middleware is configured ' +
76
+ 'in globalMiddleware, or use TenantContext.run() for non-HTTP triggers.'
77
+ );
78
+ }
79
+ return tenant;
80
+ }
81
+
82
+ /**
83
+ * Gets the current TenantInfo or undefined if not initialized.
84
+ * Use in code that works with or without tenant (ApiInvoker, loggers, public routes).
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * const tenant = TenantContext.currentOptional();
89
+ * if (tenant) {
90
+ * headers['x-tenant-id'] = tenant.tenantId;
91
+ * }
92
+ * ```
93
+ */
94
+ static currentOptional(): TenantInfo | undefined {
95
+ return this.storage.getStore();
96
+ }
97
+
98
+ /**
99
+ * Checks if there's an active tenant context.
100
+ * Useful for conditionals without getting the full object.
101
+ */
102
+ static isActive(): boolean {
103
+ return this.storage.getStore() !== undefined;
104
+ }
105
+
106
+ /**
107
+ * Clears the current context. Only for testing.
108
+ * DO NOT use in production — the context is automatically cleaned when exiting the scope.
109
+ * @internal
110
+ */
111
+ static _reset(): void {
112
+ this.storage.disable();
113
+ (this as any).storage = new AsyncLocalStorage<TenantInfo>();
114
+ }
115
+ }
@@ -1,112 +1,112 @@
1
- import type { TenantInfo } from './types.js';
2
- import { isTenantType, isPlan, TENANT_HEADERS } from './types.js';
3
-
4
- /**
5
- * Resolves a claim value by checking both `custom:{key}` and `{key}` formats.
6
- * Cognito Pre Token Generation V2 adds claims WITHOUT the `custom:` prefix,
7
- * while Cognito user pool custom attributes use the `custom:` prefix.
8
- * This helper supports both conventions transparently.
9
- */
10
- function getClaim(claims: Record<string, any>, key: string): any {
11
- return claims[`custom:${key}`] ?? claims[key];
12
- }
13
-
14
- /**
15
- * Builds a TenantInfo from TenantClaims in the JWT.
16
- * Used by initTenantContext middleware in serverless-event-orchestrator.
17
- *
18
- * Supports claims with or without the `custom:` prefix:
19
- * - `custom:tenantId` (Cognito user pool custom attributes)
20
- * - `tenantId` (Cognito Pre Token Generation V2 added claims)
21
- *
22
- * @param claims - Partial claims object from JWT (event.context.identity.claims)
23
- * @returns TenantInfo if all required fields are present, undefined otherwise
24
- */
25
- export function tenantInfoFromClaims(claims: Record<string, any>): TenantInfo | undefined {
26
- const tenantId = getClaim(claims, 'tenantId');
27
- const tenantType = getClaim(claims, 'tenantType');
28
- const userId = getClaim(claims, 'userId') || claims['sub'];
29
- const countryCode = getClaim(claims, 'countryCode');
30
- const personProfileId = getClaim(claims, 'personProfileId');
31
- const orgProfileId = getClaim(claims, 'orgProfileId');
32
- const hasCRM = getClaim(claims, 'hasCRM');
33
-
34
- if (!tenantId || !tenantType || !userId || !countryCode) {
35
- return undefined;
36
- }
37
-
38
- if (!isTenantType(tenantType)) {
39
- return undefined;
40
- }
41
-
42
- return {
43
- tenantId,
44
- tenantType,
45
- userId,
46
- personProfileId,
47
- orgProfileId,
48
- countryCode,
49
- plan: isPlan(getClaim(claims, 'plan')) ? getClaim(claims, 'plan') : undefined,
50
- hasCRM,
51
- };
52
- }
53
-
54
- /**
55
- * Builds a TenantInfo from headers (Lambda-to-Lambda).
56
- * Used by initTenantContext middleware for internal routes.
57
- *
58
- * @param headers - Headers object (may have original or lowercase keys)
59
- * @returns TenantInfo if all required fields are present, undefined otherwise
60
- */
61
- export function tenantInfoFromHeaders(
62
- headers: Record<string, string | undefined>
63
- ): TenantInfo | undefined {
64
- const get = (key: string) => headers[key] || headers[key.toLowerCase()];
65
-
66
- const tenantId = get(TENANT_HEADERS.TENANT_ID);
67
- const tenantType = get(TENANT_HEADERS.TENANT_TYPE);
68
- const userId = get(TENANT_HEADERS.USER_ID);
69
- const countryCode = get(TENANT_HEADERS.COUNTRY_CODE);
70
-
71
- if (!tenantId || !tenantType || !userId || !countryCode) {
72
- return undefined;
73
- }
74
-
75
- if (!isTenantType(tenantType)) {
76
- return undefined;
77
- }
78
-
79
- return {
80
- tenantId,
81
- tenantType: tenantType as 'ORG' | 'PERSON',
82
- userId,
83
- personProfileId: get(TENANT_HEADERS.PERSON_PROFILE_ID),
84
- orgProfileId: get(TENANT_HEADERS.ORG_PROFILE_ID),
85
- countryCode,
86
- };
87
- }
88
-
89
- /**
90
- * Converts TenantInfo to headers for Lambda-to-Lambda propagation.
91
- * Used by ApiInvoker to propagate the context.
92
- *
93
- * @param tenant - TenantInfo to serialize
94
- * @returns Headers object with X-Tenant-* headers
95
- */
96
- export function tenantInfoToHeaders(tenant: TenantInfo): Record<string, string> {
97
- const headers: Record<string, string> = {
98
- [TENANT_HEADERS.TENANT_ID]: tenant.tenantId,
99
- [TENANT_HEADERS.TENANT_TYPE]: tenant.tenantType,
100
- [TENANT_HEADERS.USER_ID]: tenant.userId,
101
- [TENANT_HEADERS.COUNTRY_CODE]: tenant.countryCode,
102
- };
103
-
104
- if (tenant.personProfileId) {
105
- headers[TENANT_HEADERS.PERSON_PROFILE_ID] = tenant.personProfileId;
106
- }
107
- if (tenant.orgProfileId) {
108
- headers[TENANT_HEADERS.ORG_PROFILE_ID] = tenant.orgProfileId;
109
- }
110
-
111
- return headers;
112
- }
1
+ import type { TenantInfo } from './types.js';
2
+ import { isTenantType, isPlan, TENANT_HEADERS } from './types.js';
3
+
4
+ /**
5
+ * Resolves a claim value by checking both `custom:{key}` and `{key}` formats.
6
+ * Cognito Pre Token Generation V2 adds claims WITHOUT the `custom:` prefix,
7
+ * while Cognito user pool custom attributes use the `custom:` prefix.
8
+ * This helper supports both conventions transparently.
9
+ */
10
+ function getClaim(claims: Record<string, any>, key: string): any {
11
+ return claims[`custom:${key}`] ?? claims[key];
12
+ }
13
+
14
+ /**
15
+ * Builds a TenantInfo from TenantClaims in the JWT.
16
+ * Used by initTenantContext middleware in serverless-event-orchestrator.
17
+ *
18
+ * Supports claims with or without the `custom:` prefix:
19
+ * - `custom:tenantId` (Cognito user pool custom attributes)
20
+ * - `tenantId` (Cognito Pre Token Generation V2 added claims)
21
+ *
22
+ * @param claims - Partial claims object from JWT (event.context.identity.claims)
23
+ * @returns TenantInfo if all required fields are present, undefined otherwise
24
+ */
25
+ export function tenantInfoFromClaims(claims: Record<string, any>): TenantInfo | undefined {
26
+ const tenantId = getClaim(claims, 'tenantId');
27
+ const tenantType = getClaim(claims, 'tenantType');
28
+ const userId = getClaim(claims, 'userId') || claims['sub'];
29
+ const countryCode = getClaim(claims, 'countryCode');
30
+ const personProfileId = getClaim(claims, 'personProfileId');
31
+ const orgProfileId = getClaim(claims, 'orgProfileId');
32
+ const hasCRM = getClaim(claims, 'hasCRM');
33
+
34
+ if (!tenantId || !tenantType || !userId || !countryCode) {
35
+ return undefined;
36
+ }
37
+
38
+ if (!isTenantType(tenantType)) {
39
+ return undefined;
40
+ }
41
+
42
+ return {
43
+ tenantId,
44
+ tenantType,
45
+ userId,
46
+ personProfileId,
47
+ orgProfileId,
48
+ countryCode,
49
+ plan: isPlan(getClaim(claims, 'plan')) ? getClaim(claims, 'plan') : undefined,
50
+ hasCRM,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Builds a TenantInfo from headers (Lambda-to-Lambda).
56
+ * Used by initTenantContext middleware for internal routes.
57
+ *
58
+ * @param headers - Headers object (may have original or lowercase keys)
59
+ * @returns TenantInfo if all required fields are present, undefined otherwise
60
+ */
61
+ export function tenantInfoFromHeaders(
62
+ headers: Record<string, string | undefined>
63
+ ): TenantInfo | undefined {
64
+ const get = (key: string) => headers[key] || headers[key.toLowerCase()];
65
+
66
+ const tenantId = get(TENANT_HEADERS.TENANT_ID);
67
+ const tenantType = get(TENANT_HEADERS.TENANT_TYPE);
68
+ const userId = get(TENANT_HEADERS.USER_ID);
69
+ const countryCode = get(TENANT_HEADERS.COUNTRY_CODE);
70
+
71
+ if (!tenantId || !tenantType || !userId || !countryCode) {
72
+ return undefined;
73
+ }
74
+
75
+ if (!isTenantType(tenantType)) {
76
+ return undefined;
77
+ }
78
+
79
+ return {
80
+ tenantId,
81
+ tenantType: tenantType as 'ORG' | 'PERSON',
82
+ userId,
83
+ personProfileId: get(TENANT_HEADERS.PERSON_PROFILE_ID),
84
+ orgProfileId: get(TENANT_HEADERS.ORG_PROFILE_ID),
85
+ countryCode,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Converts TenantInfo to headers for Lambda-to-Lambda propagation.
91
+ * Used by ApiInvoker to propagate the context.
92
+ *
93
+ * @param tenant - TenantInfo to serialize
94
+ * @returns Headers object with X-Tenant-* headers
95
+ */
96
+ export function tenantInfoToHeaders(tenant: TenantInfo): Record<string, string> {
97
+ const headers: Record<string, string> = {
98
+ [TENANT_HEADERS.TENANT_ID]: tenant.tenantId,
99
+ [TENANT_HEADERS.TENANT_TYPE]: tenant.tenantType,
100
+ [TENANT_HEADERS.USER_ID]: tenant.userId,
101
+ [TENANT_HEADERS.COUNTRY_CODE]: tenant.countryCode,
102
+ };
103
+
104
+ if (tenant.personProfileId) {
105
+ headers[TENANT_HEADERS.PERSON_PROFILE_ID] = tenant.personProfileId;
106
+ }
107
+ if (tenant.orgProfileId) {
108
+ headers[TENANT_HEADERS.ORG_PROFILE_ID] = tenant.orgProfileId;
109
+ }
110
+
111
+ return headers;
112
+ }
@@ -1,21 +1,21 @@
1
- export { TenantContext } from './TenantContext.js';
2
-
3
- export type {
4
- TenantInfo,
5
- TenantType,
6
- Plan,
7
- TenantFeatures,
8
- TenantClaims,
9
- } from './types.js';
10
-
11
- export {
12
- isTenantType,
13
- isPlan,
14
- TENANT_HEADERS,
15
- } from './types.js';
16
-
17
- export {
18
- tenantInfoFromClaims,
19
- tenantInfoFromHeaders,
20
- tenantInfoToHeaders,
21
- } from './helpers.js';
1
+ export { TenantContext } from './TenantContext.js';
2
+
3
+ export type {
4
+ TenantInfo,
5
+ TenantType,
6
+ Plan,
7
+ TenantFeatures,
8
+ TenantClaims,
9
+ } from './types.js';
10
+
11
+ export {
12
+ isTenantType,
13
+ isPlan,
14
+ TENANT_HEADERS,
15
+ } from './types.js';
16
+
17
+ export {
18
+ tenantInfoFromClaims,
19
+ tenantInfoFromHeaders,
20
+ tenantInfoToHeaders,
21
+ } from './helpers.js';