@memberjunction/server 5.50.0 → 6.1.0-edge.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 (116) 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 +18 -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 +71 -0
  23. package/dist/generated/generated.d.ts.map +1 -1
  24. package/dist/generated/generated.js +383 -3
  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 +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -2
  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/realtimeWidget/widgetGuestElevation.d.ts +22 -0
  47. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  48. package/dist/realtimeWidget/widgetGuestElevation.js +36 -0
  49. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  50. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  51. package/dist/resolvers/ActionResolver.js +12 -5
  52. package/dist/resolvers/ActionResolver.js.map +1 -1
  53. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  54. package/dist/resolvers/MCPResolver.js +13 -16
  55. package/dist/resolvers/MCPResolver.js.map +1 -1
  56. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +22 -1
  57. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  58. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -30
  59. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  60. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/RemoteBrowserActionResolver.js +19 -26
  62. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  63. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  64. package/dist/resolvers/ReportResolver.js +11 -3
  65. package/dist/resolvers/ReportResolver.js.map +1 -1
  66. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  68. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  69. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  70. package/dist/resolvers/RunTestResolver.js +90 -112
  71. package/dist/resolvers/RunTestResolver.js.map +1 -1
  72. package/dist/resolvers/SearchKnowledgeResolver.js +3 -3
  73. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  74. package/dist/resolvers/SearchKnowledgeStreamResolver.js +2 -2
  75. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  76. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  77. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  78. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  79. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  80. package/dist/services/TaskOrchestrator.js +17 -15
  81. package/dist/services/TaskOrchestrator.js.map +1 -1
  82. package/package.json +100 -100
  83. package/src/__tests__/RealtimeBridgeResolver.test.ts +6 -0
  84. package/src/__tests__/RealtimeClientSessionResolver.test.ts +420 -0
  85. package/src/__tests__/SessionManager.test.ts +106 -0
  86. package/src/__tests__/multiTenancy.security.test.ts +254 -22
  87. package/src/__tests__/multiTenancy.test.ts +22 -0
  88. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  89. package/src/__tests__/resolverBase.rls.test.ts +4 -0
  90. package/src/__tests__/search-knowledge-tags.test.ts +74 -6
  91. package/src/__tests__/sessionUserClone.test.ts +161 -0
  92. package/src/__tests__/widgetGuestElevation.test.ts +70 -2
  93. package/src/agentSessions/SessionManager.ts +32 -3
  94. package/src/auth/actingContextResolver.ts +56 -0
  95. package/src/auth/index.ts +1 -0
  96. package/src/auth/sessionUserClone.ts +50 -0
  97. package/src/context.ts +48 -6
  98. package/src/generated/generated.ts +276 -3
  99. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  100. package/src/generic/PushStatusResolver.ts +79 -4
  101. package/src/generic/ResolverBase.ts +33 -13
  102. package/src/index.ts +4 -2
  103. package/src/multiTenancy/index.ts +92 -5
  104. package/src/realtimeWidget/widgetGuestElevation.ts +41 -0
  105. package/src/resolvers/ActionResolver.ts +15 -7
  106. package/src/resolvers/MCPResolver.ts +13 -15
  107. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -29
  108. package/src/resolvers/RemoteBrowserActionResolver.ts +19 -25
  109. package/src/resolvers/ReportResolver.ts +11 -3
  110. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  111. package/src/resolvers/RunTestResolver.ts +90 -112
  112. package/src/resolvers/SearchKnowledgeResolver.ts +3 -3
  113. package/src/resolvers/SearchKnowledgeStreamResolver.ts +2 -2
  114. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  115. package/src/resolvers/__tests__/ReportResolver.test.ts +279 -0
  116. package/src/services/TaskOrchestrator.ts +17 -17
@@ -1,4 +1,6 @@
1
- import { Arg, Field, ID, ObjectType, Resolver, ResolverFilterData, Root, Subscription } from 'type-graphql';
1
+ import { Arg, Field, ID, ObjectType, PubSubEngine, Resolver, ResolverFilterData, Root, Subscription } from 'type-graphql';
2
+ import { UUIDsEqual } from '@memberjunction/global';
3
+ import { UserPayload } from '../types.js';
2
4
 
3
5
  export const PUSH_STATUS_UPDATES_TOPIC = 'PUSH_STATUS_UPDATES';
4
6
 
@@ -14,27 +16,100 @@ export class PushStatusNotification {
14
16
  sessionId!: string;
15
17
  }
16
18
 
