@memberjunction/server 5.50.0 → 5.51.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 (49) hide show
  1. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionManager.js +6 -1
  3. package/dist/agentSessions/SessionManager.js.map +1 -1
  4. package/dist/auth/index.d.ts +8 -0
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +53 -13
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/config.d.ts +24 -0
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +12 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +19 -2
  14. package/dist/context.js.map +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/realtimeWidget/widgetGuestElevation.d.ts +22 -0
  19. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  20. package/dist/realtimeWidget/widgetGuestElevation.js +36 -0
  21. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  22. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +21 -0
  23. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  24. package/dist/resolvers/RealtimeClientSessionResolver.js +75 -16
  25. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  26. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  27. package/dist/resolvers/ReportResolver.js +2 -1
  28. package/dist/resolvers/ReportResolver.js.map +1 -1
  29. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  30. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  31. package/dist/rest/OAuthCallbackHandler.js +129 -43
  32. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  33. package/package.json +89 -89
  34. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  35. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  36. package/src/__tests__/RealtimeClientSessionResolver.test.ts +420 -0
  37. package/src/__tests__/SessionManager.test.ts +62 -0
  38. package/src/__tests__/newUsers.test.ts +729 -0
  39. package/src/__tests__/widgetGuestElevation.test.ts +70 -2
  40. package/src/agentSessions/SessionManager.ts +6 -1
  41. package/src/auth/index.ts +56 -15
  42. package/src/config.ts +12 -0
  43. package/src/context.ts +19 -2
  44. package/src/index.ts +3 -1
  45. package/src/realtimeWidget/widgetGuestElevation.ts +41 -0
  46. package/src/resolvers/RealtimeClientSessionResolver.ts +84 -16
  47. package/src/resolvers/ReportResolver.ts +2 -1
  48. package/src/resolvers/__tests__/ReportResolver.test.ts +232 -0
  49. package/src/rest/OAuthCallbackHandler.ts +149 -41
@@ -16,7 +16,10 @@ interface PageRenderer {
16
16
  }
17
17
 
18
18
  function makeRenderer(): PageRenderer {
19
- const handler = new OAuthCallbackHandler({ publicUrl: 'https://example.test' });
19
+ const handler = new OAuthCallbackHandler({
20
+ publicUrl: 'https://example.test',
21
+ allowedFrontendOrigins: ['*'],
22
+ });
20
23
  return handler as unknown as PageRenderer;
21
24
  }
22
25
 
@@ -81,6 +81,16 @@ vi.mock('@memberjunction/aiengine', () => ({
81
81
  const prepareClientSessionMock = vi.fn();
82
82
  const executeRelayedToolMock = vi.fn();
83
83
  const cancelInFlightMock = vi.fn((_agentSessionID: string, _callID?: string): number => 0);
84
+ // Recording-store helpers (module-level exports of ai-agents) — mocked so the recording mutations'
85
+ // identity threading is observable without touching MJStorage. vi.hoisted because the factory below
86
+ // references them as PLAIN properties (evaluated when the factory runs, unlike the deferred
87
+ // class-property/getter references above).
88
+ const { resolveStorageMock, storeRecordingMock, writeSegmentMock, deleteSegmentsMock } = vi.hoisted(() => ({
89
+ resolveStorageMock: vi.fn(async (): Promise<string | null> => 'storage-acct-1'),
90
+ storeRecordingMock: vi.fn(async (): Promise<string | null> => 'file-1'),
91
+ writeSegmentMock: vi.fn(async (): Promise<boolean> => true),
92
+ deleteSegmentsMock: vi.fn(async (): Promise<number> => 0),
93
+ }));
84
94
  const onChannelStateSaveMock = vi.fn(
85
95
  async (_agentSessionID: string, _channelName: string, stateJson: string): Promise<string> => stateJson,
86
96
  );
@@ -110,6 +120,10 @@ vi.mock('@memberjunction/ai-agents', async (importOriginal) => {
110
120
  return { OnChannelStateSave: onChannelStateSaveMock };
111
121
  },
112
122
  },
123
+ resolveRecordingStorageAccountID: resolveStorageMock,
124
+ storeRealtimeRecording: storeRecordingMock,
125
+ writeRealtimeRecordingSegment: writeSegmentMock,
126
+ deleteRealtimeRecordingSegments: deleteSegmentsMock,
113
127
  };
114
128
  });
