@memberjunction/server 5.51.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 (109) 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 +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/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/ReportResolver.d.ts.map +1 -1
  60. package/dist/resolvers/ReportResolver.js +11 -3
  61. package/dist/resolvers/ReportResolver.js.map +1 -1
  62. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  63. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  64. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  65. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  66. package/dist/resolvers/RunTestResolver.js +90 -112
  67. package/dist/resolvers/RunTestResolver.js.map +1 -1
  68. package/dist/resolvers/SearchKnowledgeResolver.js +3 -3
  69. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  70. package/dist/resolvers/SearchKnowledgeStreamResolver.js +2 -2
  71. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  72. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  73. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  74. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  75. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  76. package/dist/services/TaskOrchestrator.js +17 -15
  77. package/dist/services/TaskOrchestrator.js.map +1 -1
  78. package/package.json +100 -100
  79. package/src/__tests__/RealtimeBridgeResolver.test.ts +6 -0
  80. package/src/__tests__/SessionManager.test.ts +44 -0
  81. package/src/__tests__/multiTenancy.security.test.ts +254 -22
  82. package/src/__tests__/multiTenancy.test.ts +22 -0
  83. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  84. package/src/__tests__/resolverBase.rls.test.ts +4 -0
  85. package/src/__tests__/search-knowledge-tags.test.ts +74 -6
  86. package/src/__tests__/sessionUserClone.test.ts +161 -0
  87. package/src/agentSessions/SessionManager.ts +26 -2
  88. package/src/auth/actingContextResolver.ts +56 -0
  89. package/src/auth/index.ts +1 -0
  90. package/src/auth/sessionUserClone.ts +50 -0
  91. package/src/context.ts +48 -6
  92. package/src/generated/generated.ts +276 -3
  93. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  94. package/src/generic/PushStatusResolver.ts +79 -4
  95. package/src/generic/ResolverBase.ts +33 -13
  96. package/src/index.ts +4 -2
  97. package/src/multiTenancy/index.ts +92 -5
  98. package/src/resolvers/ActionResolver.ts +15 -7
  99. package/src/resolvers/MCPResolver.ts +13 -15
  100. package/src/resolvers/RealtimeClientSessionResolver.ts +15 -13
  101. package/src/resolvers/RemoteBrowserActionResolver.ts +19 -25
  102. package/src/resolvers/ReportResolver.ts +11 -3
  103. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  104. package/src/resolvers/RunTestResolver.ts +90 -112
  105. package/src/resolvers/SearchKnowledgeResolver.ts +3 -3
  106. package/src/resolvers/SearchKnowledgeStreamResolver.ts +2 -2
  107. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  108. package/src/resolvers/__tests__/ReportResolver.test.ts +279 -0
  109. package/src/services/TaskOrchestrator.ts +17 -17