19
+ /**
20
+ * Internal wire payload for a push-status update. Carries `ownerUserId` — the authenticated user
21
+ * the update belongs to — used ONLY server-side by the subscription filter and deliberately NOT
22
+ * exposed on the client-facing {@link PushStatusNotification} (we never leak user IDs to subscribers).
23
+ *
24
+ * `ownerUserId` is REQUIRED: it is the load-bearing security property (see
25
+ * {@link statusUpdatesFilter}), so making it required means the compiler rejects any publish that
26
+ * omits identity. Always publish through {@link publishStatusUpdate} (or the
27
+ * `ResolverBase.PublishStatusUpdate` ergonomic wrapper) rather than calling `pubSub.publish` on
28
+ * this topic directly, so a new publisher can never forget it.
29
+ */
17
30
  export interface PushStatusNotificationPayload {
18
31
  message?: string;
19
32
  sessionId: string;
33
+ /** Authenticated user the update belongs to. Server-side filter key; never sent to the client. */
34
+ ownerUserId: string;
20
35
  }
21
36
 
22
37
  interface PushStatusNotificationArgs {
23
38
  sessionId: string;
24
39
  }
25
40
 
41
+ /** Parameters for {@link publishStatusUpdate} — identity (`ownerUserId`) is required by design. */
42
+ export interface StatusUpdateParams {
43
+ /** Session the target client subscribed on (routes the push to the right browser tab). */
44
+ sessionId: string;
45
+ /** Authenticated user the operation belongs to (the trust anchor the filter enforces). */
46
+ ownerUserId: string;
47
+ /** Message envelope (plain string, or the resolvers' JSON `{resolver,type,status,data}` shape). */
48
+ message?: string;
49
+ }
50
+
51
+ /**
52
+ * The single, centralized vehicle for publishing on {@link PUSH_STATUS_UPDATES_TOPIC}. Every
53
+ * publisher — resolver or service — routes through here (resolvers via the
54
+ * `ResolverBase.PublishStatusUpdate` wrapper; non-resolvers by calling this directly), so the
55
+ * payload shape and the required-identity guarantee live in exactly one place. Adding a field to
56
+ * the push is a one-line change here; a new publisher physically cannot omit `ownerUserId`.
57
+ */
58
+ export function publishStatusUpdate(pubSub: PubSubEngine, params: StatusUpdateParams): void {
59
+ const payload: PushStatusNotificationPayload = {
60
+ sessionId: params.sessionId,
61
+ ownerUserId: params.ownerUserId,
62
+ message: params.message,
63
+ };
64
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, payload);
65
+ }
66
+
67
+ /** Minimal shape of the subscription's connection context needed by the filter. */
68
+ export interface StatusUpdatesFilterContext {
69
+ userPayload?: UserPayload;
70
+ }
71
+
72
+ /**
73
+ * Filter predicate for the `statusUpdates` subscription. Exported so it can be unit-tested in
74
+ * isolation (it is the security-critical gate). A push reaches a subscriber only when BOTH hold:
75
+ * 1. the push's `sessionId` matches the subscriber's requested `sessionId` (tab routing), AND
76
+ * 2. the push's `ownerUserId` matches the subscriber CONNECTION's authenticated identity
77
+ * (`context.userPayload.userRecord.ID`, established once at WS connect).
78
+ *
79
+ * Condition (2) is the fix for the session-hijack class (B49): `sessionId` is a client-chosen
80
+ * correlation value, so a subscriber who lifts another user's `sessionId` would previously receive
81
+ * their pushes. Binding delivery to the connection's server-authenticated identity means knowing a
82
+ * `sessionId` is no longer sufficient. Fails CLOSED — a missing owner or connection identity never
83
+ * matches.
84
+ */
85
+ export function statusUpdatesFilter(data: {
86
+ payload: PushStatusNotificationPayload;
87
+ args: PushStatusNotificationArgs;
88
+ context: StatusUpdatesFilterContext | undefined;
89
+ }): boolean {
90
+ const { payload, args, context } = data;
91
+ if (payload.sessionId !== args.sessionId) {
92
+ return false;
93
+ }
94
+ const connectionUserId: string | undefined = context?.userPayload?.userRecord?.ID;
95
+ if (!connectionUserId || !payload.ownerUserId) {
96
+ return false; // fail closed — no identity on either side means no delivery
97
+ }
98
+ return UUIDsEqual(payload.ownerUserId, connectionUserId);
99
+ }
100
+
26
101
  @Resolver()