115
129
 
@@ -131,8 +145,24 @@ vi.mock('../util.js', () => ({
131
145
  GetReadWriteProvider: () => currentProvider,
132
146
  }));
133
147
 
148
+ // --- Partial mock: control ONLY UserCache.GetSystemUser (the scoped-anonymous elevation seam,
149
+ // issue #3371); everything else in the data provider stays real ---
150
+ const getSystemUserMock = vi.fn<[], UserInfo | undefined>();
151
+ vi.mock('@memberjunction/sqlserver-dataprovider', async (importOriginal) => {
152
+ const actual = await importOriginal<Record<string, unknown>>();
153
+ return {
154
+ ...actual,
155
+ UserCache: {
156
+ get Instance() {
157
+ return { GetSystemUser: getSystemUserMock };
158
+ },
159
+ },
160
+ };
161
+ });
162
+
134
163
  import { RealtimeClientSessionResolver } from '../resolvers/RealtimeClientSessionResolver.js';
135
164
  import type { AppContext } from '../types.js';
165
+ import type { UserInfo } from '@memberjunction/core';
136
166
 
137
167
  const USER = { ID: 'user-1', Email: 'tester@example.com' };
138
168
 
@@ -221,6 +251,16 @@ beforeEach(() => {
221
251
  createSessionMock.mockReset();
222
252
  closeSessionMock.mockClear();
223
253
  heartbeatMock.mockClear();
254
+ getSystemUserMock.mockReset();
255
+ getSystemUserMock.mockReturnValue(undefined);
256
+ resolveStorageMock.mockReset();
257
+ resolveStorageMock.mockResolvedValue('storage-acct-1');
258
+ storeRecordingMock.mockReset();
259
+ storeRecordingMock.mockResolvedValue('file-1');
260
+ writeSegmentMock.mockReset();
261
+ writeSegmentMock.mockResolvedValue(true);
262
+ deleteSegmentsMock.mockReset();
263
+ deleteSegmentsMock.mockResolvedValue(0);
224
264
  agentsMock.mockReturnValue([{ ID: 'co-agent-1', Name: 'Realtime Co-Agent' }]);
225
265
  agentTypesMock.mockReturnValue([]);
226
266
  });
@@ -2662,6 +2702,9 @@ describe('RealtimeClientSessionResolver — app awareness (applicationId / appCo
2662
2702
  }),
2663
2703
  );
2664
2704
  executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
2705
+ // The union is CanRun-gated against the caller before dispatch — grant it here so this test
2706
+ // covers the pass-through, not the gate (which has its own describe block).
2707
+ hasPermissionMock.mockResolvedValue(true);
2665
2708
  const resolver = makeResolver();
2666
2709
 
2667
2710
  await resolver.ExecuteRealtimeSessionTool(
@@ -2672,3 +2715,380 @@ describe('RealtimeClientSessionResolver — app awareness (applicationId / appCo
2672
2715
  expect(relayArg.AllowedAgents?.map(a => a.agentId)).toEqual(['skip-1']);
2673
2716
  });
2674
2717
  });