@@ -0,0 +1,161 @@
1
+ /**
2
+ * CloneUserForSessionContext tests.
3
+ *
4
+ * FAIL-OPEN GUARD: the clone must carry EVERY restricting per-session context
5
+ * (TenantContext, MagicLinkScope, APIKeyActingContext, APIKeyRowFilters, ...) —
6
+ * a lossy clone silently DROPS a restriction, which fails open. These tests also
7
+ * pin the mechanism note in sessionUserClone.ts: if a refactor converts UserInfo
8
+ * backing fields to #private fields or flips class-field emit semantics, the
9
+ * spread-based clone breaks and these tests catch it.
10
+ *
11
+ * The other direction matters too: stamping the clone must never write through
12
+ * to the source, because the source is very often the SHARED process-wide
13
+ * UserCache instance (same-instant cross-request aliasing).
14
+ */
15
+ import { describe, it, expect } from 'vitest';
16
+ import { UserInfo } from '@memberjunction/core';
17
+ import type { APIKeyRowFilterBinding } from '@memberjunction/core';
18
+ import { CloneUserForSessionContext } from '../auth/sessionUserClone.js';
19
+
20
+ const USER_ID = 'U0000000-0000-0000-0000-000000000001';
21
+
22
+ function buildSourceUser(): UserInfo {
23
+ const user = new UserInfo(null, {
24
+ ID: USER_ID,
25
+ Name: 'Test User',
26
+ Email: 'test@example.com',
27
+ IsActive: true,
28
+ Type: 'User',
29
+ UserRoles: [
30
+ { UserID: USER_ID, RoleID: 'R0000000-0000-0000-0000-000000000001', Role: 'Developer' },
31
+ { UserID: USER_ID, RoleID: 'R0000000-0000-0000-0000-000000000002', Role: 'Integration' },
32
+ ],
33
+ });
34
+ user.TenantContext = { TenantID: 'tenant-1', Source: 'header' };
35
+ user.MagicLinkScope = { ResourceID: 'res-1', ResourceType: 'Reports' };
36
+ user.APIKeyActingContext = {
37
+ ActingOrganizationID: 'ORG00000-0000-0000-0000-000000000001',
38
+ ActingCompanyIDs: ['c1', 'c2'],
39
+ };
40
+ user.APIKeyRowFilters = [
41
+ { EntityID: 'E0000000-0000-0000-0000-000000000001', PermissionType: 'Read', FilterID: 'F0000000-0000-0000-0000-000000000001' },
42
+ ];
43
+ user.ReturningVisitorContext = { VisitorKey: 'vk-1' };
44
+ user.WidgetGuestContext = { WidgetID: 'W0000000-0000-0000-0000-000000000001' };
45
+ user.IsMagicLinkAnonymous = true;
46
+ return user;
47
+ }
48
+
49
+ describe('CloneUserForSessionContext', () => {
50
+ it('returns a distinct UserInfo instance preserving identity fields', () => {
51
+ const source = buildSourceUser();
52
+ const clone = CloneUserForSessionContext(source);
53
+
54
+ expect(clone).not.toBe(source);
55
+ expect(clone).toBeInstanceOf(UserInfo);
56
+ expect(clone.ID).toBe(USER_ID);
57
+ expect(clone.Email).toBe('test@example.com');
58
+ expect(clone.Name).toBe('Test User');
59
+ expect(clone.IsActive).toBe(true);
60
+ });
61
+
62
+ it('preserves UserRoles (same length, same role IDs)', () => {
63
+ const source = buildSourceUser();
64
+ const clone = CloneUserForSessionContext(source);
65
+
66
+ expect(clone.UserRoles).toHaveLength(source.UserRoles.length);
67
+ expect(clone.UserRoles.map(r => r.RoleID)).toEqual(source.UserRoles.map(r => r.RoleID));
68
+ });
69
+
70
+ it('preserves TenantContext — dropping it would unscope the tenant [fail-open]', () => {
71
+ const source = buildSourceUser();
72
+ const clone = CloneUserForSessionContext(source);
73
+
74
+ expect(clone.TenantContext).toEqual({ TenantID: 'tenant-1', Source: 'header' });
75
+ });
76
+
77
+ it('preserves MagicLinkScope — dropping it would widen a resource-pinned share [fail-open]', () => {
78
+ const source = buildSourceUser();
79
+ const clone = CloneUserForSessionContext(source);
80
+
81
+ expect(clone.MagicLinkScope).toEqual({ ResourceID: 'res-1', ResourceType: 'Reports' });
82
+ });
83
+
84
+ it('preserves APIKeyActingContext — dropping it would collapse acting-scoped filters [fail-open]', () => {
85
+ const source = buildSourceUser();
86
+ const clone = CloneUserForSessionContext(source);
87
+
88
+ expect(clone.APIKeyActingContext).toEqual({
89
+ ActingOrganizationID: 'ORG00000-0000-0000-0000-000000000001',
90
+ ActingCompanyIDs: ['c1', 'c2'],
91
+ });
92
+ });
93
+
94
+ it('preserves APIKeyRowFilters — dropping them would lift the key ceiling entirely [fail-open]', () => {
95
+ const source = buildSourceUser();
96
+ const clone = CloneUserForSessionContext(source);
97
+
98
+ expect(clone.APIKeyRowFilters).toEqual(source.APIKeyRowFilters);
99
+ expect(clone.APIKeyRowFilters).toHaveLength(1);
100
+ });
101
+
102
+ it('preserves ReturningVisitorContext, WidgetGuestContext, and IsMagicLinkAnonymous', () => {
103
+ const source = buildSourceUser();
104
+ const clone = CloneUserForSessionContext(source);
105
+
106
+ expect(clone.ReturningVisitorContext).toEqual({ VisitorKey: 'vk-1' });
107
+ expect(clone.WidgetGuestContext).toEqual({ WidgetID: 'W0000000-0000-0000-0000-000000000001' });
108
+ expect(clone.IsMagicLinkAnonymous).toBe(true);
109
+ });
110
+
111
+ it('stamping new contexts on the clone does NOT write through to the source', () => {
112
+ const source = buildSourceUser();
113
+ const clone = CloneUserForSessionContext(source);
114
+
115
+ const newBindings: APIKeyRowFilterBinding[] = [
116
+ { EntityID: 'E0000000-0000-0000-0000-000000000099', PermissionType: 'Update', FilterID: 'F0000000-0000-0000-0000-000000000099' },
117
+ ];
118
+ clone.TenantContext = { TenantID: 'tenant-OTHER', Source: 'custom' };
119
+ clone.MagicLinkScope = { ResourceID: 'res-OTHER', ResourceType: 'Dashboards' };
120
+ clone.APIKeyActingContext = { ActingOrganizationID: 'ORG-OTHER' };
121
+ clone.APIKeyRowFilters = newBindings;
122
+ clone.IsMagicLinkAnonymous = false;
123
+
124
+ // The source (potentially the shared UserCache instance) must be untouched
125
+ expect(source.TenantContext).toEqual({ TenantID: 'tenant-1', Source: 'header' });
126
+ expect(source.MagicLinkScope).toEqual({ ResourceID: 'res-1', ResourceType: 'Reports' });
127
+ expect(source.APIKeyActingContext?.ActingOrganizationID).toBe('ORG00000-0000-0000-0000-000000000001');
128
+ expect(source.APIKeyRowFilters).toHaveLength(1);
129
+ expect(source.APIKeyRowFilters?.[0].FilterID).toBe('F0000000-0000-0000-0000-000000000001');
130
+ expect(source.IsMagicLinkAnonymous).toBe(true);
131
+ });
132
+
133
+ it('clearing contexts on the clone does NOT clear them on the source', () => {
134
+ const source = buildSourceUser();
135
+ const clone = CloneUserForSessionContext(source);
136
+
137
+ clone.TenantContext = undefined;
138
+ clone.APIKeyActingContext = undefined;
139
+ clone.APIKeyRowFilters = undefined;
140
+
141
+ expect(source.TenantContext).toBeDefined();
142
+ expect(source.APIKeyActingContext).toBeDefined();
143
+ expect(source.APIKeyRowFilters).toBeDefined();
144
+ });
145
+
146
+ it('does not invent contexts on a clone of a context-free user', () => {
147
+ const source = new UserInfo(null, {
148
+ ID: USER_ID,
149
+ Email: 'plain@example.com',
150
+ UserRoles: [],
151
+ });
152
+ const clone = CloneUserForSessionContext(source);
153
+
154
+ expect(clone.TenantContext).toBeUndefined();
155
+ expect(clone.MagicLinkScope).toBeUndefined();
156
+ expect(clone.APIKeyActingContext).toBeUndefined();
157
+ expect(clone.APIKeyRowFilters).toBeUndefined();
158
+ expect(clone.IsMagicLinkAnonymous).toBe(false);
159
+ expect(clone.UserRoles).toEqual([]);
160
+ });
161
+ });
@@ -79,7 +79,8 @@ const HEARTBEAT_MIN_WRITE_INTERVAL_MS = 3_000;
79
79
  * correct identity/provider and never pin the process-global provider.