27
102
  export class PushStatusResolver {
28
103
  @Subscription(() => PushStatusNotification, {
29
104
  topics: PUSH_STATUS_UPDATES_TOPIC,
30
- filter: ({ payload, args, context }: ResolverFilterData<PushStatusNotificationPayload, PushStatusNotificationArgs, any>) => {
31
- return payload.sessionId === args.sessionId;
32
- },
105
+ filter: (data: ResolverFilterData<PushStatusNotificationPayload, PushStatusNotificationArgs, StatusUpdatesFilterContext>) =>
106
+ statusUpdatesFilter(data),
33
107
  })
34
108
  statusUpdates(
35
109
  @Root() { message }: PushStatusNotificationPayload,
36
110
  @Arg('sessionId', () => String) sessionId: string
37
111
  ): PushStatusNotification {
112
+ // NOTE: ownerUserId is intentionally NOT returned — it is a server-side filter key only.
38
113
  return { message, date: new Date(), sessionId };
39
114
  }
40
115
  }
@@ -32,7 +32,7 @@ import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './Run
32
32
  import { DeleteOptionsInput } from './DeleteOptionsInput.js';
33
33
  import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
34
34
  import { EncryptionEngine } from '@memberjunction/encryption';
35
- import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
35
+ import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
36
36
  import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
37
37
  import { PubSubManager } from './PubSubManager.js';
38
38
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
@@ -691,7 +691,11 @@ export class ResolverBase {
691
691
  return;
692
692
  }
693
693
 
694
- // Check specific scope
694
+ // Check specific scope. The acting context stamped on the session user (set
695
+ // server-side in context.ts, never client-supplied) rides along so filtered
696
+ // rules with {{Acting*}} tokens can validate their required values — without
697
+ // it, a filtered rule fails closed even for a session carrying valid context.
698
+ const sessionUser = this.GetUserFromPayload(userPayload);
695
699
  const result = await apiKeyEngine.Authorize(
696
700
  userPayload.apiKeyHash,
697
701
  'MJAPI',
@@ -701,6 +705,9 @@ export class ResolverBase {
701
705
  {
702
706
  endpoint: '/graphql',
703
707
  method: 'POST'
708
+ },
709
+ {
710
+ actingContext: sessionUser?.APIKeyActingContext
704
711
  }
705
712
  );
706
713
 
@@ -999,7 +1006,7 @@ export class ResolverBase {
999
1006
  ?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
1000
1007
  if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
1001
1008
 
1002
- return entityInfo.GetUserRowLevelSecurityWhereClause(user, type, returnPrefix);
1009
+ return entityInfo.GetEffectiveRowFilterWhereClause(user, type, returnPrefix);
1003
1010
  }
1004
1011
 
1005
1012
  protected async createAuditLogRecord(
@@ -1101,6 +1108,22 @@ export class ResolverBase {
1101
1108
  });
1102
1109
  }
1103
1110
 
1111
+ /**
1112
+ * Publishes a push-status update to the client on {@link PUSH_STATUS_UPDATES_TOPIC}, stamping the
1113
+ * authenticated owner's user ID from `userPayload` so the subscription filter can bind delivery
1114
+ * to identity (see B49 / `statusUpdatesFilter`). The ergonomic wrapper every resolver should use
1115
+ * instead of calling `pubSub.publish` on the topic directly — it makes omitting identity
1116
+ * impossible. Non-resolver publishers (services, the liveness heartbeat) call the shared
1117
+ * `publishStatusUpdate()` function directly with an explicit `ownerUserId`.
1118
+ */
1119
+ protected PublishStatusUpdate(pubSub: PubSubEngine, sessionId: string, message: string | undefined, userPayload: UserPayload): void {
1120
+ publishStatusUpdate(pubSub, {
1121
+ sessionId,
1122
+ ownerUserId: userPayload?.userRecord?.ID ?? '',
1123
+ message,
1124
+ });
1125
+ }
1126
+
1104
1127
  protected ListenForEntityMessages(entityObject: BaseEntity, pubSub: PubSubEngine, userPayload: UserPayload) {
1105
1128
  // The unique key is set up for each entity object via it's primary key to ensure that we only have one listener at most for each unique
1106
1129
  // entity in the system. This is important because we don't want to have multiple listeners for the same entity as it could
@@ -1130,16 +1153,13 @@ export class ResolverBase {
1130
1153
  const baseEntityEvent = event.args as BaseEntityEvent;
1131
1154
  // message from our entity object, relay it to the client
1132
1155
  LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
1133
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
1134
- message: JSON.stringify({
1135
- status: 'OK',
1136
- type: 'EntityObjectStatusMessage',
1137
- entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
1138
- primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
1139
- message: event.args.payload,
1140
- }),
1141
- sessionId: userPayload.sessionId,
1142
- });
1156
+ this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
1157
+ status: 'OK',
1158
+ type: 'EntityObjectStatusMessage',
1159
+ entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
1160
+ primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
1161
+ message: event.args.payload,
1162
+ }), userPayload);
1143
1163
  }