2718
+
2719
+ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3371)', () => {
2720
+ /** A scoped anonymous magic-link caller (no widget context) — the elevation-eligible shape. */
2721
+ const ANON_USER = {
2722
+ ID: 'anon-1',
2723
+ Email: 'anonymous@magic-link.local',
2724
+ IsMagicLinkAnonymous: true,
2725
+ MagicLinkScope: { ResourceID: 'scope-res-1' },
2726
+ };
2727
+ const SYSTEM_USER = { ID: 'system-1', Email: 'system@system.org' } as UserInfo;
2728
+
2729
+ /** Resolver whose caller is the scoped anonymous visitor. */
2730
+ function makeAnonResolver(): RealtimeClientSessionResolver {
2731
+ const resolver = new RealtimeClientSessionResolver();
2732
+ (resolver as unknown as { GetUserFromPayload: () => unknown }).GetUserFromPayload = () => ANON_USER;
2733
+ return resolver;
2734
+ }
2735
+
2736
+ /** The provider's GetEntityObject spy, typed for call-args assertions. */
2737
+ function getEntityObjectSpy(): ReturnType<typeof vi.fn> {
2738
+ return (currentProvider as { GetEntityObject: ReturnType<typeof vi.fn> }).GetEntityObject;
2739
+ }
2740
+
2741
+ beforeEach(() => {
2742
+ getSystemUserMock.mockReturnValue(SYSTEM_USER);
2743
+ });
2744
+
2745
+ it('dispatches the relayed tool as the SYSTEM user while the ownership gate stays on the caller', async () => {
2746
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
2747
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
2748
+ const resolver = makeAnonResolver();
2749
+
2750
+ const out = await resolver.ExecuteRealtimeSessionTool(
2751
+ 'session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub(),
2752
+ );
2753
+
2754
+ expect(out).toBe('{"ok":true}');
2755
+ // The session load (ownership gate) ran as the CALLER…
2756
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Agent Sessions', ANON_USER);
2757
+ // …the delegated dispatch ran as the SYSTEM user…
2758
+ expect(executeRelayedToolMock.mock.calls[0][1]).toBe(SYSTEM_USER);
2759
+ // …and the heartbeat (a session write the anon role holds) stayed on the caller.
2760
+ expect(heartbeatMock).toHaveBeenCalledWith('session-1', ANON_USER, currentProvider);
2761
+ });
2762
+
2763
+ it('attributes the delegated run to the VISITOR even though it executes as the system user', async () => {
2764
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
2765
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
2766
+ const resolver = makeAnonResolver();
2767
+
2768
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
2769
+
2770
+ // Executes as system (the run entities are outside the anon role)…
2771
+ expect(executeRelayedToolMock.mock.calls[0][1]).toBe(SYSTEM_USER);
2772
+ // …but the run row and its context-memory scope stay the visitor's.
2773
+ expect(executeRelayedToolMock.mock.calls[0][0].AttributionUserID).toBe('anon-1');
2774
+ });
2775
+
2776
+ it('still rejects a non-owned session for a scoped anonymous caller before any elevated work', async () => {
2777
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'someone-else' }));
2778
+ const resolver = makeAnonResolver();
2779
+
2780
+ await expect(
2781
+ resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub()),
2782
+ ).rejects.toThrow(/do not own/i);
2783
+ expect(executeRelayedToolMock).not.toHaveBeenCalled();
2784
+ });
2785
+
2786
+ it('dispatches as the caller unchanged for a normal authenticated user', async () => {
2787
+ currentProvider = makeProvider(() => makeSessionEntity());
2788
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
2789
+ const resolver = makeResolver();
2790
+
2791
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
2792
+
2793
+ expect(executeRelayedToolMock.mock.calls[0][1]).toBe(USER);
2794
+ expect(getSystemUserMock).not.toHaveBeenCalled();
2795
+ });
2796
+
2797
+ it('FAILS CLOSED — dispatches as the anonymous caller when no system user is available', async () => {
2798
+ getSystemUserMock.mockReturnValue(undefined);
2799
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
2800
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
2801
+ const resolver = makeAnonResolver();
2802
+
2803
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
2804
+
2805
+ expect(executeRelayedToolMock.mock.calls[0][1]).toBe(ANON_USER);
2806
+ });
2807
+
2808
+ it('accumulates relayed usage onto the prompt run under the SYSTEM user', async () => {
2809
+ const session = makeSessionEntity({
2810
+ UserID: 'anon-1',
2811
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2812
+ });
2813
+ const promptRun = makeSessionEntity({ ID: 'prompt-run-1', TokensPrompt: null, TokensCompletion: null, TokensUsed: null });
2814
+ currentProvider = {
2815
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Prompt Runs' ? promptRun : session)),
2816
+ };
2817
+ const resolver = makeAnonResolver();
2818
+
2819
+ const ok = await resolver.RelayRealtimeUsage('session-1', 100, 25, makeCtx());
2820
+
2821
+ expect(ok).toBe(true);
2822
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Agent Sessions', ANON_USER);
2823
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
2824
+ expect(promptRun.Save).toHaveBeenCalled();
2825
+ });
2826
+
2827
+ it('mirrors the transcript turn onto the prompt run as SYSTEM while the Conversation Detail stays the caller', async () => {
2828
+ const session = makeSessionEntity({
2829
+ UserID: 'anon-1',
2830
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2831
+ });
2832
+ const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
2833
+ const detail = makeSessionEntity({ ID: 'detail-1' });
2834
+ currentProvider = {
2835
+ GetEntityObject: vi.fn(async (name: string) => {
2836
+ if (name === 'MJ: AI Prompt Runs') return promptRun;
2837
+ if (name === 'MJ: Conversation Details') return detail;
2838
+ return session;
2839
+ }),
2840
+ };
2841
+ const resolver = makeAnonResolver();
2842
+
2843
+ const ok = await resolver.RelayRealtimeTranscript('session-1', 'User', 'hello there', makeCtx());
2844
+
2845
+ expect(ok).toBe(true);
2846
+ // The visible chat turn is written as the CALLER (the anon role holds Conversation Details)…
2847
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
2848
+ // …the co-agent prompt-run mirror is written as the SYSTEM user.
2849
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
2850
+ expect(promptRun.Save).toHaveBeenCalled();
2851
+ });
2852
+
2853
+ it('records the co-agent tool turn (RelayRealtimeToolTurn) under the SYSTEM user', async () => {
2854
+ const session = makeSessionEntity({
2855
+ UserID: 'anon-1',
2856
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
2857
+ });
2858
+ const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
2859
+ currentProvider = {
2860
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Prompt Runs' ? promptRun : session)),
2861
+ };
2862
+ const resolver = makeAnonResolver();
2863
+
2864
+ const ok = await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{"url":"x"}');
2865
+
2866
+ expect(ok).toBe(true);
2867
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
2868
+ });
2869
+
2870
+ it('prepares the session with observability under the SYSTEM user while UserID stays the visitor', async () => {
2871
+ hasPermissionMock.mockResolvedValue(true);
2872
+ currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
2873
+ createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-anon', UserID: 'anon-1' }));
2874
+ prepareClientSessionMock.mockResolvedValue({
2875
+ Success: true,
2876
+ ClientConfig: {
2877
+ Provider: 'openai',
2878
+ Model: 'gpt-realtime',
2879
+ EphemeralToken: 'ek_abc',
2880
+ ExpiresAt: '2026-01-01T00:00:00Z',
2881
+ SessionConfig: {},
2882
+ },
2883
+ });
2884
+ const resolver = makeAnonResolver();
2885
+
2886
+ await resolver.StartRealtimeClientSession('target-1', makeCtx());
2887
+
2888
+ // CanRun stays gated on the CALLER — elevation never widens who can start a session.
2889
+ expect(hasPermissionMock).toHaveBeenCalledWith('target-1', ANON_USER, 'run');
2890
+ const [prepInput, prepUser] = prepareClientSessionMock.mock.calls[0] as [
2891
+ { UserID?: string }, unknown,
2892
+ ];
2893
+ // Observability creation runs as the SYSTEM user…
2894
+ expect(prepUser).toBe(SYSTEM_USER);
2895
+ // …but run attribution + memory scope stay the visitor's.
2896
+ expect(prepInput.UserID).toBe('anon-1');
2897
+ });
2898
+
2899
+ it('junction-links delegated artifacts under the SYSTEM user and stamps the hidden anchor with the SESSION owner', async () => {
2900
+ const session = makeSessionEntity({ UserID: 'anon-1' });
2901
+ const junctions: FakeSession[] = [];
2902
+ const anchors: FakeSession[] = [];
2903
+ currentProvider = {
2904
+ GetEntityObject: vi.fn(async (name: string) => {
2905
+ if (name === 'MJ: Conversation Detail Artifacts') {
2906
+ const junction = makeSessionEntity({ ID: `junction-${junctions.length + 1}` });
2907
+ junctions.push(junction);
2908
+ return junction;
2909
+ }
2910
+ if (name === 'MJ: Conversation Details') {
2911
+ const anchor = makeSessionEntity({ ID: 'anchor-detail-1' });
2912
+ anchors.push(anchor);
2913
+ return anchor;
2914
+ }
2915
+ return session;
2916
+ }),
2917
+ RunView: vi.fn(async () => ({ Success: true, Results: [] })),
2918
+ };
2919
+ executeRelayedToolMock.mockResolvedValue({
2920
+ ResultJson: '{"ok":true}',
2921
+ Success: true,
2922
+ Artifacts: [{ ArtifactID: 'a-1', ArtifactVersionID: 'av-1', Name: 'Report' }],
2923
+ });
2924
+ const resolver = makeAnonResolver();
2925
+
2926
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
2927
+
2928
+ // The junction write (an entity the anon role does NOT hold) runs as the SYSTEM user.
2929
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Detail Artifacts', SYSTEM_USER);
2930
+ expect(junctions).toHaveLength(1);
2931
+ // The hidden anchor is attributed to the SESSION owner, not the elevated principal.
2932
+ expect(anchors).toHaveLength(1);
2933
+ expect(anchors[0].UserID).toBe('anon-1');
2934
+ });
2935
+
2936
+ /** Provider routing the session (ownership) and the co-agent (recording storage resolution). */
2937
+ function makeRecordingProvider(): unknown {
2938
+ const session = makeSessionEntity({ UserID: 'anon-1' });
2939
+ const agent = makeSessionEntity({ ID: 'co-agent-1' });
2940
+ return {
2941
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Agents' ? agent : session)),
2942
+ };
2943
+ }
2944
+ const AUDIO_B64 = Buffer.from('abc').toString('base64');
2945
+
2946
+ it('stores the consolidated recording under the SYSTEM user (ownership + consent stay caller-gated)', async () => {
2947
+ currentProvider = makeRecordingProvider();
2948
+ const resolver = makeAnonResolver();
2949
+
2950
+ const result = await resolver.UploadRealtimeRecording('session-1', AUDIO_B64, 'audio/wav', makeCtx(), 1000, true);
2951
+
2952
+ expect(result.Success).toBe(true);
2953
+ // Ownership ran as the CALLER; the agent read (an entity the anon role does not hold) as SYSTEM.
2954
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Agent Sessions', ANON_USER);
2955
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Agents', SYSTEM_USER);
2956
+ // Storage-account resolution, the MJ: Files write, and the shard cleanup all run as SYSTEM.
2957
+ expect(resolveStorageMock.mock.calls[0][1]).toBe(SYSTEM_USER);
2958
+ expect((storeRecordingMock.mock.calls[0][0] as { ContextUser: unknown }).ContextUser).toBe(SYSTEM_USER);
2959
+ expect(deleteSegmentsMock).toHaveBeenCalledWith('session-1', 'storage-acct-1', SYSTEM_USER);
2960
+ });
2961
+
2962
+ it('still refuses a consent-less recording upload BEFORE any elevated work', async () => {
2963
+ currentProvider = makeRecordingProvider();
2964
+ const resolver = makeAnonResolver();
2965
+
2966
+ const result = await resolver.UploadRealtimeRecording('session-1', AUDIO_B64, 'audio/wav', makeCtx(), 1000, false);
2967
+
2968
+ expect(result.Success).toBe(false);
2969
+ expect(storeRecordingMock).not.toHaveBeenCalled();
2970
+ });
2971
+
2972
+ it('stores a crash-recovery recording segment under the SYSTEM user', async () => {
2973
+ currentProvider = makeRecordingProvider();
2974
+ const resolver = makeAnonResolver();
2975
+
2976
+ const ok = await resolver.UploadRealtimeRecordingSegment('session-1', 0, AUDIO_B64, 'audio/wav', makeCtx());
2977
+
2978
+ expect(ok).toBe(true);
2979
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Agent Sessions', ANON_USER);
2980
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Agents', SYSTEM_USER);
2981
+ expect((writeSegmentMock.mock.calls[0][0] as { ContextUser: unknown }).ContextUser).toBe(SYSTEM_USER);
2982
+ });
2983
+
2984
+ it('recording writes stay entirely on the caller for a normal authenticated user', async () => {
2985
+ currentProvider = makeRecordingProvider();
2986
+ // A named caller owning the session (the recording provider stamps UserID 'anon-1', so re-stamp).
2987
+ const session = makeSessionEntity({ UserID: 'user-1' });
2988
+ const agent = makeSessionEntity({ ID: 'co-agent-1' });
2989
+ currentProvider = {
2990
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: AI Agents' ? agent : session)),
2991
+ };
2992
+ const resolver = makeResolver();
2993
+
2994
+ const result = await resolver.UploadRealtimeRecording('session-1', AUDIO_B64, 'audio/wav', makeCtx(), 1000, true);
2995
+
2996
+ expect(result.Success).toBe(true);
2997
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Agents', USER);
2998
+ expect((storeRecordingMock.mock.calls[0][0] as { ContextUser: unknown }).ContextUser).toBe(USER);
2999
+ expect(getSystemUserMock).not.toHaveBeenCalled();
3000
+ });
3001
+ });
3002
+
3003
+ describe('RealtimeClientSessionResolver — colleague authorization (allowedAgents)', () => {
3004
+ /** A two-colleague session config: the lead plus a restricted and an open colleague. */
3005
+ function makeMultiTargetSession(userID: string): FakeSession {
3006
+ return makeSessionEntity({
3007
+ UserID: userID,
3008
+ Config_: JSON.stringify({
3009
+ targetAgentID: 'target-1',
3010
+ allowedAgents: [
3011
+ { agentId: 'colleague-open', label: 'Open Colleague' },
3012
+ { agentId: 'colleague-restricted', label: 'Restricted Colleague' },
3013
+ ],
3014
+ }),
3015
+ });
3016
+ }
3017
+
3018
+ /** CanRun: everything except `colleague-restricted`. */
3019
+ function grantAllExceptRestricted(): void {
3020
+ hasPermissionMock.mockImplementation((...args: unknown[]) =>
3021
+ Promise.resolve(args[0] !== 'colleague-restricted'),
3022
+ );
3023
+ }
3024
+
3025
+ /** The `AllowedAgents` union the resolver actually handed to the delegation layer. */
3026
+ function dispatchedAgentIDs(): string[] {
3027
+ const input = executeRelayedToolMock.mock.calls[0][0] as { AllowedAgents?: { agentId: string }[] };
3028
+ return (input.AllowedAgents ?? []).map((a) => a.agentId);
3029
+ }
3030
+
3031
+ beforeEach(() => {
3032
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
3033
+ getSystemUserMock.mockReturnValue({ ID: 'system-1', Email: 'system@system.org' } as UserInfo);
3034
+ });
3035
+
3036
+ it('drops colleagues the AUTHENTICATED caller cannot run', async () => {
3037
+ currentProvider = makeProvider(() => makeMultiTargetSession('user-1'));
3038
+ grantAllExceptRestricted();
3039
+
3040
+ await makeResolver().ExecuteRealtimeSessionTool(
3041
+ 'session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub(),
3042
+ );
3043
+
3044
+ expect(dispatchedAgentIDs()).toEqual(['colleague-open']);
3045
+ });
3046
+
3047
+ it('gates the union against the CALLER, not the elevated system user (issue #3371)', async () => {
3048
+ const anonUser = {
3049
+ ID: 'anon-1',
3050
+ Email: 'anonymous@magic-link.local',
3051
+ IsMagicLinkAnonymous: true,
3052
+ MagicLinkScope: { ResourceID: 'scope-res-1' },
3053
+ };
3054
+ currentProvider = makeProvider(() => makeMultiTargetSession('anon-1'));
3055
+ grantAllExceptRestricted();
3056
+ const resolver = new RealtimeClientSessionResolver();
3057
+ (resolver as unknown as { GetUserFromPayload: () => unknown }).GetUserFromPayload = () => anonUser;
3058
+
3059
+ await resolver.ExecuteRealtimeSessionTool(
3060
+ 'session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub(),
3061
+ );
3062
+
3063
+ // Elevation must never widen agent authority: the restricted colleague is still gone…
3064
+ expect(dispatchedAgentIDs()).toEqual(['colleague-open']);
3065
+ // …and every CanRun verdict was asked about the visitor, never the system user.
3066
+ for (const call of hasPermissionMock.mock.calls) {
3067
+ expect((call as unknown[])[1]).toBe(anonUser);
3068
+ }
3069
+ });
3070
+
3071
+ it('excludes every colleague when the caller can run none of them', async () => {
3072
+ currentProvider = makeProvider(() => makeMultiTargetSession('user-1'));
3073
+ hasPermissionMock.mockResolvedValue(false);
3074
+
3075
+ await makeResolver().ExecuteRealtimeSessionTool(
3076
+ 'session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub(),
3077
+ );
3078
+
3079
+ expect(dispatchedAgentIDs()).toEqual([]);
3080
+ });
3081
+
3082
+ it('passes a single-target session through with no permission checks', async () => {
3083
+ currentProvider = makeProvider(() => makeSessionEntity());
3084
+ hasPermissionMock.mockResolvedValue(true);
3085
+
3086
+ await makeResolver().ExecuteRealtimeSessionTool(
3087
+ 'session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub(),
3088
+ );
3089
+
3090
+ const input = executeRelayedToolMock.mock.calls[0][0] as { AllowedAgents?: unknown };
3091
+ expect(input.AllowedAgents).toBeUndefined();
3092
+ expect(hasPermissionMock).not.toHaveBeenCalled();
3093
+ });
3094
+ });
@@ -40,6 +40,21 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
40
40
  };