80
80
  *
81
81
  * The only in-memory state it holds is a small per-session last-heartbeat-write timestamp map used
82
- * purely to coalesce {@link Heartbeat} writes; it carries no user or provider state across calls.
82
+ * purely to coalesce {@link Heartbeat} writes, plus the injected {@link RealtimeClientSessionService}
83
+ * used by {@link finalizeObservabilityRuns}; it carries no user or provider state across calls.
83
84
  *
84
85
  * Responsibilities:
85
86
  * - **Create** — authorize (`CanRun`), optionally mint a Conversation, persist an `Active` session.
@@ -96,6 +97,29 @@ export class SessionManager {
96
97
  /** session ID (lowercased) → epoch ms of its last persisted `LastActiveAt` write. */
97
98
  private heartbeatLastWrite = new Map<string, number>();
98
99
 
100
+ /**
101
+ * The {@link RealtimeClientSessionService} used to finalize a closing session's co-agent
102
+ * observability runs (see {@link finalizeObservabilityRuns}). Defaults to a fresh instance for
103
+ * callers that don't own a shared one (e.g. `SessionJanitor`, telephony services) — but a caller
104
+ * that itself created the co-agent's `AIPromptRun` writes through its OWN `RealtimeClientSessionService`
105
+ * (currently only `RealtimeClientSessionResolver`) MUST pass that same instance here. Otherwise
106
+ * finalize's internal per-run write-chain cleanup runs against a different object than the one that
107
+ * accumulated the chain, silently no-op'ing on the live instance (see the constructor param doc).
108
+ */
109
+ private readonly realtimeClientSessionService: RealtimeClientSessionService;
110
+
111
+ /**
112
+ * @param realtimeClientSessionService The shared {@link RealtimeClientSessionService} instance to
113
+ * finalize observability runs through. Pass the SAME instance your resolver/service used to create
114
+ * and write to the session's co-agent `AIAgentRun`/`AIPromptRun` (e.g.
115
+ * `RealtimeClientSessionResolver`'s own field) so `CloseSession` can correctly clean up that
116
+ * instance's per-run state. Omit only when this `SessionManager` never closes a client-direct
117
+ * voice session it also originated observability writes for.
118
+ */
119
+ constructor(realtimeClientSessionService?: RealtimeClientSessionService) {
120
+ this.realtimeClientSessionService = realtimeClientSessionService ?? new RealtimeClientSessionService();
121
+ }
122
+
99
123
  /**
100
124
  * Authorize and create a new session. Flow:
101
125
  * 1. `CanRun` check on the agent — denied → {@link SessionAuthorizationError}, no row written.
@@ -235,7 +259,7 @@ export class SessionManager {
235
259
  // for a scoped anonymous session, so an owner-initiated (or error) close must finalize
236
260
  // under it too — the caller's role holds no grants on the AI run entities. Janitor and
237
261
  // shutdown sweeps already close as the system user and pass through unchanged.
238
- await new RealtimeClientSessionService().FinalizeCoAgentRun(
262
+ await this.realtimeClientSessionService.FinalizeCoAgentRun(
239
263
  config.coAgentRunID ?? null,
240
264
  config.promptRunID ?? null,
241
265
  ResolveScopedAnonymousRunUser(contextUser),
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Deployment extension point for the API-key acting context (plan §5.8).
3
+ *
4
+ * MJ defines the CARRIER and the CONTRACT for `{{Acting*}}` row-filter tokens
5
+ * (`UserInfo.APIKeyActingContext`); the VALUES are deployment-specific — a
6
+ * verified session token, a server-side session lookup, a trusted upstream
7
+ * assertion. A deployment registers a resolver at startup and `context.ts`
8
+ * invokes it after API-key validation, stamping the result onto the CLONED
9
+ * per-request UserInfo.
10
+ *
11
+ * TRUST BOUNDARY (load-bearing): the resolver runs server-side and its return
12
+ * value flows straight into row-filter SQL token substitution. Resolver
13
+ * implementations MUST derive every value server-side — never from a
14
+ * client-supplied header, argument, or GraphQL variable. A client-settable
15
+ * acting context is a total bypass of API-key row filtering. Validation of the
16
+ * value SHAPES (GUID / bounded identifier) happens downstream in
17
+ * `APIKeyEngine.Authorize()` and fails closed, but shape validation cannot
18
+ * detect a forged-but-well-formed value — provenance is the resolver's job.
19
+ */
20
+ import type { IncomingMessage } from 'http';
21
+ import type { APIKeyActingContext, UserInfo } from '@memberjunction/core';
22
+
23
+ /**
24
+ * Resolves the acting context for one API-key request. Return `undefined` when
25
+ * the request carries no acting identity — filters requiring `{{Acting*}}`
26
+ * tokens then fail closed (match nothing / deny) by design.
27
+ *
28
+ * @param req - The incoming HTTP request (undefined on transports without one, e.g. WebSocket connects)
29
+ * @param user - The per-request session UserInfo (already cloned; safe to read, do not mutate)
30
+ * @param apiKeyId - The validated API key's ID
31
+ */
32
+ export type APIKeyActingContextResolver = (
33
+ req: IncomingMessage | undefined,
34
+ user: UserInfo,
35
+ apiKeyId: string
36
+ ) => Promise<APIKeyActingContext | undefined> | APIKeyActingContext | undefined;
37
+
38
+ let registeredResolver: APIKeyActingContextResolver | undefined;
39
+
40
+ /**
41
+ * Registers the deployment's acting-context resolver. Call once at server
42
+ * startup, before requests are served. Registering again replaces the previous
43
+ * resolver (last write wins); pass `undefined` to clear.
44
+ */
45
+ export function RegisterAPIKeyActingContextResolver(fn: APIKeyActingContextResolver | undefined): void {
46
+ registeredResolver = fn;
47
+ }
48
+
49
+ /**
50
+ * Returns the registered acting-context resolver, or `undefined` when the
51
+ * deployment has not registered one (the default — API-key sessions then carry
52
+ * no acting context, and any filter requiring `{{Acting*}}` tokens fails closed).
53
+ */
54
+ export function GetAPIKeyActingContextResolver(): APIKeyActingContextResolver | undefined {
55
+ return registeredResolver;
56
+ }
package/src/auth/index.ts CHANGED
@@ -10,6 +10,7 @@ import { AuthProviderFactory } from '@memberjunction/auth-providers';
10
10
  import { initializeAuthProviders } from './initializeProviders.js';
11
11
 
12
12
  export * from './APIKeyScopeAuth.js';
13
+ export * from './actingContextResolver.js';
13
14
 
14
15
  // This is a hard-coded forever constant due to internal migrations
15
16
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Clone-before-stamp helper for per-session UserInfo state.
3
+ *
4
+ * The resolved userRecord for a request is very often the SHARED, process-wide
5
+ * UserCache instance. Any per-session context stamped onto it in place —
6
+ * TenantContext, MagicLinkScope, APIKeyActingContext — leaks to every other
7
+ * concurrent session of the same user THE INSTANT it is written; this is
8
+ * same-moment cross-request aliasing, not a cache-staleness problem. The
9
+ * magic-link path has always cloned for exactly this reason (see
10
+ * buildMagicLinkSessionUser in context.ts); this helper makes that rule a
11
+ * single shared primitive so every per-session stamp goes through the same
12
+ * clone.
13
+ *
14
+ * MECHANISM NOTE (load-bearing, tested): the spread `{ ...user }` captures the
15
+ * `_`-prefixed backing fields (enumerable own properties under
16
+ * useDefineForClassFields:false) but NOT the public getters; UserInfo's
17
+ * constructor then copies via BaseInfo.copyInitData, which assigns only keys
18
+ * that already exist on the fresh instance (hasOwnProperty guard). Roles need
19
+ * the explicit UserRoles hand-off because the constructor reads
20
+ * `initData.UserRoles || initData._UserRoles`. If a refactor converts backing
21
+ * fields to #private fields or a tsconfig change flips class-field semantics,
22
+ * this clone silently drops context — the sessionUserClone unit tests exist to
23
+ * catch precisely that, because dropping a RESTRICTING context fails open.
24
+ */
25
+ import { Metadata, UserInfo } from '@memberjunction/core';
26
+
27
+ /**
28
+ * Returns a fresh UserInfo carrying the same identity, fields, roles, and all
29
+ * per-session contexts as the input. Safe to stamp additional per-session
30
+ * state on the clone; never stamp the input itself.
31
+ */
32
+ export function CloneUserForSessionContext(user: UserInfo): UserInfo {
33
+ const md = Metadata.Provider; // global-provider-ok: server-side session construction under the server's single default provider, same as buildMagicLinkSessionUser
34
+ const clone = new UserInfo(md, {
35
+ ...user,
36
+ _UserRoles: undefined,
37
+ UserRoles: user.UserRoles,
38
+ });
39
+ // Object-valued per-session contexts ride through the spread as backing
40
+ // fields; re-stamp them explicitly anyway so the clone's correctness does
41
+ // not depend on field-emit semantics staying frozen forever.
42
+ clone.TenantContext = user.TenantContext;
43
+ clone.MagicLinkScope = user.MagicLinkScope;
44
+ clone.ReturningVisitorContext = user.ReturningVisitorContext;
45
+ clone.WidgetGuestContext = user.WidgetGuestContext;
46
+ clone.IsMagicLinkAnonymous = user.IsMagicLinkAnonymous;
47
+ clone.APIKeyActingContext = user.APIKeyActingContext;
48
+ clone.APIKeyRowFilters = user.APIKeyRowFilters;
49
+ return clone;
50
+ }
package/src/context.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { IncomingMessage } from 'http';
2
+ import { createHash, timingSafeEqual } from 'crypto';
2
3
  import { default as jwt } from 'jsonwebtoken';
3
4
  import 'reflect-metadata';
4
5
  import { Subject, firstValueFrom } from 'rxjs';
5
6
  import { AuthenticationError, AuthorizationError } from 'type-graphql';
6
7
  import sql from 'mssql';
7
8
  import { getSigningKeys, getSystemUser, getValidationOptions, verifyUserRecord, extractUserInfoFromPayload } from './auth/index.js';
9
+ import { CloneUserForSessionContext } from './auth/sessionUserClone.js';
10
+ import { GetAPIKeyActingContextResolver } from './auth/actingContextResolver.js';
8
11
  import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
9
12
  import { authCache } from './cache.js';
10
13
  import { userEmailMap, apiKey, mj_core_schema } from './config.js';
@@ -319,7 +322,13 @@ const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayloa
319
322
  return;
320
323
  }
