serverless-event-orchestrator 1.2.6 → 1.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 (62) hide show
  1. package/dist/dispatcher.d.ts.map +1 -1
  2. package/dist/dispatcher.js +30 -7
  3. package/dist/dispatcher.js.map +1 -1
  4. package/dist/index.d.ts +5 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +17 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/middleware/crm-guard.d.ts +25 -0
  9. package/dist/middleware/crm-guard.d.ts.map +1 -0
  10. package/dist/middleware/crm-guard.js +45 -0
  11. package/dist/middleware/crm-guard.js.map +1 -0
  12. package/dist/middleware/index.d.ts +4 -0
  13. package/dist/middleware/index.d.ts.map +1 -0
  14. package/dist/middleware/index.js +10 -0
  15. package/dist/middleware/index.js.map +1 -0
  16. package/dist/middleware/init-tenant-context.d.ts +21 -0
  17. package/dist/middleware/init-tenant-context.d.ts.map +1 -0
  18. package/dist/middleware/init-tenant-context.js +50 -0
  19. package/dist/middleware/init-tenant-context.js.map +1 -0
  20. package/dist/middleware/tenant-guard.d.ts +26 -0
  21. package/dist/middleware/tenant-guard.d.ts.map +1 -0
  22. package/dist/middleware/tenant-guard.js +48 -0
  23. package/dist/middleware/tenant-guard.js.map +1 -0
  24. package/dist/tenant/TenantContext.d.ts +88 -0
  25. package/dist/tenant/TenantContext.d.ts.map +1 -0
  26. package/dist/tenant/TenantContext.js +110 -0
  27. package/dist/tenant/TenantContext.js.map +1 -0
  28. package/dist/tenant/helpers.d.ts +26 -0
  29. package/dist/tenant/helpers.d.ts.map +1 -0
  30. package/dist/tenant/helpers.js +86 -0
  31. package/dist/tenant/helpers.js.map +1 -0
  32. package/dist/tenant/index.d.ts +5 -0
  33. package/dist/tenant/index.d.ts.map +1 -0
  34. package/dist/tenant/index.js +14 -0
  35. package/dist/tenant/index.js.map +1 -0
  36. package/dist/tenant/types.d.ts +84 -0
  37. package/dist/tenant/types.d.ts.map +1 -0
  38. package/dist/tenant/types.js +29 -0
  39. package/dist/tenant/types.js.map +1 -0
  40. package/dist/types/routes.d.ts +2 -0
  41. package/dist/types/routes.d.ts.map +1 -1
  42. package/dist/utils/path-matcher.d.ts.map +1 -1
  43. package/dist/utils/path-matcher.js +6 -2
  44. package/dist/utils/path-matcher.js.map +1 -1
  45. package/package.json +11 -1
  46. package/src/dispatcher.ts +34 -9
  47. package/src/index.ts +30 -0
  48. package/src/middleware/crm-guard.ts +51 -0
  49. package/src/middleware/index.ts +3 -0
  50. package/src/middleware/init-tenant-context.ts +59 -0
  51. package/src/middleware/tenant-guard.ts +54 -0
  52. package/src/tenant/TenantContext.ts +115 -0
  53. package/src/tenant/helpers.ts +95 -0
  54. package/src/tenant/index.ts +21 -0
  55. package/src/tenant/types.ts +101 -0
  56. package/src/types/routes.ts +2 -0
  57. package/src/utils/path-matcher.ts +10 -5
  58. package/tests/middleware/crm-guard.test.ts +69 -0
  59. package/tests/middleware/init-tenant-context.test.ts +147 -0
  60. package/tests/middleware/tenant-guard.test.ts +100 -0
  61. package/tests/tenant/TenantContext.test.ts +134 -0
  62. package/tests/tenant/helpers.test.ts +122 -0