1144
1164
  }
1145
1165
  });
package/src/index.ts CHANGED
@@ -113,6 +113,8 @@ export {
113
113
  verifyUserRecord,
114
114
  } from './auth/index.js';
115
115
  export * from './auth/APIKeyScopeAuth.js';
116
+ export * from './auth/actingContextResolver.js';
117
+ export { CloneUserForSessionContext } from './auth/sessionUserClone.js';
116
118
 
117
119
  export * from './generic/PushStatusResolver.js';
118
120
  export * from './generic/PubSubManager.js';
@@ -481,7 +483,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
481
483
  startupLog.LogIf('verbose', 'Read-only Connection Pool has been initialized.');
482
484
  }
483
485
 
484
- const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
486
+ const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval / 1000); // convert ms to seconds (checkRefreshIntervalSeconds)
485
487
  // MJAPI is a long-running server, so entry-point default is 'full' engine pre-warm;
486
488
  // MJ_STARTUP_MODE / mj.config.cjs startup.mode can override per the shared precedence chain
487
489
  const startupMode = ResolveStartupMode({ configValue: configInfo.startup?.mode, defaultMode: 'full' });
@@ -516,7 +518,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
516
518
  await codegenPool.connect();
517
519
 
518
520
  const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
519
- const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, cacheRefreshInterval);
521
+ const codegenConfig = new SQLServerProviderConfigData(codegenPool, mj_core_schema, cacheRefreshInterval / 1000); // convert ms to seconds (checkRefreshIntervalSeconds)
520
522
  const codegenProvider = new SQLServerDataProvider();
521
523
  await codegenProvider.Config(codegenConfig);
522
524
  RuntimeSchemaManager.Instance.SetDDLProvider(codegenProvider);
@@ -12,6 +12,24 @@ import type { PreRunViewHook, PreSaveHook } from '@memberjunction/core';
12
12
  import { Metadata, type UserInfo, type TenantContext } from '@memberjunction/core';
13
13
  import type { MultiTenancyConfig } from '../config.js';
14
14
  import type { UserPayload } from '../types.js';
15
+ import { CloneUserForSessionContext } from '../auth/sessionUserClone.js';
16
+
17
+ /**
18
+ * Allowlist for tenant identifiers arriving from a request header: GUIDs and
19
+ * conservative identifiers only. Anything else is rejected AT THE BOUNDARY with
20
+ * a 400 — never silently dropped, because a dropped tenant header degrades to an
21
+ * UNSCOPED session, which is the fail-open case validation exists to prevent.
22
+ * Escaping at predicate construction is defense in depth behind this, not a
23
+ * substitute: `x' OR '1'='1` previously survived to the WHERE clause because the
24
+ * downstream keyword blocklist strips string literals before matching and has no
25
+ * rule for OR.
26
+ */
27
+ const TENANT_ID_PATTERN = /^[A-Za-z0-9_.\-]{1,128}$/;
28
+
29
+ /** True when the value is acceptable as a tenant identifier. Exported for tests. */
30
+ export function IsValidTenantId(tenantId: string): boolean {
31
+ return TENANT_ID_PATTERN.test(tenantId);
32
+ }
15
33
 
16
34
  /** Custom tenant context extractor signature */