321
324
 
322
- const verifyOptions: jwt.VerifyOptions = {};
325
+ const verifyOptions: jwt.VerifyOptions = {
326
+ // SECURITY: explicitly pin the accepted signature algorithms to the asymmetric family.
327
+ // The signing key here comes from the issuer's JWKS (an RSA/EC public key), so without an
328
+ // explicit allow-list a future key-format change or library regression could reintroduce
329
+ // classic `alg=none` / RS256->HS256 confusion attacks. Pinning fails such tokens closed.
330
+ algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256'],
331
+ };
323
332
  if (Array.isArray(options.audience)) {
324
333
  verifyOptions.audience = options.audience as [string, ...string[]];
325
334
  } else {
@@ -367,7 +376,8 @@ export const getUserPayload = async (
367
376
  requestDomain?: string,
368
377
  systemApiKey?: string,
369
378
  userApiKey?: string,
370
- requestContext?: RequestContext
379
+ requestContext?: RequestContext,
380
+ req?: IncomingMessage
371
381
  ): Promise<UserPayload> => {
372
382
  try {
373
383
  const readOnlyDataSource = GetReadOnlyDataSource(dataSources, { allowFallbackToReadWrite: true });
@@ -394,7 +404,7 @@ export const getUserPayload = async (
394
404
  systemUser
395
405
  );
396
406
 
397
- if (validationResult.IsValid && validationResult.User) {
407
+ if (validationResult.IsValid && validationResult.User && validationResult.APIKeyId) {
398
408
  // Get the user from UserCache to ensure UserRoles is properly populated
399
409
  // The validationResult.User from APIKeyEngine doesn't include UserRoles
400
410
  const cachedUser = UserCache.Instance.Users.find(
@@ -402,7 +412,32 @@ export const getUserPayload = async (
402
412
  );
403
413
 
404
414
  // Use cached user if available, otherwise fall back to the validation result
405
- const userRecord = cachedUser || validationResult.User;
415
+ const resolvedUser = cachedUser || validationResult.User;
416
+
417
+ // ALWAYS clone before stamping per-session state: the resolved user is
418
+ // very often the SHARED UserCache instance, and stamping it in place
419
+ // leaks one session's row-filter bindings / acting context to every
420
+ // concurrent session of the same user (same pattern as
421
+ // buildMagicLinkSessionUser — see sessionUserClone.ts).
422
+ const userRecord = CloneUserForSessionContext(resolvedUser);
423
+
424
+ // Row-filter bindings for this key, rebuilt PER REQUEST from the
425
+ // APIKeysEngineBase cache (staleness envelope = scopeCacheTTLMs, same
426
+ // as scope enforcement). Consumed by
427
+ // EntityInfo.GetEffectiveRowFilterWhereClause at every RLS enforcement
428
+ // point — outside the role-RLS exemption. Throws (fail closed) if the
429
+ // engine's base cache has not loaded yet.
430
+ userRecord.APIKeyRowFilters = apiKeyEngine.GetRowFilterBindingsForKey(validationResult.APIKeyId);
431
+
432
+ // Acting context: values are deployment-supplied via the registered
433
+ // resolver (plan §5.8) — server-derived only, never client input. No
434
+ // resolver (the default) means no acting context, and any filter
435
+ // requiring {{Acting*}} tokens fails closed.
436
+ const actingResolver = GetAPIKeyActingContextResolver();
437
+ if (actingResolver) {
438
+ userRecord.APIKeyActingContext =
439
+ (await actingResolver(req, userRecord, validationResult.APIKeyId)) ?? undefined;
440
+ }
406
441
 
407
442
  return {
408
443
  userRecord,
@@ -420,7 +455,13 @@ export const getUserPayload = async (
420
455
  // Check for system API key (x-mj-api-key header)
421
456
  // This authenticates as the system user for system-level operations
422
457
  if (systemApiKey && systemApiKey != String(undefined)) {
423
- if (systemApiKey === apiKey) {
458
+ // SECURITY: compare the superadmin system API key in constant time. A plain `===`
459
+ // short-circuits on the first differing byte, leaking a timing side-channel that could
460
+ // be used to recover the key byte-by-byte. Hash both sides to fixed-length digests so
461
+ // timingSafeEqual never throws on length mismatch and the comparison is length-agnostic.
462
+ const systemKeyDigest = createHash('sha256').update(String(systemApiKey)).digest();
463
+ const providedKeyDigest = createHash('sha256').update(String(apiKey)).digest();
464
+ if (timingSafeEqual(systemKeyDigest, providedKeyDigest)) {
424
465
  const systemUser = await getSystemUser(readOnlyDataSource);
425
466
  return {
426
467
  userRecord: systemUser,
@@ -622,7 +663,8 @@ export function createUnifiedAuthMiddleware(
622
663
  requestDomain,
623
664
  systemApiKey,
624
665
  userApiKey,
625
- requestContext
666
+ requestContext,
667
+ req
626
668
  );
627
669
 
628
670
  if (!userPayload) {