@@ -0,0 +1,147 @@
1
+ import { initTenantContext } from '../../src/middleware/init-tenant-context';
2
+ import { TenantContext } from '../../src/tenant';
3
+ import { RouteSegment } from '../../src/types/event-type.enum';
4
+ import type { NormalizedEvent } from '../../src/types/routes';
5
+
6
+ function createMockEvent(overrides: Partial<{
7
+ segment: RouteSegment;
8
+ claims: Record<string, any>;
9
+ headers: Record<string, string>;
10
+ userId: string;
11
+ groups: string[];
12
+ }>): NormalizedEvent {
13
+ return {
14
+ eventRaw: {},
15
+ eventType: 'apigateway',
16
+ payload: {
17
+ body: {},
18
+ pathParameters: {},
19
+ queryStringParameters: {},
20
+ headers: overrides.headers ?? {},
21
+ },
22
+ params: {},
23
+ context: {
24
+ segment: overrides.segment ?? RouteSegment.Private,
25
+ identity: overrides.claims ? {
26
+ userId: overrides.userId ?? 'user-1',
27
+ groups: overrides.groups ?? [],
28
+ claims: overrides.claims,
29
+ } : undefined,
30
+ requestId: 'req-test',
31
+ },
32
+ };
33
+ }
34
+
35
+ describe('initTenantContext', () => {
36
+ afterEach(() => {
37
+ TenantContext._reset();
38
+ });
39
+
40
+ describe('from JWT claims (private/backoffice)', () => {
41
+ it('extracts tenantInfo from identity.claims and adds to context.tenantInfo', async () => {
42
+ const event = createMockEvent({
43
+ segment: RouteSegment.Private,
44
+ claims: {
45
+ 'custom:tenantId': 'org_abc',
46
+ 'custom:tenantType': 'ORG',
47
+ 'custom:userId': 'user-1',
48
+ 'custom:countryCode': 'CO',
49
+ 'custom:personProfileId': 'person_1',
50
+ 'custom:orgProfileId': 'org_abc',
51
+ 'custom:plan': 'PRO',
52
+ 'custom:hasCRM': 'true',
53
+ },
54
+ });
55
+
56
+ const result = await initTenantContext(event) as NormalizedEvent;
57
+
58
+ expect(result.context.tenantInfo).toBeDefined();
59
+ expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
60
+ expect(result.context.tenantInfo?.tenantType).toBe('ORG');
61
+ expect(result.context.tenantInfo?.countryCode).toBe('CO');
62
+ expect(result.context.tenantInfo?.plan).toBe('PRO');
63
+ expect(result.context.tenantInfo?.hasCRM).toBe(true);
64
+ });
65
+
66
+ it('initializes TenantContext (AsyncLocalStorage)', async () => {
67
+ const event = createMockEvent({
68
+ claims: {
69
+ 'custom:tenantId': 'org_abc',
70
+ 'custom:tenantType': 'ORG',
71
+ 'custom:userId': 'user-1',
72
+ 'custom:countryCode': 'CO',
73
+ },
74
+ });
75
+
76
+ await initTenantContext(event);
77
+
78
+ expect(TenantContext.isActive()).toBe(true);
79
+ expect(TenantContext.current().tenantId).toBe('org_abc');
80
+ });
81
+
82
+ it('handles incomplete claims without error (returns event without tenantInfo)', async () => {
83
+ const event = createMockEvent({
84
+ claims: { 'custom:tenantId': 'org_abc' }, // missing tenantType, userId, countryCode
85
+ });
86
+
87
+ const result = await initTenantContext(event) as NormalizedEvent;
88
+
89
+ expect(result.context.tenantInfo).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ describe('from headers (internal/Lambda-to-Lambda)', () => {
94
+ it('extracts tenantInfo from x-tenant-* headers', async () => {
95
+ const event = createMockEvent({
96
+ segment: RouteSegment.Internal,
97
+ headers: {
98
+ 'x-tenant-id': 'org_xyz',
99
+ 'x-tenant-type': 'ORG',
100
+ 'x-user-id': 'user-2',
101
+ 'x-country-code': 'MX',
102
+ },
103
+ });
104
+
105
+ const result = await initTenantContext(event) as NormalizedEvent;
106
+
107
+ expect(result.context.tenantInfo?.tenantId).toBe('org_xyz');
108
+ expect(result.context.tenantInfo?.tenantType).toBe('ORG');
109
+ });
110
+ });
111
+
112
+ describe('public routes (no authentication)', () => {
113
+ it('returns event without tenantInfo when no claims or headers', async () => {
114
+ const event = createMockEvent({
115
+ segment: RouteSegment.Public,
116
+ });
117
+
118
+ const result = await initTenantContext(event) as NormalizedEvent;
119
+
120
+ expect(result.context.tenantInfo).toBeUndefined();
121
+ expect(TenantContext.isActive()).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe('priority: claims > headers', () => {
126
+ it('uses claims if both are present', async () => {
127
+ const event = createMockEvent({
128
+ claims: {
129
+ 'custom:tenantId': 'org_from_claims',
130
+ 'custom:tenantType': 'ORG',
131
+ 'custom:userId': 'user-1',
132
+ 'custom:countryCode': 'CO',
133
+ },
134
+ headers: {
135
+ 'x-tenant-id': 'org_from_headers',
136
+ 'x-tenant-type': 'PERSON',
137
+ 'x-user-id': 'user-2',
138
+ 'x-country-code': 'MX',
139
+ },
140
+ });
141
+
142
+ const result = await initTenantContext(event) as NormalizedEvent;
143
+
144
+ expect(result.context.tenantInfo?.tenantId).toBe('org_from_claims');
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,100 @@
1
+ import { tenantGuard } from '../../src/middleware/tenant-guard';
2
+ import { RouteSegment } from '../../src/types/event-type.enum';
3
+ import type { NormalizedEvent } from '../../src/types/routes';
4
+ import type { TenantInfo } from '../../src/tenant';
5
+
6
+ function createEvent(options: {
7
+ tenantInfo?: TenantInfo;
8
+ groups?: string[];
9
+ }): NormalizedEvent {
10
+ return {
11
+ eventRaw: {},
12
+ eventType: 'apigateway',
13
+ payload: { headers: {} },
14
+ params: {},
15
+ context: {
16
+ segment: RouteSegment.Private,
17
+ identity: {
18
+ userId: 'user-1',
19
+ groups: options.groups ?? [],
20
+ claims: {},
21
+ },
22
+ requestId: 'req-test',
23
+ tenantInfo: options.tenantInfo,
24
+ },
25
+ };
26
+ }
27
+
28
+ const mockTenant: TenantInfo = {
29
+ tenantId: 'org_abc',
30
+ tenantType: 'ORG',
31
+ userId: 'user-1',
32
+ countryCode: 'CO',
33
+ };
34
+
35
+ describe('tenantGuard', () => {
36
+ it('allows access when tenantInfo exists', async () => {
37
+ const event = createEvent({ tenantInfo: mockTenant });
38
+ const result = await tenantGuard(event) as NormalizedEvent;
39
+ expect(result).toBeDefined();
40
+ expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
41
+ });
42
+
43
+ it('throws 403 when no tenantInfo and not PLATFORM_ADMIN', async () => {
44
+ const event = createEvent({ groups: ['AGENT'] });
45
+ await expect(tenantGuard(event)).rejects.toMatchObject({
46
+ statusCode: 403,
47
+ });
48
+ });
49
+
50
+ it('throws 403 when no tenantInfo and no groups', async () => {
51
+ const event = createEvent({});
52
+ await expect(tenantGuard(event)).rejects.toMatchObject({
53
+ statusCode: 403,
54
+ });
55
+ });
56
+
57
+ it('throws 403 when tenantId is empty string', async () => {
58
+ const event = createEvent({
59
+ tenantInfo: { ...mockTenant, tenantId: '' }
60
+ });
61
+ await expect(tenantGuard(event)).rejects.toMatchObject({
62
+ statusCode: 403,
63
+ });
64
+ });
65
+
66
+ it('throws 403 when tenantId is whitespace only', async () => {
67
+ const event = createEvent({
68
+ tenantInfo: { ...mockTenant, tenantId: ' ' }
69
+ });
70
+ await expect(tenantGuard(event)).rejects.toMatchObject({
71
+ statusCode: 403,
72
+ });
73
+ });
74
+
75
+ it('error includes TENANT_CONTEXT_MISSING code', async () => {
76
+ const event = createEvent({ groups: ['ORG_ADMIN'] });
77
+ try {
78
+ await tenantGuard(event);
79
+ fail('Should have thrown');
80
+ } catch (err: any) {
81
+ const body = JSON.parse(err.body);
82
+ expect(body.code).toBe('TENANT_CONTEXT_MISSING');
83
+ }
84
+ });
85
+
86
+ it('allows PLATFORM_ADMIN without tenantInfo (cross-tenant)', async () => {
87
+ const event = createEvent({ groups: ['PLATFORM_ADMIN'] });
88
+ const result = await tenantGuard(event);
89
+ expect(result).toBeDefined();
90
+ });
91
+
92
+ it('allows PLATFORM_ADMIN with tenantInfo', async () => {
93
+ const event = createEvent({
94
+ tenantInfo: mockTenant,
95
+ groups: ['PLATFORM_ADMIN'],
96
+ });
97
+ const result = await tenantGuard(event) as NormalizedEvent;
98
+ expect(result.context.tenantInfo?.tenantId).toBe('org_abc');
99
+ });
100
+ });
@@ -0,0 +1,134 @@
1
+ import { TenantContext } from '../../src/tenant/TenantContext';
2
+ import type { TenantInfo } from '../../src/tenant/types';
3
+
4
+ const mockTenantORG: TenantInfo = {
5
+ tenantId: 'org_century21',
6
+ tenantType: 'ORG',
7
+ userId: 'user_001',
8
+ personProfileId: 'person_001',
9
+ orgProfileId: 'org_century21',
10
+ countryCode: 'CO',
11
+ plan: 'PRO',
12
+ hasCRM: true,
13
+ };
14
+
15
+ const mockTenantPERSON: TenantInfo = {
16
+ tenantId: 'person_agent_x',
17
+ tenantType: 'PERSON',
18
+ userId: 'user_002',
19
+ personProfileId: 'person_agent_x',
20
+ countryCode: 'MX',
21
+ plan: 'FREE',
22
+ hasCRM: false,
23
+ };
24
+
25
+ describe('TenantContext', () => {
26
+ afterEach(() => {
27
+ TenantContext._reset();
28
+ });
29
+
30
+ describe('run() + current()', () => {
31
+ it('stores and retrieves TenantInfo correctly inside callback', async () => {
32
+ await TenantContext.run(mockTenantORG, async () => {
33
+ const tenant = TenantContext.current();
34
+ expect(tenant.tenantId).toBe('org_century21');
35
+ expect(tenant.tenantType).toBe('ORG');
36
+ expect(tenant.userId).toBe('user_001');
37
+ expect(tenant.countryCode).toBe('CO');
38
+ expect(tenant.plan).toBe('PRO');
39
+ expect(tenant.hasCRM).toBe(true);
40
+ });
41
+ });
42
+
43
+ it('supports PERSON tenants', async () => {
44
+ await TenantContext.run(mockTenantPERSON, async () => {
45
+ const tenant = TenantContext.current();
46
+ expect(tenant.tenantId).toBe('person_agent_x');
47
+ expect(tenant.tenantType).toBe('PERSON');
48
+ expect(tenant.orgProfileId).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ it('isolates context between concurrent calls', async () => {
53
+ const results: string[] = [];
54
+
55
+ await Promise.all([
56
+ TenantContext.run(mockTenantORG, async () => {
57
+ await new Promise((r) => setTimeout(r, 10));
58
+ results.push(TenantContext.current().tenantId);
59
+ }),
60
+ TenantContext.run(mockTenantPERSON, async () => {
61
+ await new Promise((r) => setTimeout(r, 5));
62
+ results.push(TenantContext.current().tenantId);
63
+ }),
64
+ ]);
65
+
66
+ expect(results).toContain('org_century21');
67
+ expect(results).toContain('person_agent_x');
68
+ });
69
+
70
+ it('propagates context to nested async functions', async () => {
71
+ async function nestedFunction(): Promise<string> {
72
+ return TenantContext.current().tenantId;
73
+ }
74
+
75
+ await TenantContext.run(mockTenantORG, async () => {
76
+ const result = await nestedFunction();
77
+ expect(result).toBe('org_century21');
78
+ });
79
+ });
80
+ });
81
+
82
+ describe('current() — fail-closed', () => {
83
+ it('throws error if not initialized', () => {
84
+ expect(() => TenantContext.current()).toThrow('TenantContext not initialized');
85
+ });
86
+
87
+ it('throws error after exiting run() scope', async () => {
88
+ await TenantContext.run(mockTenantORG, async () => {
89
+ expect(TenantContext.current().tenantId).toBe('org_century21');
90
+ });
91
+
92
+ // Outside scope → no more context
93
+ expect(() => TenantContext.current()).toThrow('TenantContext not initialized');
94
+ });
95
+ });
96
+
97
+ describe('currentOptional()', () => {
98
+ it('returns undefined if no context', () => {
99
+ expect(TenantContext.currentOptional()).toBeUndefined();
100
+ });
101
+
102
+ it('returns TenantInfo if context exists', async () => {
103
+ await TenantContext.run(mockTenantORG, async () => {
104
+ const tenant = TenantContext.currentOptional();
105
+ expect(tenant).toBeDefined();
106
+ expect(tenant?.tenantId).toBe('org_century21');
107
+ });
108
+ });
109
+ });
110
+
111
+ describe('set()', () => {
112
+ it('sets context in current AsyncLocalStorage scope', async () => {
113
+ await TenantContext.run(mockTenantORG, async () => {
114
+ expect(TenantContext.current().tenantId).toBe('org_century21');
115
+
116
+ // Overwrite with set()
117
+ TenantContext.set(mockTenantPERSON);
118
+ expect(TenantContext.current().tenantId).toBe('person_agent_x');
119
+ });
120
+ });
121
+ });
122
+
123
+ describe('isActive()', () => {
124
+ it('returns false outside a context', () => {
125
+ expect(TenantContext.isActive()).toBe(false);
126
+ });
127
+
128
+ it('returns true inside a context', async () => {
129
+ await TenantContext.run(mockTenantORG, async () => {
130
+ expect(TenantContext.isActive()).toBe(true);
131
+ });
132
+ });
133
+ });
134
+ });
@@ -0,0 +1,122 @@
1
+ import {
2
+ isTenantType,
3
+ isPlan,
4
+ tenantInfoFromClaims,
5
+ tenantInfoFromHeaders,
6
+ tenantInfoToHeaders,
7
+ TENANT_HEADERS,
8
+ } from '../../src/tenant';
9
+ import type { TenantInfo } from '../../src/tenant';
10
+
11
+ describe('Type Guards', () => {
12
+ describe('isTenantType', () => {
13
+ it('accepts ORG', () => expect(isTenantType('ORG')).toBe(true));
14
+ it('accepts PERSON', () => expect(isTenantType('PERSON')).toBe(true));
15
+ it('rejects invalid string', () => expect(isTenantType('INVALID')).toBe(false));
16
+ it('rejects undefined', () => expect(isTenantType(undefined)).toBe(false));
17
+ it('rejects null', () => expect(isTenantType(null)).toBe(false));
18
+ });
19
+
20
+ describe('isPlan', () => {
21
+ it.each(['FREE', 'BASIC', 'PRO', 'ENTERPRISE'])('accepts %s', (plan) => {
22
+ expect(isPlan(plan)).toBe(true);
23
+ });
24
+ it('rejects invalid string', () => expect(isPlan('PREMIUM')).toBe(false));
25
+ });
26
+ });
27
+
28
+ describe('tenantInfoFromClaims', () => {
29
+ const validClaims = {
30
+ 'custom:tenantId': 'org_abc',
31
+ 'custom:tenantType': 'ORG',
32
+ 'custom:userId': 'user_001',
33
+ 'custom:countryCode': 'CO',
34
+ 'custom:personProfileId': 'person_001',
35
+ 'custom:orgProfileId': 'org_abc',
36
+ 'custom:plan': 'PRO',
37
+ 'custom:hasCRM': 'true',
38
+ };
39
+
40
+ it('builds complete TenantInfo from valid claims', () => {
41
+ const result = tenantInfoFromClaims(validClaims);
42
+ expect(result).toEqual({
43
+ tenantId: 'org_abc',
44
+ tenantType: 'ORG',
45
+ userId: 'user_001',
46
+ countryCode: 'CO',
47
+ personProfileId: 'person_001',
48
+ orgProfileId: 'org_abc',
49
+ plan: 'PRO',
50
+ hasCRM: true,
51
+ });
52
+ });
53
+
54
+ it('returns undefined if tenantId is missing', () => {
55
+ const { 'custom:tenantId': _, ...claims } = validClaims;
56
+ expect(tenantInfoFromClaims(claims)).toBeUndefined();
57
+ });
58
+
59
+ it('returns undefined if tenantType is missing', () => {
60
+ const { 'custom:tenantType': _, ...claims } = validClaims;
61
+ expect(tenantInfoFromClaims(claims)).toBeUndefined();
62
+ });
63
+
64
+ it('returns undefined if tenantType is invalid', () => {
65
+ expect(tenantInfoFromClaims({ ...validClaims, 'custom:tenantType': 'INVALID' })).toBeUndefined();
66
+ });
67
+
68
+ it('hasCRM is false if claim is not "true"', () => {
69
+ const result = tenantInfoFromClaims({ ...validClaims, 'custom:hasCRM': 'false' });
70
+ expect(result?.hasCRM).toBe(false);
71
+ });
72
+
73
+ it('plan is undefined if claim is not a valid Plan', () => {
74
+ const result = tenantInfoFromClaims({ ...validClaims, 'custom:plan': 'PREMIUM' });
75
+ expect(result?.plan).toBeUndefined();
76
+ });
77
+
78
+ it('uses sub as fallback for userId', () => {
79
+ const claims = {
80
+ 'custom:tenantId': 'org_abc',
81
+ 'custom:tenantType': 'ORG',
82
+ 'sub': 'cognito-sub-123',
83
+ 'custom:countryCode': 'CO',
84
+ };
85
+ const result = tenantInfoFromClaims(claims);
86
+ expect(result?.userId).toBe('cognito-sub-123');
87
+ });
88
+ });
89
+
90
+ describe('tenantInfoFromHeaders / tenantInfoToHeaders', () => {
91
+ const tenant: TenantInfo = {
92
+ tenantId: 'org_abc',
93
+ tenantType: 'ORG',
94
+ userId: 'user_001',
95
+ countryCode: 'CO',
96
+ personProfileId: 'person_001',
97
+ orgProfileId: 'org_abc',
98
+ };
99
+
100
+ it('roundtrip: toHeaders → fromHeaders returns same TenantInfo', () => {
101
+ const headers = tenantInfoToHeaders(tenant);
102
+ const result = tenantInfoFromHeaders(headers);
103
+ expect(result).toEqual(tenant);
104
+ });
105
+
106
+ it('fromHeaders returns undefined if x-tenant-id is missing', () => {
107
+ const headers = tenantInfoToHeaders(tenant);
108
+ delete headers[TENANT_HEADERS.TENANT_ID];
109
+ expect(tenantInfoFromHeaders(headers)).toBeUndefined();
110
+ });
111
+
112
+ it('fromHeaders supports lowercase headers (API Gateway normalization)', () => {
113
+ const headers = {
114
+ 'x-tenant-id': 'org_abc',
115
+ 'x-tenant-type': 'ORG',
116
+ 'x-user-id': 'user_001',
117
+ 'x-country-code': 'CO',
118
+ };
119
+ const result = tenantInfoFromHeaders(headers);
120
+ expect(result?.tenantId).toBe('org_abc');
121
+ });
122
+ });