41
41
  });
42
42
 
43
+ // --- Partial mock: control ONLY UserCache.GetSystemUser (the scoped-anonymous elevation seam,
44
+ // issue #3371); everything else in the data provider stays real ---
45
+ const getSystemUserMock = vi.fn<[], UserInfo | undefined>();
46
+ vi.mock('@memberjunction/sqlserver-dataprovider', async (importOriginal) => {
47
+ const actual = await importOriginal<Record<string, unknown>>();
48
+ return {
49
+ ...actual,
50
+ UserCache: {
51
+ get Instance() {
52
+ return { GetSystemUser: getSystemUserMock };
53
+ },
54
+ },
55
+ };
56
+ });
57
+
43
58
  import { SessionManager, SessionAuthorizationError } from '../agentSessions/SessionManager.js';
44
59
  import type { UserInfo, IMetadataProvider } from '@memberjunction/core';
45
60
 
@@ -94,6 +109,8 @@ beforeEach(() => {
94
109
  hostSessionClosedMock.mockResolvedValue(undefined);
95
110
  runViewMock.mockReset();
96
111
  runViewMock.mockResolvedValue({ Success: true, Results: [] });
112
+ getSystemUserMock.mockReset();
113
+ getSystemUserMock.mockReturnValue(undefined);
97
114
  });