17
35
  export type TenantContextExtractor = (
@@ -31,7 +49,7 @@ export type TenantContextExtractor = (
31
49
  * has TenantContext set — no deferred pickup via `req['__mj_tenantId']` needed.
32
50
  */
33
51
  export function createTenantMiddleware(config: MultiTenancyConfig): RequestHandler {
34
- return (req, _res, next) => {
52
+ return (req, res, next) => {
35
53
  const userPayload = (req as { userPayload?: UserPayload }).userPayload;
36
54
  if (!userPayload?.userRecord) {
37
55
  // No authenticated user — skip tenant resolution
@@ -40,9 +58,29 @@ export function createTenantMiddleware(config: MultiTenancyConfig): RequestHandl
40
58
  }
41
59
 
42
60
  if (config.contextSource === 'header') {
43
- const tenantId = req.headers[config.tenantHeader.toLowerCase()] as string | undefined;
61
+ const rawHeader = req.headers[config.tenantHeader.toLowerCase()];
62
+ // A repeated header arrives as string[] — ambiguous which value is authoritative, so
63
+ // reject rather than silently pick one (Array.prototype's coercion to a comma-joined
64
+ // string would otherwise sail past IsValidTenantId's regex unnoticed for some inputs).
65
+ if (Array.isArray(rawHeader)) {
66
+ res.status(400).json({ error: `Malformed ${config.tenantHeader} header: expected a single value.` });
67
+ return;
68
+ }
69
+ const tenantId = rawHeader;
44
70
  if (tenantId) {
45
- attachTenantContext(userPayload.userRecord as UserInfo, tenantId, 'header');
71
+ if (!IsValidTenantId(tenantId)) {
72
+ // Reject the REQUEST, never degrade to an unscoped session: a malformed
73
+ // tenant header that silently attaches nothing produces a session with NO
74
+ // tenant filter at all — strictly worse than failing loudly.
75
+ res.status(400).json({ error: 'Invalid tenant identifier' });
76
+ return;
77
+ }
78
+ // Clone before stamping: userRecord is very often the SHARED UserCache
79
+ // instance, and stamping it in place races concurrent requests for the
80
+ // same user (one request's tenant becomes another's, same-instant).
81
+ const sessionUser = CloneUserForSessionContext(userPayload.userRecord as UserInfo);
82
+ attachTenantContext(sessionUser, tenantId, 'header');
83
+ userPayload.userRecord = sessionUser;
46
84
  }
47
85
  }
48
86
  next();
@@ -52,12 +90,22 @@ export function createTenantMiddleware(config: MultiTenancyConfig): RequestHandl
52
90
  /**
53
91
  * Attaches TenantContext to a UserInfo object.
54
92
  * Called from the GraphQL context function after authentication.
93
+ *
94
+ * The caller is responsible for two invariants (createTenantMiddleware shows both):
95
+ * validate untrusted tenant ids with {@link IsValidTenantId} BEFORE calling, and
96
+ * never pass the shared cached UserInfo — clone it first
97
+ * (CloneUserForSessionContext). This function still validates as defense in depth
98
+ * so a future caller from a different source ('linkedEntity', 'custom') cannot
99
+ * reintroduce the injection path.
55
100
  */
56
101
  export function attachTenantContext(
57
102
  user: UserInfo,
58
103
  tenantId: string,
59
104
  source: TenantContext['Source']
60
105
  ): void {
106
+ if (!IsValidTenantId(tenantId)) {
107
+ throw new Error(`Invalid tenant identifier (must match ${TENANT_ID_PATTERN})`);
108
+ }
61
109
  user.TenantContext = { TenantID: tenantId, Source: source };
62
110
  }
63
111
 
@@ -118,9 +166,29 @@ export function createTenantPreRunViewHook(config: MultiTenancyConfig): PreRunVi
118
166
  // Check if this entity should be scoped
119
167
  if (!isEntityScoped(entityName, config)) return params;
120
168
 
121
- // Determine which column holds the tenant ID
169
+ // Determine which column holds the tenant ID. The column is operator config, not
170
+ // user input — but it is interpolated into an identifier position, so it must
171
+ // resolve to a real, non-virtual field on the entity (rejecting typos AND ruling
172
+ // out identifier injection through a compromised config), and it is quoted through
173
+ // the platform-appropriate helper rather than hardcoded T-SQL brackets, which are
174
+ // invalid on PostgreSQL.
122
175
  const tenantColumn = config.entityColumnMappings[entityName] ?? config.defaultTenantColumn;
123
- const tenantFilter = `[${tenantColumn}] = '${contextUser.TenantContext.TenantID}'`;
176
+ const md = new Metadata(); // global-provider-ok: same rationale as isEntityScoped above — hooks carry no per-request provider
177
+ const entityInfo = md.EntityByName(entityName);
178
+ const field = entityInfo?.Fields.find(
179
+ f => !f.IsVirtual && f.Name.trim().toLowerCase() === tenantColumn.trim().toLowerCase()
180
+ );
181
+ if (!field) {
182
+ throw new Error(
183
+ `Multi-tenancy misconfiguration: tenant column '${tenantColumn}' does not resolve to a stored field on entity '${entityName}'`
184
+ );
185
+ }
186
+
187
+ // Escape the value at construction even though the boundary validated it —
188
+ // defense in depth for future callers that attach TenantContext from sources
189
+ // other than the validated header path.
190
+ const safeTenantId = contextUser.TenantContext.TenantID.replace(/'/g, "''");
191
+ const tenantFilter = `${QuoteFilterIdentifier(field.Name)} = '${safeTenantId}'`;
124
192
 
125
193
  // Inject the tenant filter
126
194
  if (params.ExtraFilter && typeof params.ExtraFilter === 'string' && params.ExtraFilter.trim().length > 0) {
@@ -133,6 +201,25 @@ export function createTenantPreRunViewHook(config: MultiTenancyConfig): PreRunVi
133
201
  };
134
202
  }
135
203
 
204
+ /**
205
+ * Quotes an identifier for use in a tenant predicate via the active provider's
206
+ * QuoteIdentifier (DatabaseProviderBase always exposes one server-side, yielding [x]
207
+ * on SQL Server and "x" on PostgreSQL). Throws — never falls back to hardcoded T-SQL
208
+ * bracket quoting — when no provider is available: a silent fallback would emit
209
+ * invalid SQL on PostgreSQL, and "fails to build a query" is a worse failure mode to
210
+ * debug than "refused to build one." A tenant filter must fail closed, loudly, at the
211
+ * point where it can no longer guarantee platform-correct SQL. Exported for tests.
212
+ */
213
+ export function QuoteFilterIdentifier(name: string): string {
214
+ const provider = Metadata.Provider as unknown as { QuoteIdentifier?: (id: string) => string }; // global-provider-ok: hook-level code, single server provider
215
+ if (!provider || typeof provider.QuoteIdentifier !== 'function') {
216
+ throw new Error(
217
+ `[MultiTenancy] Cannot quote identifier '${name}': no database provider available to quote it — refusing to fall back to hardcoded bracket syntax, which is invalid on PostgreSQL.`
218
+ );
219
+ }
220
+ return provider.QuoteIdentifier(name);
221
+ }
222
+
136
223
  /**
137
224
  * Creates a PreSaveHook that validates the tenant column on writes.
138
225
  *
@@ -77,6 +77,47 @@ export async function resolveWidgetGuestRunContext(
77
77
  };
78
78
  }
79
79
 
80
+ /**
81
+ * Returns the identity a realtime session's AI-RUN-ENTITY work (delegated tool dispatch +
82
+ * observability run creation/append/finalize) should execute as: the trusted SYSTEM principal when
83
+ * the caller is a SCOPED anonymous magic-link session, else the caller unchanged (issue #3371).
84
+ *
85
+ * A scoped anonymous session (`IsMagicLinkAnonymous` + `MagicLinkScope.ResourceID`) holds only the
86
+ * narrow relay grants its invite's role carries — deliberately NOT the AI run entities, whose rows
87
+ * leak the rendered system prompt. Unlike {@link resolveWidgetGuestRunContext} no widget instance is
88
+ * required: on the realtime path the agent authority is already server-side (the session config's
89
+ * `targetAgentID`, `CanRun`-gated at session start), so there is no client-supplied agent id to pin.
90
+ *
91
+ * PUBLIC WEB-WIDGET guests are deliberately EXCLUDED (returned unchanged): their seeded role writes
92
+ * run rows under the guest principal, which the `Widget Guest: Own Agent Runs` RLS read filter
93
+ * depends on — elevating them here would silently break that read-side control.
94
+ *
95
+ * Fails CLOSED: when no system user is available the caller is returned unchanged, so the request
96
+ * fails exactly as it would today rather than proceeding unelevated-but-assumed-elevated.
97
+ *
98
+ * Ownership/RLS gates must NEVER use this — they stay on the caller; this only changes who the
99
+ * work RUNS AS after ownership is proven.
100
+ */
101
+ export function ResolveScopedAnonymousRunUser(contextUser: UserInfo): UserInfo {
102
+ const scopeId = contextUser?.MagicLinkScope?.ResourceID;
103
+ if (!contextUser?.IsMagicLinkAnonymous || !scopeId || contextUser.WidgetGuestContext?.WidgetID) {
104
+ return contextUser;
105
+ }
106
+
107
+ const systemUser = UserCache.Instance.GetSystemUser();
108
+ if (!systemUser) {
109
+ LogError(
110
+ '[Realtime] Cannot elevate scoped-anonymous run work: no system user available; ' +
111
+ `falling back to the anonymous caller for scope ${scopeId}.`,
112
+ );
113
+ return contextUser;
114
+ }
115
+
116
+ // Deliberately silent on success — callers include per-utterance/per-usage-delta relays, so a
117
+ // per-call log line would flood a live session's log. The dispatch path logs the elevation once.
118
+ return systemUser;
119
+ }
120
+
80
121
  /**
81
122
  * Builds an elevated {@link UserPayload} that runs subsequent agent work as `elevatedUser` while
82
123
  * preserving the guest's `sessionId` — so progress/streaming PubSub still routes to the guest's
@@ -7,7 +7,7 @@ import { Field, InputType, ObjectType } from "type-graphql";
7
7
  import { KeyValuePairInput } from "../generic/KeyValuePairInput.js";
8
8
  import { AppContext, ProviderInfo } from "../types.js";
9
9
  import { CopyScalarsAndArrays, UUIDsEqual } from "@memberjunction/global";
10
- import { GetReadOnlyProvider } from "../util.js";
10
+ import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
11
11
  import { ResolverBase } from "../generic/ResolverBase.js";
12
12
 
13
13
  /**
@@ -203,8 +203,10 @@ export class ActionResolver extends ResolverBase {
203
203
  // Parse the parameters
204
204
  const params = this.parseActionParameters(input.Params);
205
205
 
206
- // Run the action
207
- const result = await this.executeAction(action, user, params, input.SkipActionLog);
206
+ // Run the action — thread the request-scoped provider so provider-bound
207
+ // actions (e.g. the semantic find-* / Search Entity actions, which call
208
+ // params.Provider.SearchEntity) run against the caller's metadata layer.
209
+ const result = await this.executeAction(action, user, params, ctx.providers, input.SkipActionLog);
208
210
 
209
211
  // Return the result
210
212
  return this.createActionResult(result);
@@ -271,17 +273,23 @@ export class ActionResolver extends ResolverBase {
271
273
  * @private
272
274
  */
273
275
  private async executeAction(
274
- action: any,
275
- user: UserInfo,
276
- params: ActionParam[],
276
+ action: any,
277
+ user: UserInfo,
278
+ params: ActionParam[],
279
+ providers: Array<ProviderInfo>,
277
280
  skipActionLog?: boolean
278
281
  ): Promise<ActionResult> {
282
+ // Resolve the request-scoped provider. Prefer read-write (actions may mutate);
283
+ // fall back to read-only, then to the engine's global-Metadata default when no
284
+ // request providers are present (preserves prior behavior).
285
+ const provider = GetReadWriteProvider(providers, { allowFallbackToReadOnly: true }) ?? undefined;
279
286
  return await ActionEngineServer.Instance.RunAction({
280
287
  Action: action,
281
288
  ContextUser: user,
282
289
  Params: params,
283
290
  SkipActionLog: skipActionLog,
284
- Filters: []
291
+ Filters: [],
292
+ Provider: provider
285
293
  });
286
294
  }
287
295
 
@@ -19,7 +19,7 @@ import {
19
19
  } from '@memberjunction/ai-mcp-client';
20
20
  import { AppContext } from '../types.js';
21
21
  import { ResolverBase } from '../generic/ResolverBase.js';
22
- import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
22
+ import { publishStatusUpdate } from '../generic/PushStatusResolver.js';
23
23
  import { GraphQLJSONObject } from 'graphql-type-json';
24
24
  import { configInfo } from '../config.js';
25
25
 
@@ -583,7 +583,7 @@ export class MCPResolver extends ResolverBase {
583
583
  await manager.initialize(user, { publicUrl });
584
584
 
585
585
  // Publish initial progress
586
- this.publishProgress(pubSub, sessionId, ConnectionID, 'connecting', 'Connecting to MCP server...');
586
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'connecting', 'Connecting to MCP server...');
587
587
 
588
588
  // Connect if not already connected
589
589
  const isConnected = manager.isConnected(ConnectionID);
@@ -595,18 +595,18 @@ export class MCPResolver extends ResolverBase {
595
595
  // Check for OAuth authorization required
596
596
  if (connectError instanceof OAuthAuthorizationRequiredError) {
597
597
  const authError = connectError as OAuthAuthorizationRequiredError;
598
- this.publishProgress(pubSub, sessionId, ConnectionID, 'error',
598
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'error',
599
599
  `OAuth authorization required. Please authorize at: ${authError.authorizationUrl}`);
600
600
  return this.createOAuthRequiredResult(authError.authorizationUrl, authError.stateParameter);
601
601
  }
602
602
  if (connectError instanceof OAuthReauthorizationRequiredError) {
603
603
  const reAuthError = connectError as OAuthReauthorizationRequiredError;
604
- this.publishProgress(pubSub, sessionId, ConnectionID, 'error',
604
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'error',
605
605
  `OAuth re-authorization required: ${reAuthError.reason}`);
606
606
  return this.createOAuthReauthorizationResult(reAuthError.reason, reAuthError.authorizationUrl, reAuthError.stateParameter);
607
607
  }
608
608
  const connectErrorMsg = connectError instanceof Error ? connectError.message : String(connectError);
609
- this.publishProgress(pubSub, sessionId, ConnectionID, 'error', `Connection failed: ${connectErrorMsg}`);
609
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'error', `Connection failed: ${connectErrorMsg}`);
610
610
  return this.createErrorResult(`Failed to connect to MCP server: ${connectErrorMsg}`);
611
611
  }
612
612
  }
@@ -617,7 +617,7 @@ export class MCPResolver extends ResolverBase {
617
617
  const connectionName = connectionInfo?.connectionName || 'Unknown Connection';
618
618
 
619
619
  // Publish listing progress
620
- this.publishProgress(pubSub, sessionId, ConnectionID, 'listing', 'Discovering tools from MCP server...');
620
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'listing', 'Discovering tools from MCP server...');
621
621
 
622
622
  // Perform the sync with event listening for granular progress
623
623
  LogStatus(`MCPResolver: Starting tool sync for connection ${ConnectionID}`);
@@ -626,7 +626,7 @@ export class MCPResolver extends ResolverBase {
626
626
  const eventHandler = (event: { type: string; data?: Record<string, unknown> }) => {
627
627
  if (event.type === 'toolsSynced') {
628
628
  const data = event.data as { added: number; updated: number; deprecated: number; total: number } | undefined;
629
- this.publishProgress(pubSub, sessionId, ConnectionID, 'complete', 'Tool sync complete', {
629
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'complete', 'Tool sync complete', {
630
630
  added: data?.added || 0,
631
631
  updated: data?.updated || 0,
632
632
  deprecated: data?.deprecated || 0,
@@ -637,7 +637,7 @@ export class MCPResolver extends ResolverBase {
637
637
  manager.addEventListener('toolsSynced', eventHandler);
638
638
 
639
639
  // Publish syncing progress
640
- this.publishProgress(pubSub, sessionId, ConnectionID, 'syncing', 'Synchronizing tools to database...');
640
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'syncing', 'Synchronizing tools to database...');
641
641
 
642
642
  // Perform the sync
643
643
  const syncResult: MCPSyncToolsResult = await manager.syncTools(ConnectionID, { contextUser: user });
@@ -646,12 +646,12 @@ export class MCPResolver extends ResolverBase {
646
646
  manager.removeEventListener('toolsSynced', eventHandler);
647
647
 
648
648
  if (!syncResult.success) {
649
- this.publishProgress(pubSub, sessionId, ConnectionID, 'error', `Sync failed: ${syncResult.error}`);
649
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'error', `Sync failed: ${syncResult.error}`);
650
650
  return this.createErrorResult(syncResult.error || 'Tool sync failed');
651
651
  }
652
652
 
653
653
  // Publish final completion
654
- this.publishProgress(pubSub, sessionId, ConnectionID, 'complete',
654
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'complete',
655
655
  `Sync complete: ${syncResult.added} added, ${syncResult.updated} updated, ${syncResult.deprecated} deprecated`,
656
656
  {
657
657
  added: syncResult.added,
@@ -675,7 +675,7 @@ export class MCPResolver extends ResolverBase {
675
675
  } catch (error) {
676
676
  const errorMsg = error instanceof Error ? error.message : String(error);
677
677
  LogError(`MCPResolver: Error syncing tools for ${ConnectionID}: ${errorMsg}`);
678
- this.publishProgress(pubSub, sessionId, ConnectionID, 'error', `Error: ${errorMsg}`);
678
+ this.publishProgress(pubSub, sessionId, user.ID, ConnectionID, 'error', `Error: ${errorMsg}`);
679
679
  return this.createErrorResult(errorMsg);
680
680
  }
681
681
  }
@@ -1345,6 +1345,7 @@ export class MCPResolver extends ResolverBase {
1345
1345
  private publishProgress(
1346
1346
  pubSub: PubSubEngine,
1347
1347
  sessionId: string,
1348
+ ownerUserId: string,
1348
1349
  connectionId: string,
1349
1350
  phase: SyncProgressMessage['phase'],
1350
1351
  message: string,
@@ -1360,10 +1361,7 @@ export class MCPResolver extends ResolverBase {
1360
1361
  result
1361
1362
  };
1362
1363
 
1363
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
1364
- message: JSON.stringify(progressMessage),
1365
- sessionId
1366
- });
1364
+ publishStatusUpdate(pubSub, { sessionId, ownerUserId, message: JSON.stringify(progressMessage) });
1367
1365
  }
1368
1366
 
1369
1367
  /**