@memberjunction/server 5.51.0 → 6.1.0-edge.1

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 (147) hide show
  1. package/LICENSE +7 -0
  2. package/dist/agentSessions/SessionManager.d.ts +22 -1
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +13 -3
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/actingContextResolver.d.ts +44 -0
  7. package/dist/auth/actingContextResolver.d.ts.map +1 -0
  8. package/dist/auth/actingContextResolver.js +18 -0
  9. package/dist/auth/actingContextResolver.js.map +1 -0
  10. package/dist/auth/index.d.ts +1 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js +1 -0
  13. package/dist/auth/index.js.map +1 -1
  14. package/dist/auth/sessionUserClone.d.ts +32 -0
  15. package/dist/auth/sessionUserClone.d.ts.map +1 -0
  16. package/dist/auth/sessionUserClone.js +50 -0
  17. package/dist/auth/sessionUserClone.js.map +1 -0
  18. package/dist/context.d.ts +1 -1
  19. package/dist/context.d.ts.map +1 -1
  20. package/dist/context.js +43 -6
  21. package/dist/context.js.map +1 -1
  22. package/dist/generated/generated.d.ts +234 -642
  23. package/dist/generated/generated.d.ts.map +1 -1
  24. package/dist/generated/generated.js +1277 -3755
  25. package/dist/generated/generated.js.map +1 -1
  26. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  27. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  28. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  29. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  30. package/dist/generic/PushStatusResolver.d.ts +58 -0
  31. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  32. package/dist/generic/PushStatusResolver.js +42 -3
  33. package/dist/generic/PushStatusResolver.js.map +1 -1
  34. package/dist/generic/ResolverBase.d.ts +9 -0
  35. package/dist/generic/ResolverBase.d.ts.map +1 -1
  36. package/dist/generic/ResolverBase.js +31 -13
  37. package/dist/generic/ResolverBase.js.map +1 -1
  38. package/dist/index.d.ts +7 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +20 -4
  41. package/dist/index.js.map +1 -1
  42. package/dist/multiTenancy/index.d.ts +19 -0
  43. package/dist/multiTenancy/index.d.ts.map +1 -1
  44. package/dist/multiTenancy/index.js +82 -5
  45. package/dist/multiTenancy/index.js.map +1 -1
  46. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  47. package/dist/resolvers/ActionResolver.js +12 -5
  48. package/dist/resolvers/ActionResolver.js.map +1 -1
  49. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  50. package/dist/resolvers/MCPResolver.js +13 -16
  51. package/dist/resolvers/MCPResolver.js.map +1 -1
  52. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -1
  53. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  54. package/dist/resolvers/RealtimeClientSessionResolver.js +15 -14
  55. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  56. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  57. package/dist/resolvers/RemoteBrowserActionResolver.js +19 -26
  58. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  59. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  60. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  61. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  62. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  63. package/dist/resolvers/RunTestResolver.js +90 -112
  64. package/dist/resolvers/RunTestResolver.js.map +1 -1
  65. package/dist/resolvers/SearchKnowledgeResolver.js +3 -3
  66. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  67. package/dist/resolvers/SearchKnowledgeStreamResolver.js +2 -2
  68. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  69. package/dist/resolvers/TaskGraphFrameResolver.d.ts +85 -0
  70. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -0
  71. package/dist/resolvers/TaskGraphFrameResolver.js +163 -0
  72. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -0
  73. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  75. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  76. package/dist/rest/RESTEndpointHandler.d.ts +0 -4
  77. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  78. package/dist/rest/RESTEndpointHandler.js +1 -15
  79. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  80. package/dist/services/StartTaskGraphDispatcher.d.ts +33 -0
  81. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -0
  82. package/dist/services/StartTaskGraphDispatcher.js +31 -0
  83. package/dist/services/StartTaskGraphDispatcher.js.map +1 -0
  84. package/dist/services/TaskGraphAgentRunner.d.ts +15 -0
  85. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -0
  86. package/dist/services/TaskGraphAgentRunner.js +74 -0
  87. package/dist/services/TaskGraphAgentRunner.js.map +1 -0
  88. package/dist/services/TaskGraphContinuationDeliverer.d.ts +70 -0
  89. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -0
  90. package/dist/services/TaskGraphContinuationDeliverer.js +162 -0
  91. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -0
  92. package/dist/services/TaskGraphProviderFactory.d.ts +39 -0
  93. package/dist/services/TaskGraphProviderFactory.d.ts.map +1 -0
  94. package/dist/services/TaskGraphProviderFactory.js +28 -0
  95. package/dist/services/TaskGraphProviderFactory.js.map +1 -0
  96. package/package.json +103 -100
  97. package/src/__tests__/RealtimeBridgeResolver.test.ts +6 -0
  98. package/src/__tests__/SessionManager.test.ts +44 -0
  99. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +249 -0
  100. package/src/__tests__/TaskGraphFrameResolver.test.ts +136 -0
  101. package/src/__tests__/multiTenancy.security.test.ts +254 -22
  102. package/src/__tests__/multiTenancy.test.ts +22 -0
  103. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  104. package/src/__tests__/resolverBase.rls.test.ts +4 -0
  105. package/src/__tests__/search-knowledge-tags.test.ts +74 -6
  106. package/src/__tests__/sessionUserClone.test.ts +161 -0
  107. package/src/agentSessions/SessionManager.ts +26 -2
  108. package/src/auth/actingContextResolver.ts +56 -0
  109. package/src/auth/index.ts +1 -0
  110. package/src/auth/sessionUserClone.ts +50 -0
  111. package/src/context.ts +48 -6
  112. package/src/generated/generated.ts +899 -2555
  113. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  114. package/src/generic/PushStatusResolver.ts +79 -4
  115. package/src/generic/ResolverBase.ts +33 -13
  116. package/src/index.ts +21 -4
  117. package/src/multiTenancy/index.ts +92 -5
  118. package/src/resolvers/ActionResolver.ts +15 -7
  119. package/src/resolvers/MCPResolver.ts +13 -15
  120. package/src/resolvers/RealtimeClientSessionResolver.ts +15 -13
  121. package/src/resolvers/RemoteBrowserActionResolver.ts +19 -25
  122. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  123. package/src/resolvers/RunTestResolver.ts +90 -112
  124. package/src/resolvers/SearchKnowledgeResolver.ts +3 -3
  125. package/src/resolvers/SearchKnowledgeStreamResolver.ts +2 -2
  126. package/src/resolvers/TaskGraphFrameResolver.ts +160 -0
  127. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  128. package/src/rest/RESTEndpointHandler.ts +1 -15
  129. package/src/services/StartTaskGraphDispatcher.ts +61 -0
  130. package/src/services/TaskGraphAgentRunner.ts +81 -0
  131. package/src/services/TaskGraphContinuationDeliverer.ts +181 -0
  132. package/src/services/TaskGraphProviderFactory.ts +53 -0
  133. package/dist/resolvers/ReportResolver.d.ts +0 -24
  134. package/dist/resolvers/ReportResolver.d.ts.map +0 -1
  135. package/dist/resolvers/ReportResolver.js +0 -190
  136. package/dist/resolvers/ReportResolver.js.map +0 -1
  137. package/dist/resolvers/TaskResolver.d.ts +0 -31
  138. package/dist/resolvers/TaskResolver.d.ts.map +0 -1
  139. package/dist/resolvers/TaskResolver.js +0 -157
  140. package/dist/resolvers/TaskResolver.js.map +0 -1
  141. package/dist/services/TaskOrchestrator.d.ts +0 -130
  142. package/dist/services/TaskOrchestrator.d.ts.map +0 -1
  143. package/dist/services/TaskOrchestrator.js +0 -690
  144. package/dist/services/TaskOrchestrator.js.map +0 -1
  145. package/src/resolvers/ReportResolver.ts +0 -157
  146. package/src/resolvers/TaskResolver.ts +0 -148
  147. package/src/services/TaskOrchestrator.ts +0 -873
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for the task-graph live signal.
3
+ *
4
+ * `taskGraphFrameFilter` is the security-critical gate and is tested in isolation for the same
5
+ * reason `statusUpdatesFilter` is: a `parentTaskId` is a discoverable value, so without an identity
6
+ * check anyone holding one could watch another user's workflow — including its per-step error
7
+ * messages. It must fail **closed**.
8
+ *
9
+ * The broadcaster's contract is that it can never disturb the work it is describing: a frame is
10
+ * commentary on execution, never a step of it.
11
+ */
12
+ // type-graphql's decorators evaluate at module load, and they need the metadata polyfill. Without
13
+ // this the file fails to COLLECT — which vitest reports as zero tests rather than as a failure.
14
+ import 'reflect-metadata';
15
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
16
+
17
+ vi.mock('@memberjunction/core', () => ({ LogError: vi.fn() }));
18
+
19
+ import {
20
+ TASK_GRAPH_FRAMES_TOPIC,
21
+ TaskGraphFrameBroadcaster,
22
+ taskGraphFrameFilter,
23
+ type TaskGraphFramePayload,
24
+ } from '../resolvers/TaskGraphFrameResolver';
25
+ import type { TaskGraphFrame } from '@memberjunction/task-graph';
26
+ import type { PubSubEngine } from 'type-graphql';
27
+
28
+ const OWNER = '11111111-1111-1111-1111-111111111111';
29
+ const OTHER = '22222222-2222-2222-2222-222222222222';
30
+ const GRAPH = '33333333-3333-3333-3333-333333333333';
31
+
32
+ const payload = (over: Partial<TaskGraphFramePayload> = {}): TaskGraphFramePayload => ({
33
+ Kind: 'TaskCompleted',
34
+ ParentTaskID: GRAPH,
35
+ TaskID: 'task-1',
36
+ TaskName: 'Summarize',
37
+ Status: 'Complete',
38
+ ownerUserId: OWNER,
39
+ ...over,
40
+ });
41
+
42
+ // `null` rather than `undefined` for "no identity": passing `undefined` explicitly triggers a
43
+ // default parameter, so the case would silently test the opposite of what it claims.
44
+ const context = (userId: string | null) => (userId ? { userPayload: { userRecord: { ID: userId } } } : {});
45
+
46
+ const check = (over: Partial<TaskGraphFramePayload> = {}, args = GRAPH, userId: string | null = OWNER) =>
47
+ taskGraphFrameFilter({ payload: payload(over), args: { parentTaskId: args }, context: context(userId) });
48
+
49
+ describe('taskGraphFrameFilter — the delivery gate', () => {
50
+ it('delivers a frame for the watched graph to its owner', () => {
51
+ expect(check()).toBe(true);
52
+ });
53
+
54
+ it('matches graph IDs case-insensitively — UUID casing varies by source', () => {
55
+ expect(check({}, GRAPH.toUpperCase())).toBe(true);
56
+ });
57
+
58
+ it('does NOT deliver frames from another graph', () => {
59
+ expect(check({ ParentTaskID: '44444444-4444-4444-4444-444444444444' })).toBe(false);
60
+ });
61
+
62
+ it('does NOT deliver another user\'s workflow to a subscriber holding its ID', () => {
63
+ // The whole reason identity is checked: parentTaskId is discoverable, and the frames carry
64
+ // per-step error messages.
65
+ expect(check({}, GRAPH, OTHER)).toBe(false);
66
+ });
67
+
68
+ it('fails closed when the connection has no identity', () => {
69
+ expect(check({}, GRAPH, null)).toBe(false);
70
+ });
71
+
72
+ it('fails closed when the frame has no owner', () => {
73
+ expect(check({ ownerUserId: '' as unknown as string })).toBe(false);
74
+ });
75
+
76
+ it('fails closed on a missing graph ID on either side', () => {
77
+ expect(check({ ParentTaskID: '' })).toBe(false);
78
+ expect(check({}, '')).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('TaskGraphFrameBroadcaster', () => {
83
+ let published: Array<[string, unknown]>;
84
+ let pubSub: PubSubEngine;
85
+
86
+ beforeEach(() => {
87
+ published = [];
88
+ pubSub = {
89
+ publish: vi.fn().mockImplementation(async (topic: string, p: unknown) => { published.push([topic, p]); }),
90
+ } as unknown as PubSubEngine;
91
+ });
92
+
93
+ const frame = (over: Partial<TaskGraphFrame> = {}): TaskGraphFrame => ({
94
+ Kind: 'TaskStarted', ParentTaskID: GRAPH, OwnerUserID: OWNER, TaskID: 'task-1', TaskName: 'Gather', ...over,
95
+ });
96
+
97
+ it('publishes the frame with the owner attached as the filter key', () => {
98
+ new TaskGraphFrameBroadcaster(pubSub).OnFrame(frame());
99
+
100
+ expect(published).toHaveLength(1);
101
+ const [topic, p] = published[0];
102
+ expect(topic).toBe(TASK_GRAPH_FRAMES_TOPIC);
103
+ expect((p as TaskGraphFramePayload).ownerUserId).toBe(OWNER);
104
+ expect((p as TaskGraphFramePayload).Kind).toBe('TaskStarted');
105
+ });
106
+
107
+ it('drops a frame with no owner rather than publishing one the filter would refuse', () => {
108
+ // Reachable for graphs submitted before ownership was recorded on the parent.
109
+ new TaskGraphFrameBroadcaster(pubSub).OnFrame(frame({ OwnerUserID: null }));
110
+ expect(published).toHaveLength(0);
111
+ });
112
+
113
+ it('never throws when publishing fails — a frame must not disturb the work it describes', () => {
114
+ const exploding = { publish: vi.fn(() => { throw new Error('pubsub down'); }) } as unknown as PubSubEngine;
115
+ expect(() => new TaskGraphFrameBroadcaster(exploding).OnFrame(frame())).not.toThrow();
116
+ });
117
+
118
+ it('carries the whole frame through, so a viewer can render without re-reading rows', () => {
119
+ new TaskGraphFrameBroadcaster(pubSub).OnFrame(
120
+ frame({ Kind: 'TaskFailed', Status: 'Failed', ErrorMessage: 'model timed out' }),
121
+ );
122
+ const p = published[0][1] as TaskGraphFramePayload;
123
+ expect(p.Status).toBe('Failed');
124
+ expect(p.ErrorMessage).toBe('model timed out');
125
+ expect(p.TaskName).toBe('Gather');
126
+ });
127
+
128
+ it('carries graph-level progress on a settled frame', () => {
129
+ new TaskGraphFrameBroadcaster(pubSub).OnFrame(
130
+ frame({ Kind: 'GraphSettled', TaskID: undefined, Status: 'Complete', CompletedCount: 7, TotalCount: 7 }),
131
+ );
132
+ const p = published[0][1] as TaskGraphFramePayload;
133
+ expect(p.CompletedCount).toBe(7);
134
+ expect(p.TotalCount).toBe(7);
135
+ });
136
+ });
@@ -6,9 +6,16 @@
6
6
  * in multiTenancy.test.ts.
7
7
  */
8
8
  import { describe, it, expect, vi } from 'vitest';
9
- import { createTenantPreRunViewHook, createTenantPreSaveHook, createTenantMiddleware } from '../multiTenancy/index.js';
9
+ import {
10
+ createTenantPreRunViewHook,
11
+ createTenantPreSaveHook,
12
+ createTenantMiddleware,
13
+ IsValidTenantId,
14
+ attachTenantContext,
15
+ } from '../multiTenancy/index.js';
10
16
  import type { MultiTenancyConfig } from '../config.js';
11
- import type { RunViewParams } from '@memberjunction/core';
17
+ import { Metadata } from '@memberjunction/core';
18
+ import type { RunViewParams, UserInfo, TenantContext } from '@memberjunction/core';
12
19
 
13
20
  // Mock @memberjunction/core Metadata for entity schema lookup.
14
21
  vi.mock('@memberjunction/core', async (importOriginal) => {
@@ -20,6 +27,31 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
20
27
  { Name: 'Orders', SchemaName: 'dbo' },
21
28
  { Name: 'Users', SchemaName: '__mj' },
22
29
  ];
30
+
31
+ // The hook validates that the tenant column resolves to a real, stored
32
+ // (non-virtual) field before interpolating it as an identifier.
33
+ EntityByName(name: string) {
34
+ const entity = this.Entities.find(
35
+ e => e.Name.trim().toLowerCase() === name.trim().toLowerCase()
36
+ );
37
+ if (!entity) return undefined;
38
+ return {
39
+ ...entity,
40
+ Fields: [
41
+ { Name: 'OrganizationID', IsVirtual: false },
42
+ { Name: 'TenantID', IsVirtual: false },
43
+ // A view-only column: must be REJECTED as a tenant column target
44
+ { Name: 'OrganizationName', IsVirtual: true },
45
+ ],
46
+ };
47
+ }
48
+
49
+ // QuoteFilterIdentifier reads the STATIC Metadata.Provider, matching a real
50
+ // server (Metadata.Provider is a DatabaseProviderBase instance). Tests that need
51
+ // to exercise the no-provider-available fail-closed path override this locally.
52
+ static Provider: { QuoteIdentifier: (name: string) => string } | undefined = {
53
+ QuoteIdentifier: (name: string) => `[${name}]`,
54
+ };
23
55
  }
24
56
 
25
57
  return {
@@ -58,41 +90,164 @@ function makeUser(tenantId?: string, roles: string[] = []): MockUser {
58
90
 
59
91
  describe('Multi-Tenancy Security Edge Cases', () => {
60
92
  describe('SQL injection in tenant ID', () => {
61
- it('should treat SQL injection attempt as literal tenant ID', () => {
93
+ it('should escape quotes so an injection attempt stays INSIDE the string literal', () => {
62
94
  const hook = createTenantPreRunViewHook(makeConfig());
63
95
  const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
64
96
  const user = makeUser("' OR 1=1 --");
65
97
 
66
98
  const result = hook(params, user) as RunViewParams;
67
99
 
68
- // The tenant ID is inserted as-is the SQL provider should use
69
- // parameterized queries to prevent actual injection.
70
- // The hook correctly generates a filter with the raw value.
71
- expect(result.ExtraFilter).toContain("' OR 1=1 --");
72
- expect(result.ExtraFilter).toBe("[OrganizationID] = '' OR 1=1 --'");
100
+ // The single quote is doubled at predicate construction, so the payload
101
+ // cannot terminate the literal and rewrite the WHERE clause. (The header
102
+ // boundary rejects this value outright via IsValidTenantId; this is the
103
+ // defense-in-depth layer for TenantContext attached from other sources.)
104
+ expect(result.ExtraFilter).toBe("[OrganizationID] = ''' OR 1=1 --'");
73
105
  });
74
106
 
75
- it('should handle tenant ID with SQL UNION attack', () => {
107
+ it('should escape quotes in a UNION attack payload', () => {
76
108
  const hook = createTenantPreRunViewHook(makeConfig());
77
109
  const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
78
110
  const user = makeUser("' UNION SELECT * FROM Users --");
79
111
 
80
112
  const result = hook(params, user) as RunViewParams;
81
- expect(result.ExtraFilter).toContain("UNION SELECT");
82
- // Note: This is a known limitation the filter is string-concatenated.
83
- // Defense-in-depth: the tenant ID should be validated upstream before
84
- // it reaches TenantContext. Middle-layer middleware (e.g., BCSaaS)
85
- // should validate that the tenant ID is a valid UUID or org ID.
113
+ // Leading quote doubled → the UNION text is inert literal content
114
+ expect(result.ExtraFilter).toBe("[OrganizationID] = ''' UNION SELECT * FROM Users --'");
86
115
  });
87
116
 
88
- it('should handle tenant ID with semicolon (statement terminator)', () => {
117
+ it('should escape quotes in a stacked-statement payload', () => {
89
118
  const hook = createTenantPreRunViewHook(makeConfig());
90
119
  const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
91
120
  const user = makeUser("abc'; DROP TABLE Customers; --");
92
121
 
93
122
  const result = hook(params, user) as RunViewParams;
94
- expect(result.ExtraFilter).toContain("DROP TABLE");
95
- // Same caveat: parameterized queries at the DB layer prevent execution
123
+ expect(result.ExtraFilter).toBe("[OrganizationID] = 'abc''; DROP TABLE Customers; --'");
124
+ });
125
+ });
126
+
127
+ // ─── Boundary validation: IsValidTenantId ───────────────────────────────
128
+
129
+ describe('IsValidTenantId', () => {
130
+ it('rejects a quote-based SQL injection payload', () => {
131
+ expect(IsValidTenantId("x' OR '1'='1")).toBe(false);
132
+ });
133
+
134
+ it('rejects the empty string', () => {
135
+ expect(IsValidTenantId('')).toBe(false);
136
+ });
137
+
138
+ it('rejects a 129-character string (max is 128)', () => {
139
+ expect(IsValidTenantId('a'.repeat(129))).toBe(false);
140
+ });
141
+
142
+ it('accepts a 128-character string (boundary)', () => {
143
+ expect(IsValidTenantId('a'.repeat(128))).toBe(true);
144
+ });
145
+
146
+ it('rejects whitespace, semicolons, and comparison operators', () => {
147
+ expect(IsValidTenantId('tenant 1')).toBe(false);
148
+ expect(IsValidTenantId('a;b')).toBe(false);
149
+ expect(IsValidTenantId("1'='1")).toBe(false);
150
+ expect(IsValidTenantId('a=b')).toBe(false);
151
+ });
152
+
153
+ it('accepts a standard GUID', () => {
154
+ expect(IsValidTenantId('550e8400-e29b-41d4-a716-446655440000')).toBe(true);
155
+ });
156
+
157
+ it('accepts conservative identifiers with underscore, dot, and hyphen', () => {
158
+ expect(IsValidTenantId('tenant_1.a-b')).toBe(true);
159
+ });
160
+
161
+ it('KNOWN GAP: accepts consecutive hyphens (SQL line-comment token) because GUIDs need hyphens', () => {
162
+ // 'a--b' passes the character allowlist. This is safe today only because
163
+ // every consumer interpolates the value inside a quote-escaped string
164
+ // literal, where '--' is inert. If a future consumer places the tenant id
165
+ // outside a quoted literal, this becomes a comment-injection vector —
166
+ // flagged here so a tightening of the pattern flips this assertion.
167
+ expect(IsValidTenantId('a--b')).toBe(true);
168
+ });
169
+ });
170
+
171
+ // ─── attachTenantContext validation ─────────────────────────────────────
172
+
173
+ describe('attachTenantContext', () => {
174
+ function makePlainUser(): UserInfo {
175
+ return { ID: 'user-1' } as unknown as UserInfo;
176
+ }
177
+
178
+ it('throws on an invalid tenant id instead of attaching it', () => {
179
+ const user = makePlainUser();
180
+ expect(() => attachTenantContext(user, "x' OR '1'='1", 'header')).toThrow(/Invalid tenant identifier/);
181
+ expect(user.TenantContext).toBeUndefined();
182
+ });
183
+
184
+ it('throws on empty and oversized tenant ids', () => {
185
+ const user = makePlainUser();
186
+ expect(() => attachTenantContext(user, '', 'custom')).toThrow(/Invalid tenant identifier/);
187
+ expect(() => attachTenantContext(user, 'a'.repeat(129), 'custom')).toThrow(/Invalid tenant identifier/);
188
+ });
189
+
190
+ it('attaches a valid tenant id with the given source', () => {
191
+ const user = makePlainUser();
192
+ attachTenantContext(user, 'tenant_1.a-b', 'linkedEntity');
193
+ expect(user.TenantContext).toEqual<TenantContext>({ TenantID: 'tenant_1.a-b', Source: 'linkedEntity' });
194
+ });
195
+ });
196
+
197
+ // ─── Tenant column resolution (identifier position) ─────────────────────
198
+
199
+ describe('tenant column must resolve to a stored field', () => {
200
+ it('produces an escaped-value predicate with a quoted identifier for a valid setup', () => {
201
+ const hook = createTenantPreRunViewHook(makeConfig());
202
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
203
+ const user = makeUser("t'1"); // legal only via non-header sources; must still be escaped
204
+
205
+ const result = hook(params, user) as RunViewParams;
206
+ expect(result.ExtraFilter).toBe("[OrganizationID] = 't''1'");
207
+ });
208
+
209
+ it('throws when the configured tenant column does not exist on the entity', () => {
210
+ const hook = createTenantPreRunViewHook(makeConfig({ defaultTenantColumn: 'NoSuchColumn' }));
211
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
212
+ const user = makeUser('tenant-1');
213
+
214
+ expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
215
+ });
216
+
217
+ it('throws when the tenant column resolves only to a VIRTUAL field', () => {
218
+ const hook = createTenantPreRunViewHook(makeConfig({ defaultTenantColumn: 'OrganizationName' }));
219
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
220
+ const user = makeUser('tenant-1');
221
+
222
+ expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
223
+ });
224
+
225
+ it('throws when an entityColumnMappings override points at a nonexistent column', () => {
226
+ const hook = createTenantPreRunViewHook(makeConfig({
227
+ entityColumnMappings: { 'Customers': "Bad]Column" },
228
+ }));
229
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
230
+ const user = makeUser('tenant-1');
231
+
232
+ expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
233
+ });
234
+
235
+ it('throws (fails closed) rather than falling back to hardcoded bracket quoting when no provider is available', () => {
236
+ const savedProvider = Metadata.Provider;
237
+ // @ts-expect-error — simulating the no-provider-available case
238
+ Metadata.Provider = undefined;
239
+ try {
240
+ const hook = createTenantPreRunViewHook(makeConfig());
241
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
242
+ const user = makeUser('tenant-1');
243
+
244
+ // A silent bracket-quoting fallback would produce invalid SQL on PostgreSQL
245
+ // without ever surfacing an error — refusing to build the query at all is the
246
+ // correct fail-closed behavior for a security-relevant identifier quote.
247
+ expect(() => hook(params, user)).toThrow(/no database provider available to quote/);
248
+ } finally {
249
+ Metadata.Provider = savedProvider;
250
+ }
96
251
  });
97
252
  });
98
253
 
@@ -232,12 +387,13 @@ describe('Multi-Tenancy Security Edge Cases', () => {
232
387
  expect(next).toHaveBeenCalledTimes(1);
233
388
  });
234
389
 
235
- it('should attach TenantContext when userPayload and header are present', () => {
390
+ it('should attach TenantContext to a CLONED userRecord when userPayload and header are present', () => {
236
391
  const middleware = createTenantMiddleware(makeConfig());
237
392
  const userRecord = { ID: 'u1' } as Record<string, unknown>;
393
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
238
394
  const req = {
239
395
  headers: { 'x-tenant-id': 'tenant-abc' },
240
- userPayload: { userRecord, email: 'test@test.com', sessionId: 's1' },
396
+ userPayload,
241
397
  } as unknown as Parameters<typeof middleware>[0];
242
398
  const res = {} as Parameters<typeof middleware>[1];
243
399
  const next = vi.fn();
@@ -245,26 +401,56 @@ describe('Multi-Tenancy Security Edge Cases', () => {
245
401
  middleware(req, res, next);
246
402
 
247
403
  expect(next).toHaveBeenCalledTimes(1);
248
- expect(userRecord['TenantContext']).toEqual({
404
+ // Clone-before-stamp: the SHARED cached record must never be mutated —
405
+ // stamping it in place leaks one session's tenant to concurrent sessions.
406
+ expect(userRecord['TenantContext']).toBeUndefined();
407
+ const stamped = userPayload.userRecord as unknown as UserInfo;
408
+ expect(stamped).not.toBe(userRecord);
409
+ expect(stamped.ID).toBe('u1');
410
+ expect(stamped.TenantContext).toEqual({
249
411
  TenantID: 'tenant-abc',
250
412
  Source: 'header',
251
413
  });
252
414
  });
253
415
 
416
+ it('should reject an invalid tenant header with 400 and never attach or continue', () => {
417
+ const middleware = createTenantMiddleware(makeConfig());
418
+ const userRecord = { ID: 'u1' } as Record<string, unknown>;
419
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
420
+ const req = {
421
+ headers: { 'x-tenant-id': "x' OR '1'='1" },
422
+ userPayload,
423
+ } as unknown as Parameters<typeof middleware>[0];
424
+ const json = vi.fn();
425
+ const status = vi.fn().mockReturnValue({ json });
426
+ const res = { status } as unknown as Parameters<typeof middleware>[1];
427
+ const next = vi.fn();
428
+
429
+ middleware(req, res, next);
430
+
431
+ // Reject the REQUEST — never degrade to an unscoped (fail-open) session
432
+ expect(status).toHaveBeenCalledWith(400);
433
+ expect(json).toHaveBeenCalledWith({ error: 'Invalid tenant identifier' });
434
+ expect(next).not.toHaveBeenCalled();
435
+ expect(userPayload.userRecord).toBe(userRecord);
436
+ expect(userRecord['TenantContext']).toBeUndefined();
437
+ });
438
+
254
439
  it('should handle case-insensitive header name', () => {
255
440
  const middleware = createTenantMiddleware(makeConfig({ tenantHeader: 'X-Tenant-ID' }));
256
441
  const userRecord = { ID: 'u1' } as Record<string, unknown>;
442
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
257
443
  const req = {
258
444
  // Express normalizes headers to lowercase
259
445
  headers: { 'x-tenant-id': 'tenant-xyz' },
260
- userPayload: { userRecord, email: 'test@test.com', sessionId: 's1' },
446
+ userPayload,
261
447
  } as unknown as Parameters<typeof middleware>[0];
262
448
  const res = {} as Parameters<typeof middleware>[1];
263
449
  const next = vi.fn();
264
450
 
265
451
  middleware(req, res, next);
266
452
 
267
- expect(userRecord['TenantContext']).toEqual({
453
+ expect((userPayload.userRecord as unknown as UserInfo).TenantContext).toEqual({
268
454
  TenantID: 'tenant-xyz',
269
455
  Source: 'header',
270
456
  });
@@ -284,6 +470,52 @@ describe('Multi-Tenancy Security Edge Cases', () => {
284
470
 
285
471
  expect(userRecord['TenantContext']).toBeUndefined();
286
472
  });
473
+
474
+ it('should reject a repeated header (array value) with 400 rather than guessing which value applies', () => {
475
+ const middleware = createTenantMiddleware(makeConfig());
476
+ const userRecord = { ID: 'u1' } as Record<string, unknown>;
477
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
478
+ const req = {
479
+ // Express delivers a repeated header as string[]
480
+ headers: { 'x-tenant-id': ['tenant-A', 'tenant-B'] },
481
+ userPayload,
482
+ } as unknown as Parameters<typeof middleware>[0];
483
+ const json = vi.fn();
484
+ const status = vi.fn().mockReturnValue({ json });
485
+ const res = { status } as unknown as Parameters<typeof middleware>[1];
486
+ const next = vi.fn();
487
+
488
+ middleware(req, res, next);
489
+
490
+ expect(status).toHaveBeenCalledWith(400);
491
+ expect(next).not.toHaveBeenCalled();
492
+ expect(userPayload.userRecord).toBe(userRecord);
493
+ expect(userRecord['TenantContext']).toBeUndefined();
494
+ });
495
+
496
+ it('two concurrent requests for the same shared user with different headers do not cross-contaminate', () => {
497
+ const middleware = createTenantMiddleware(makeConfig());
498
+ const sharedUserRecord = { ID: 'u1', UserRoles: [] } as unknown as Record<string, unknown>;
499
+
500
+ const reqA = {
501
+ headers: { 'x-tenant-id': 'tenant-A' },
502
+ userPayload: { userRecord: sharedUserRecord, email: 'a@test.com', sessionId: 'sA' },
503
+ } as unknown as Parameters<typeof middleware>[0];
504
+ const reqB = {
505
+ headers: { 'x-tenant-id': 'tenant-B' },
506
+ userPayload: { userRecord: sharedUserRecord, email: 'b@test.com', sessionId: 'sB' },
507
+ } as unknown as Parameters<typeof middleware>[0];
508
+ const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Parameters<typeof middleware>[1];
509
+
510
+ middleware(reqA, res, vi.fn());
511
+ middleware(reqB, res, vi.fn());
512
+
513
+ const userA = (reqA as unknown as { userPayload: { userRecord: UserInfo } }).userPayload.userRecord;
514
+ const userB = (reqB as unknown as { userPayload: { userRecord: UserInfo } }).userPayload.userRecord;
515
+ expect(userA.TenantContext?.TenantID).toBe('tenant-A');
516
+ expect(userB.TenantContext?.TenantID).toBe('tenant-B');
517
+ expect((sharedUserRecord as unknown as UserInfo).TenantContext).toBeUndefined();
518
+ });
287
519
  });
288
520
 
289
521
  // ─── PreSave security edge cases ──────────────────────────────────────
@@ -15,6 +15,28 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
15
15
  { Name: 'AI Models', SchemaName: '__mj' },
16
16
  { Name: 'Users', SchemaName: '__mj' },
17
17
  ];
18
+
19
+ // The hook validates that the tenant column resolves to a real, stored
20
+ // (non-virtual) field on the entity before interpolating it as an identifier.
21
+ EntityByName(name: string) {
22
+ const entity = this.Entities.find(
23
+ e => e.Name.trim().toLowerCase() === name.trim().toLowerCase()
24
+ );
25
+ if (!entity) return undefined;
26
+ return {
27
+ ...entity,
28
+ Fields: [
29
+ { Name: 'OrganizationID', IsVirtual: false },
30
+ { Name: 'TenantID', IsVirtual: false },
31
+ ],
32
+ };
33
+ }
34
+
35
+ // QuoteFilterIdentifier reads the STATIC Metadata.Provider, matching a real
36
+ // server (Metadata.Provider is a DatabaseProviderBase instance).
37
+ static Provider = {
38
+ QuoteIdentifier: (name: string) => `[${name}]`,
39
+ };
18
40
  }
19
41
 
20
42
  return {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * pushStatusResolver.filter.test.ts — locks the security-critical subscription filter for B49.
3
+ *
4
+ * `statusUpdatesFilter` is the gate that fixes the `statusUpdates` session-hijack: a push is
5
+ * delivered only when the push's `sessionId` matches the subscriber's requested session AND the
6
+ * push's `ownerUserId` matches the subscriber CONNECTION's server-authenticated identity. Knowing
7
+ * a victim's client-chosen `sessionId` must no longer be sufficient to receive their pushes.
8
+ *
9
+ * The filter is a pure function (no DB / config / transport), so these are plain unit tests.
10
+ */
11
+ import 'reflect-metadata';
12
+ import { describe, it, expect } from 'vitest';
13
+ import { statusUpdatesFilter, type PushStatusNotificationPayload, type StatusUpdatesFilterContext } from '../generic/PushStatusResolver';
14
+ import type { UserPayload } from '../types';
15
+
16
+ const OWNER = 'AA11BB22-0000-4000-8000-000000000001';
17
+ const ATTACKER = 'CC33DD44-0000-4000-8000-000000000002';
18
+ const SESSION = 'session-abc-123';
19
+
20
+ function ctxFor(userId: string | undefined): StatusUpdatesFilterContext {
21
+ // userRecord is `any` in UserPayload; a minimal shape with ID is all the filter reads.
22
+ return { userPayload: (userId ? { userRecord: { ID: userId } } : {}) as unknown as UserPayload };
23
+ }
24
+
25
+ function push(overrides: Partial<PushStatusNotificationPayload> = {}): PushStatusNotificationPayload {
26
+ return { sessionId: SESSION, ownerUserId: OWNER, message: 'progress', ...overrides };
27
+ }
28
+
29
+ describe('statusUpdatesFilter (B49 session-hijack gate)', () => {
30
+ it('delivers when sessionId matches AND the connection identity owns the push', () => {
31
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(true);
32
+ });
33
+
34
+ it('THE FIX: rejects a matching sessionId when the connection identity is a DIFFERENT user (hijack)', () => {
35
+ // Attacker lifted the victim's sessionId and subscribes with it — but their authenticated
36
+ // connection identity is their own, so delivery must be denied.
37
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER }), args: { sessionId: SESSION }, context: ctxFor(ATTACKER) })).toBe(false);
38
+ });
39
+
40
+ it('rejects when the requested sessionId does not match the push', () => {
41
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: 'some-other-session' }, context: ctxFor(OWNER) })).toBe(false);
42
+ });
43
+
44
+ it('fails closed when the push carries no ownerUserId', () => {
45
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: '' }), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(false);
46
+ });
47
+
48
+ it('fails closed when the connection has no authenticated identity', () => {
49
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(undefined) })).toBe(false);
50
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: undefined })).toBe(false);
51
+ });
52
+
53
+ it('matches identity case-insensitively (SQL Server upper vs PostgreSQL lower UUIDs)', () => {
54
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER.toUpperCase() }), args: { sessionId: SESSION }, context: ctxFor(OWNER.toLowerCase()) })).toBe(true);
55
+ });
56
+ });
@@ -136,7 +136,11 @@ function makeEntity(name: string, opts: {
136
136
  return {
137
137
  Name: name,
138
138
  UserExemptFromRowLevelSecurity: () => opts.exempt,
139
+ // ResolverBase now delegates to GetEffectiveRowFilterWhereClause (role RLS
140
+ // + API-key row filters); the deprecated role-only method is kept in the
141
+ // mock shape for completeness but is no longer what the resolver calls.
139
142
  GetUserRowLevelSecurityWhereClause: () => opts.exempt ? '' : opts.rlsClause,
143
+ GetEffectiveRowFilterWhereClause: () => opts.exempt ? '' : opts.rlsClause,
140
144
  } as unknown as EntityInfo;
141
145
  }
142
146