98
115
 
99
116
  describe('SessionManager.CreateSession', () => {
@@ -245,6 +262,51 @@ describe('SessionManager.CloseSession', () => {
245
262
  expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-6', 'prompt-run-6', user, provider, true, 'run-step-6');
246
263
  });
247
264
 
265
+ it('finalizes as the SYSTEM user when a scoped anonymous owner closes their session (issue #3371)', async () => {
266
+ const systemUser = { ID: 'system-1', Email: 'system@system.org' } as unknown as UserInfo;
267
+ getSystemUserMock.mockReturnValue(systemUser);
268
+ const anonUser = {
269
+ ID: 'anon-1',
270
+ Email: 'anonymous@magic-link.local',
271
+ IsMagicLinkAnonymous: true,
272
+ MagicLinkScope: { ResourceID: 'res-1' },
273
+ } as unknown as UserInfo;
274
+ const session = makeSessionEntity({
275
+ ID: 'session-anon',
276
+ Status: 'Active',
277
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-9', promptRunID: 'prompt-run-9' }),
278
+ });
279
+ const { provider } = makeProvider(() => session);
280
+ const mgr = new SessionManager();
281
+
282
+ const ok = await mgr.CloseSession('session-anon', anonUser, provider);
283
+
284
+ expect(ok).toBe(true);
285
+ // The session-close writes themselves stay on the caller — only finalize elevates.
286
+ expect(session.Status).toBe('Closed');
287
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-9', 'prompt-run-9', systemUser, provider, true, null);
288
+ });
289
+
290
+ it('FAILS CLOSED — finalizes as the anonymous caller when no system user is available', async () => {
291
+ getSystemUserMock.mockReturnValue(undefined);
292
+ const anonUser = {
293
+ ID: 'anon-1',
294
+ IsMagicLinkAnonymous: true,
295
+ MagicLinkScope: { ResourceID: 'res-1' },
296
+ } as unknown as UserInfo;
297
+ const session = makeSessionEntity({
298
+ ID: 'session-anon-2',
299
+ Status: 'Active',
300
+ Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: 'co-run-10', promptRunID: null }),
301
+ });
302
+ const { provider } = makeProvider(() => session);
303
+ const mgr = new SessionManager();
304
+
305
+ await mgr.CloseSession('session-anon-2', anonUser, provider);
306
+
307
+ expect(finalizeCoAgentRunMock).toHaveBeenCalledWith('co-run-10', null, anonUser, provider, true, null);
308
+ });
309
+
248
310
  it('does not finalize when the session config has no run ids (target only)', async () => {
249
311
  const session = makeSessionEntity({
250
312
  ID: 'session-no-runs',