@memberjunction/server 5.43.0 → 5.45.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.
- package/dist/agentSessions/ReturningVisitorRecap.d.ts +39 -0
- package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -0
- package/dist/agentSessions/ReturningVisitorRecap.js +198 -0
- package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -0
- package/dist/agentSessions/SessionJanitor.d.ts +13 -0
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +65 -7
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +40 -0
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.d.ts +10 -0
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +16 -0
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/index.d.ts +1 -1
- package/dist/auth/magicLink/index.d.ts.map +1 -1
- package/dist/auth/magicLink/index.js +1 -1
- package/dist/auth/magicLink/index.js.map +1 -1
- package/dist/auth/magicLink/types.d.ts +26 -0
- package/dist/auth/magicLink/types.d.ts.map +1 -1
- package/dist/config.d.ts +1883 -144
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +160 -36
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +45 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +1684 -54
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +57105 -48029
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +13 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +22 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +4 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +110 -3
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +6 -0
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts +26 -0
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -0
- package/dist/realtimeWidget/WidgetRouter.js +182 -0
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -0
- package/dist/realtimeWidget/WidgetSessionService.d.ts +265 -0
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -0
- package/dist/realtimeWidget/WidgetSessionService.js +477 -0
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -0
- package/dist/realtimeWidget/host-identity.d.ts +40 -0
- package/dist/realtimeWidget/host-identity.d.ts.map +1 -0
- package/dist/realtimeWidget/host-identity.js +58 -0
- package/dist/realtimeWidget/host-identity.js.map +1 -0
- package/dist/realtimeWidget/index.d.ts +10 -0
- package/dist/realtimeWidget/index.d.ts.map +1 -0
- package/dist/realtimeWidget/index.js +9 -0
- package/dist/realtimeWidget/index.js.map +1 -0
- package/dist/realtimeWidget/visitorIdentity.d.ts +76 -0
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -0
- package/dist/realtimeWidget/visitorIdentity.js +202 -0
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -0
- package/dist/realtimeWidget/widgetCore.d.ts +106 -0
- package/dist/realtimeWidget/widgetCore.d.ts.map +1 -0
- package/dist/realtimeWidget/widgetCore.js +173 -0
- package/dist/realtimeWidget/widgetCore.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +51 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.js +79 -0
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts +7 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
- package/dist/resolvers/ComponentRegistryResolver.js +31 -8
- package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
- package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityPermissionResolver.js +1 -2
- package/dist/resolvers/EntityPermissionResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +9 -6
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +15 -13
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +8 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +28 -4
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +91 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +337 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts +27 -0
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/RingCentralTelephonyResolver.js +87 -0
- package/dist/resolvers/RingCentralTelephonyResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +50 -17
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +2 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/TeamsMeetingsResolver.d.ts +28 -0
- package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +1 -0
- package/dist/resolvers/TeamsMeetingsResolver.js +91 -0
- package/dist/resolvers/TeamsMeetingsResolver.js.map +1 -0
- package/dist/resolvers/TelephonyResolver.d.ts +26 -0
- package/dist/resolvers/TelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/TelephonyResolver.js +86 -0
- package/dist/resolvers/TelephonyResolver.js.map +1 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +2 -3
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/VonageTelephonyResolver.d.ts +26 -0
- package/dist/resolvers/VonageTelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/VonageTelephonyResolver.js +86 -0
- package/dist/resolvers/VonageTelephonyResolver.js.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +195 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/dist/telephony/RingCentralTelephonyService.d.ts +115 -0
- package/dist/telephony/RingCentralTelephonyService.d.ts.map +1 -0
- package/dist/telephony/RingCentralTelephonyService.js +262 -0
- package/dist/telephony/RingCentralTelephonyService.js.map +1 -0
- package/dist/telephony/TeamsMeetingsRouter.d.ts +40 -0
- package/dist/telephony/TeamsMeetingsRouter.d.ts.map +1 -0
- package/dist/telephony/TeamsMeetingsRouter.js +96 -0
- package/dist/telephony/TeamsMeetingsRouter.js.map +1 -0
- package/dist/telephony/TeamsMeetingsService.d.ts +113 -0
- package/dist/telephony/TeamsMeetingsService.d.ts.map +1 -0
- package/dist/telephony/TeamsMeetingsService.js +196 -0
- package/dist/telephony/TeamsMeetingsService.js.map +1 -0
- package/dist/telephony/TwilioTelephonyRouter.d.ts +36 -0
- package/dist/telephony/TwilioTelephonyRouter.d.ts.map +1 -0
- package/dist/telephony/TwilioTelephonyRouter.js +143 -0
- package/dist/telephony/TwilioTelephonyRouter.js.map +1 -0
- package/dist/telephony/TwilioTelephonyService.d.ts +95 -0
- package/dist/telephony/TwilioTelephonyService.d.ts.map +1 -0
- package/dist/telephony/TwilioTelephonyService.js +193 -0
- package/dist/telephony/TwilioTelephonyService.js.map +1 -0
- package/dist/telephony/VonageTelephonyRouter.d.ts +41 -0
- package/dist/telephony/VonageTelephonyRouter.d.ts.map +1 -0
- package/dist/telephony/VonageTelephonyRouter.js +201 -0
- package/dist/telephony/VonageTelephonyRouter.js.map +1 -0
- package/dist/telephony/VonageTelephonyService.d.ts +90 -0
- package/dist/telephony/VonageTelephonyService.d.ts.map +1 -0
- package/dist/telephony/VonageTelephonyService.js +191 -0
- package/dist/telephony/VonageTelephonyService.js.map +1 -0
- package/dist/telephony/calendar-scheduler.d.ts +67 -0
- package/dist/telephony/calendar-scheduler.d.ts.map +1 -0
- package/dist/telephony/calendar-scheduler.js +133 -0
- package/dist/telephony/calendar-scheduler.js.map +1 -0
- package/dist/telephony/index.d.ts +29 -0
- package/dist/telephony/index.d.ts.map +1 -0
- package/dist/telephony/index.js +38 -0
- package/dist/telephony/index.js.map +1 -0
- package/dist/telephony/media-upgrade-router.d.ts +33 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -0
- package/dist/telephony/media-upgrade-router.js +56 -0
- package/dist/telephony/media-upgrade-router.js.map +1 -0
- package/dist/telephony/ringcentral-runtime.d.ts +14 -0
- package/dist/telephony/ringcentral-runtime.d.ts.map +1 -0
- package/dist/telephony/ringcentral-runtime.js +18 -0
- package/dist/telephony/ringcentral-runtime.js.map +1 -0
- package/dist/telephony/teams-meetings-runtime.d.ts +16 -0
- package/dist/telephony/teams-meetings-runtime.d.ts.map +1 -0
- package/dist/telephony/teams-meetings-runtime.js +20 -0
- package/dist/telephony/teams-meetings-runtime.js.map +1 -0
- package/dist/telephony/teamsAcsMediaRegistry.d.ts +69 -0
- package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/teamsAcsMediaRegistry.js +118 -0
- package/dist/telephony/teamsAcsMediaRegistry.js.map +1 -0
- package/dist/telephony/telephony-runtime.d.ts +14 -0
- package/dist/telephony/telephony-runtime.d.ts.map +1 -0
- package/dist/telephony/telephony-runtime.js +18 -0
- package/dist/telephony/telephony-runtime.js.map +1 -0
- package/dist/telephony/twilioMediaRegistry.d.ts +60 -0
- package/dist/telephony/twilioMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/twilioMediaRegistry.js +106 -0
- package/dist/telephony/twilioMediaRegistry.js.map +1 -0
- package/dist/telephony/vonage-runtime.d.ts +14 -0
- package/dist/telephony/vonage-runtime.d.ts.map +1 -0
- package/dist/telephony/vonage-runtime.js +18 -0
- package/dist/telephony/vonage-runtime.js.map +1 -0
- package/dist/telephony/vonageMediaRegistry.d.ts +78 -0
- package/dist/telephony/vonageMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/vonageMediaRegistry.js +158 -0
- package/dist/telephony/vonageMediaRegistry.js.map +1 -0
- package/package.json +88 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/__tests__/search-knowledge-tags.test.ts +9 -9
- package/src/__tests__/teams-meetings-service.test.ts +65 -0
- package/src/__tests__/teamsAcsMediaRegistry.test.ts +82 -0
- package/src/__tests__/twilio-telephony-service.test.ts +23 -0
- package/src/__tests__/twilioMediaRegistry.test.ts +103 -0
- package/src/__tests__/vonageMediaRegistry.test.ts +180 -0
- package/src/__tests__/widget.test.ts +204 -0
- package/src/__tests__/widgetGuestElevation.test.ts +57 -0
- package/src/__tests__/widgetHostIdentity.test.ts +117 -0
- package/src/agentSessions/ReturningVisitorRecap.ts +242 -0
- package/src/agentSessions/SessionJanitor.ts +66 -6
- package/src/agentSessions/SessionManager.ts +41 -0
- package/src/agentSessions/index.ts +3 -1
- package/src/auth/magicLink/MagicLinkRouter.ts +17 -0
- package/src/auth/magicLink/index.ts +1 -0
- package/src/auth/magicLink/types.ts +26 -0
- package/src/config.ts +172 -38
- package/src/context.ts +50 -3
- package/src/generated/generated.ts +26726 -20464
- package/src/generic/ResolverBase.ts +28 -0
- package/src/generic/RunViewResolver.ts +4 -0
- package/src/index.ts +119 -3
- package/src/integration/CustomColumnPromoter.ts +6 -0
- package/src/realtimeWidget/WidgetRouter.ts +204 -0
- package/src/realtimeWidget/WidgetSessionService.ts +714 -0
- package/src/realtimeWidget/host-identity.ts +84 -0
- package/src/realtimeWidget/index.ts +15 -0
- package/src/realtimeWidget/visitorIdentity.ts +258 -0
- package/src/realtimeWidget/widgetCore.ts +231 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +115 -0
- package/src/resolvers/ComponentRegistryResolver.ts +36 -10
- package/src/resolvers/EntityPermissionResolver.ts +1 -2
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/QuerySystemUserResolver.ts +14 -10
- package/src/resolvers/RealtimeBridgeResolver.ts +36 -4
- package/src/resolvers/RealtimeClientSessionResolver.ts +383 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RingCentralTelephonyResolver.ts +64 -0
- package/src/resolvers/RunAIAgentResolver.ts +60 -15
- package/src/resolvers/SearchKnowledgeResolver.ts +2 -0
- package/src/resolvers/TeamsMeetingsResolver.ts +68 -0
- package/src/resolvers/TelephonyResolver.ts +63 -0
- package/src/resolvers/TestQuerySQLResolver.ts +2 -3
- package/src/resolvers/VonageTelephonyResolver.ts +63 -0
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +223 -0
- package/src/rest/mediaRange.ts +76 -0
- package/src/telephony/RingCentralTelephonyService.ts +342 -0
- package/src/telephony/TeamsMeetingsRouter.ts +124 -0
- package/src/telephony/TeamsMeetingsService.ts +268 -0
- package/src/telephony/TwilioTelephonyRouter.ts +184 -0
- package/src/telephony/TwilioTelephonyService.ts +261 -0
- package/src/telephony/VonageTelephonyRouter.ts +239 -0
- package/src/telephony/VonageTelephonyService.ts +259 -0
- package/src/telephony/calendar-scheduler.ts +176 -0
- package/src/telephony/index.ts +43 -0
- package/src/telephony/media-upgrade-router.ts +62 -0
- package/src/telephony/ringcentral-runtime.ts +22 -0
- package/src/telephony/teams-meetings-runtime.ts +24 -0
- package/src/telephony/teamsAcsMediaRegistry.ts +152 -0
- package/src/telephony/telephony-runtime.ts +22 -0
- package/src/telephony/twilioMediaRegistry.ts +137 -0
- package/src/telephony/vonage-runtime.ts +22 -0
- package/src/telephony/vonageMediaRegistry.ts +200 -0
- package/dist/agents/skip-agent.d.ts +0 -111
- package/dist/agents/skip-agent.d.ts.map +0 -1
- package/dist/agents/skip-agent.js +0 -1487
- package/dist/agents/skip-agent.js.map +0 -1
- package/dist/agents/skip-sdk.d.ts +0 -261
- package/dist/agents/skip-sdk.d.ts.map +0 -1
- package/dist/agents/skip-sdk.js +0 -909
- package/dist/agents/skip-sdk.js.map +0 -1
- package/src/agents/skip-agent.ts +0 -1594
- package/src/agents/skip-sdk.ts +0 -1210
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { createPublicKey } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
parseAllowedOrigins,
|
|
6
|
+
isOriginAllowed,
|
|
7
|
+
isModalityEnabled,
|
|
8
|
+
evaluateWidgetMint,
|
|
9
|
+
buildWidgetGuestClaims,
|
|
10
|
+
looksLikeBot,
|
|
11
|
+
} from '../realtimeWidget/widgetCore.js';
|
|
12
|
+
import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
|
|
13
|
+
|
|
14
|
+
describe('widget core — parseAllowedOrigins', () => {
|
|
15
|
+
it('parses a JSON array of origins', () => {
|
|
16
|
+
expect(parseAllowedOrigins('["https://a.com","https://b.com"]')).toEqual(['https://a.com', 'https://b.com']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('tolerates a comma-separated string', () => {
|
|
20
|
+
expect(parseAllowedOrigins('https://a.com, https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('normalizes case and strips trailing slashes', () => {
|
|
24
|
+
expect(parseAllowedOrigins('["HTTPS://Acme.COM/"]')).toEqual(['https://acme.com']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns empty (fail-closed) for null / blank / non-array JSON', () => {
|
|
28
|
+
expect(parseAllowedOrigins(null)).toEqual([]);
|
|
29
|
+
expect(parseAllowedOrigins(' ')).toEqual([]);
|
|
30
|
+
expect(parseAllowedOrigins('{"not":"an-array"}')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('widget core — isOriginAllowed (fail-closed)', () => {
|
|
35
|
+
const allowed = ['https://acme.com', 'https://www.acme.com'];
|
|
36
|
+
|
|
37
|
+
it('accepts an exact (normalized) match', () => {
|
|
38
|
+
expect(isOriginAllowed('https://acme.com', allowed)).toBe(true);
|
|
39
|
+
expect(isOriginAllowed('HTTPS://ACME.COM/', allowed)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects a non-listed origin', () => {
|
|
43
|
+
expect(isOriginAllowed('https://evil.com', allowed)).toBe(false);
|
|
44
|
+
expect(isOriginAllowed('https://sub.acme.com', allowed)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects a missing request origin', () => {
|
|
48
|
+
expect(isOriginAllowed(undefined, allowed)).toBe(false);
|
|
49
|
+
expect(isOriginAllowed('', allowed)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects everything when the allowlist is empty (never "*")', () => {
|
|
53
|
+
expect(isOriginAllowed('https://acme.com', [])).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('widget core — isModalityEnabled', () => {
|
|
58
|
+
it('Both enables text and voice', () => {
|
|
59
|
+
expect(isModalityEnabled('Both', 'Text')).toBe(true);
|
|
60
|
+
expect(isModalityEnabled('Both', 'Voice')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('Text enables only text', () => {
|
|
63
|
+
expect(isModalityEnabled('Text', 'Text')).toBe(true);
|
|
64
|
+
expect(isModalityEnabled('Text', 'Voice')).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
it('Voice enables only voice', () => {
|
|
67
|
+
expect(isModalityEnabled('Voice', 'Voice')).toBe(true);
|
|
68
|
+
expect(isModalityEnabled('Voice', 'Text')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('widget core — evaluateWidgetMint', () => {
|
|
73
|
+
const base = { Status: 'Active', AllowedOrigins: '["https://acme.com"]', Modality: 'Both' };
|
|
74
|
+
|
|
75
|
+
it('passes for an active widget from an allowed origin', () => {
|
|
76
|
+
expect(evaluateWidgetMint(base, 'https://acme.com')).toEqual({ ok: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects a disabled widget', () => {
|
|
80
|
+
expect(evaluateWidgetMint({ ...base, Status: 'Disabled' }, 'https://acme.com')).toEqual({
|
|
81
|
+
ok: false,
|
|
82
|
+
errorCode: 'disabled',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a disallowed origin', () => {
|
|
87
|
+
expect(evaluateWidgetMint(base, 'https://evil.com')).toEqual({ ok: false, errorCode: 'origin_not_allowed' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects a missing origin (fail-closed)', () => {
|
|
91
|
+
expect(evaluateWidgetMint(base, undefined)).toEqual({ ok: false, errorCode: 'origin_not_allowed' });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('widget core — buildWidgetGuestClaims', () => {
|
|
96
|
+
const args = {
|
|
97
|
+
issuer: 'https://mj.example.com',
|
|
98
|
+
audience: 'mj-magic-link',
|
|
99
|
+
widgetId: 'WIDGET-123',
|
|
100
|
+
sessionId: 'sess-abc',
|
|
101
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
102
|
+
applicationId: 'APP-1',
|
|
103
|
+
guestRoleName: 'Widget Guest',
|
|
104
|
+
nowSeconds: 1_000_000,
|
|
105
|
+
ttlSeconds: 900,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
it('marks the session anonymous + magic-link and binds the widget id', () => {
|
|
109
|
+
const claims = buildWidgetGuestClaims(args);
|
|
110
|
+
expect(claims.mj_anon).toBe(true);
|
|
111
|
+
expect(claims.mj_magic_link).toBe(true);
|
|
112
|
+
expect(claims.mj_widget_id).toBe('WIDGET-123');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('scopes to the application + guest role and the configured anon email', () => {
|
|
116
|
+
const claims = buildWidgetGuestClaims(args);
|
|
117
|
+
expect(claims.mj_app_id).toBe('APP-1');
|
|
118
|
+
expect(claims.mj_role).toBe('Widget Guest');
|
|
119
|
+
expect(claims.email).toBe('anonymous@magic-link.local');
|
|
120
|
+
expect(claims.sub).toBe('magic-link|sess-abc');
|
|
121
|
+
expect(claims.mj_sid).toBe('sess-abc');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('carries a single scope entry for the guest grant', () => {
|
|
125
|
+
const claims = buildWidgetGuestClaims(args);
|
|
126
|
+
expect(claims.mj_scopes).toHaveLength(1);
|
|
127
|
+
expect(claims.mj_scopes?.[0]).toMatchObject({ appId: 'APP-1', role: 'Widget Guest' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('sets exp = iat + ttl', () => {
|
|
131
|
+
const claims = buildWidgetGuestClaims(args);
|
|
132
|
+
expect(claims.iat).toBe(1_000_000);
|
|
133
|
+
expect(claims.exp).toBe(1_000_900);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('widget core — RS256 sign + verify roundtrip (reuses MagicLinkKeyManager)', () => {
|
|
138
|
+
it('mints a widget guest token that verifies against the published JWKS with the widget claim intact', () => {
|
|
139
|
+
const km = MagicLinkKeyManager.Instance;
|
|
140
|
+
km.Initialize(); // ephemeral keypair (idempotent if already initialized in-process)
|
|
141
|
+
|
|
142
|
+
const claims = buildWidgetGuestClaims({
|
|
143
|
+
issuer: 'https://mj.example.com',
|
|
144
|
+
audience: 'mj-magic-link',
|
|
145
|
+
widgetId: 'WIDGET-XYZ',
|
|
146
|
+
sessionId: 'sess-roundtrip',
|
|
147
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
148
|
+
applicationId: 'APP-9',
|
|
149
|
+
guestRoleName: 'Widget Guest',
|
|
150
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
151
|
+
ttlSeconds: 900,
|
|
152
|
+
});
|
|
153
|
+
const token = km.Sign(claims);
|
|
154
|
+
|
|
155
|
+
const publicKey = createPublicKey({ key: km.GetJWKS().keys[0] as object, format: 'jwk' });
|
|
156
|
+
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'], audience: 'mj-magic-link' }) as jwt.JwtPayload;
|
|
157
|
+
|
|
158
|
+
expect(decoded.mj_widget_id).toBe('WIDGET-XYZ');
|
|
159
|
+
expect(decoded.mj_anon).toBe(true);
|
|
160
|
+
expect(decoded.mj_magic_link).toBe(true);
|
|
161
|
+
expect(decoded.mj_app_id).toBe('APP-9');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('rejects a tampered widget token', () => {
|
|
165
|
+
const km = MagicLinkKeyManager.Instance;
|
|
166
|
+
km.Initialize();
|
|
167
|
+
const claims = buildWidgetGuestClaims({
|
|
168
|
+
issuer: 'https://mj.example.com',
|
|
169
|
+
audience: 'mj-magic-link',
|
|
170
|
+
widgetId: 'W',
|
|
171
|
+
sessionId: 's',
|
|
172
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
173
|
+
applicationId: 'A',
|
|
174
|
+
guestRoleName: 'Widget Guest',
|
|
175
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
176
|
+
ttlSeconds: 900,
|
|
177
|
+
});
|
|
178
|
+
const token = km.Sign(claims);
|
|
179
|
+
const publicKey = createPublicKey({ key: km.GetJWKS().keys[0] as object, format: 'jwk' });
|
|
180
|
+
const tampered = token.slice(0, -3) + 'AAA';
|
|
181
|
+
expect(() => jwt.verify(tampered, publicKey, { algorithms: ['RS256'] })).toThrow();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('widget core — looksLikeBot (W6 mint heuristic)', () => {
|
|
186
|
+
it('flags a missing / blank user-agent as a bot', () => {
|
|
187
|
+
expect(looksLikeBot(undefined)).toBe(true);
|
|
188
|
+
expect(looksLikeBot(null)).toBe(true);
|
|
189
|
+
expect(looksLikeBot(' ')).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('flags known automation user-agents', () => {
|
|
193
|
+
expect(looksLikeBot('curl/8.4.0')).toBe(true);
|
|
194
|
+
expect(looksLikeBot('python-requests/2.31')).toBe(true);
|
|
195
|
+
expect(looksLikeBot('Googlebot/2.1 (+http://www.google.com/bot.html)')).toBe(true);
|
|
196
|
+
expect(looksLikeBot('Mozilla/5.0 (compatible; bingbot/2.0)')).toBe(true);
|
|
197
|
+
expect(looksLikeBot('HeadlessChrome/120.0.0.0')).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('allows real browser user-agents', () => {
|
|
201
|
+
expect(looksLikeBot('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36')).toBe(false);
|
|
202
|
+
expect(looksLikeBot('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1')).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { DatabaseProviderBase, UserInfo } from '@memberjunction/core';
|
|
3
|
+
import { resolveWidgetGuestRunContext, elevateUserPayload } from '../realtimeWidget/widgetGuestElevation.js';
|
|
4
|
+
import type { UserPayload } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/** A provider stub — the guard paths under test return before ever touching the provider. */
|
|
7
|
+
const providerStub = {} as DatabaseProviderBase;
|
|
8
|
+
|
|
9
|
+
/** Builds a UserPayload whose synthesized userRecord carries the given guest flags. */
|
|
10
|
+
function payloadWith(userRecord: Partial<UserInfo>): UserPayload {
|
|
11
|
+
return { email: 'anonymous@magic-link.local', userRecord, sessionId: 'sess-123' };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('widgetGuestElevation — resolveWidgetGuestRunContext (guard paths, no DB)', () => {
|
|
15
|
+
it('returns null for a normal authenticated user (not anonymous)', async () => {
|
|
16
|
+
const result = await resolveWidgetGuestRunContext(payloadWith({ IsMagicLinkAnonymous: false }), providerStub);
|
|
17
|
+
expect(result).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns null for an anonymous magic-link session that is NOT a widget guest (no WidgetGuestContext)', async () => {
|
|
21
|
+
const result = await resolveWidgetGuestRunContext(
|
|
22
|
+
payloadWith({ IsMagicLinkAnonymous: true, WidgetGuestContext: undefined }),
|
|
23
|
+
providerStub,
|
|
24
|
+
);
|
|
25
|
+
expect(result).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns null when userRecord is absent', async () => {
|
|
29
|
+
const result = await resolveWidgetGuestRunContext({ email: '', userRecord: undefined, sessionId: 's' }, providerStub);
|
|
30
|
+
expect(result).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('widgetGuestElevation — elevateUserPayload', () => {
|
|
35
|
+
it('swaps in the elevated principal while preserving the guest sessionId for PubSub routing', () => {
|
|
36
|
+
const guestPayload = payloadWith({ IsMagicLinkAnonymous: true });
|
|
37
|
+
const systemUser = { Email: 'system@system.org' } as UserInfo;
|
|
38
|
+
|
|
39
|
+
const elevated = elevateUserPayload(guestPayload, systemUser);
|
|
40
|
+
|
|
41
|
+
expect(elevated.userRecord).toBe(systemUser);
|
|
42
|
+
expect(elevated.email).toBe('system@system.org');
|
|
43
|
+
expect(elevated.isSystemUser).toBe(true);
|
|
44
|
+
// The session id MUST be preserved so progress/streaming still routes to the guest's websocket.
|
|
45
|
+
expect(elevated.sessionId).toBe('sess-123');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does not mutate the original guest payload', () => {
|
|
49
|
+
const guestPayload = payloadWith({ IsMagicLinkAnonymous: true });
|
|
50
|
+
const systemUser = { Email: 'system@system.org' } as UserInfo;
|
|
51
|
+
|
|
52
|
+
elevateUserPayload(guestPayload, systemUser);
|
|
53
|
+
|
|
54
|
+
expect(guestPayload.isSystemUser).toBeUndefined();
|
|
55
|
+
expect(guestPayload.email).toBe('anonymous@magic-link.local');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
4
|
+
import { verifyHostAssertion, extractHostIdentity } from '../realtimeWidget/host-identity.js';
|
|
5
|
+
import { buildWidgetGuestClaims } from '../realtimeWidget/widgetCore.js';
|
|
6
|
+
|
|
7
|
+
/** A throwaway RSA keypair for signing test host assertions. */
|
|
8
|
+
function keypair(): { privatePem: string; publicPem: string } {
|
|
9
|
+
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
10
|
+
return {
|
|
11
|
+
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
|
|
12
|
+
publicPem: publicKey.export({ type: 'spki', format: 'pem' }) as string,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const WIDGET_KEY = 'pk_live_acme';
|
|
17
|
+
|
|
18
|
+
function sign(privatePem: string, claims: Record<string, unknown>, opts: jwt.SignOptions = {}): string {
|
|
19
|
+
return jwt.sign(claims, privatePem, { algorithm: 'RS256', audience: WIDGET_KEY, expiresIn: '5m', ...opts });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('verifyHostAssertion', () => {
|
|
23
|
+
it('accepts a valid host assertion and extracts the identity', () => {
|
|
24
|
+
const kp = keypair();
|
|
25
|
+
const token = sign(kp.privatePem, { email: 'jane@acme.com', given_name: 'Jane', family_name: 'Doe', sub: 'host-123' });
|
|
26
|
+
const result = verifyHostAssertion(token, kp.publicPem, WIDGET_KEY);
|
|
27
|
+
expect(result.ok).toBe(true);
|
|
28
|
+
if (result.ok) {
|
|
29
|
+
expect(result.identity).toMatchObject({ email: 'jane@acme.com', firstName: 'Jane', lastName: 'Doe', hostUserId: 'host-123' });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects a missing assertion', () => {
|
|
34
|
+
const kp = keypair();
|
|
35
|
+
expect(verifyHostAssertion(undefined, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'missing' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects when no host key is configured (fail-closed)', () => {
|
|
39
|
+
const kp = keypair();
|
|
40
|
+
const token = sign(kp.privatePem, { email: 'x@y.com' });
|
|
41
|
+
expect(verifyHostAssertion(token, undefined, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'no_key' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects a signature from the wrong key', () => {
|
|
45
|
+
const signer = keypair();
|
|
46
|
+
const other = keypair();
|
|
47
|
+
const token = sign(signer.privatePem, { email: 'x@y.com' });
|
|
48
|
+
expect(verifyHostAssertion(token, other.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'bad_signature' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects a wrong-audience assertion', () => {
|
|
52
|
+
const kp = keypair();
|
|
53
|
+
const token = sign(kp.privatePem, { email: 'x@y.com' }, { audience: 'pk_live_someone_else' });
|
|
54
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'bad_signature' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects an expired assertion', () => {
|
|
58
|
+
const kp = keypair();
|
|
59
|
+
const token = sign(kp.privatePem, { email: 'x@y.com' }, { expiresIn: -10 });
|
|
60
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'expired' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects an assertion with no email', () => {
|
|
64
|
+
const kp = keypair();
|
|
65
|
+
const token = sign(kp.privatePem, { given_name: 'NoEmail' });
|
|
66
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'no_email' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects an assertion with no exp (unbounded lifetime)', () => {
|
|
70
|
+
const kp = keypair();
|
|
71
|
+
// Sign directly with no expiresIn so the token carries iat but no exp.
|
|
72
|
+
const token = jwt.sign({ email: 'x@y.com' }, kp.privatePem, { algorithm: 'RS256', audience: WIDGET_KEY });
|
|
73
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'expired' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects an assertion older than the maxAge cap even when its exp is still in the future', () => {
|
|
77
|
+
const kp = keypair();
|
|
78
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
79
|
+
// iat one hour ago (well past the 10m maxAge) with a far-future exp — the age cap must still reject it.
|
|
80
|
+
const token = jwt.sign({ email: 'x@y.com', iat: nowSec - 3600, exp: nowSec + 3600 }, kp.privatePem, {
|
|
81
|
+
algorithm: 'RS256',
|
|
82
|
+
audience: WIDGET_KEY,
|
|
83
|
+
});
|
|
84
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'expired' });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('buildWidgetGuestClaims with hostIdentity', () => {
|
|
89
|
+
it('carries the host identity as INFORMATIONAL claims but keeps the Anonymous principal email', () => {
|
|
90
|
+
const claims = buildWidgetGuestClaims({
|
|
91
|
+
issuer: 'iss',
|
|
92
|
+
audience: 'mj-magic-link',
|
|
93
|
+
widgetId: 'W',
|
|
94
|
+
sessionId: 's',
|
|
95
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
96
|
+
applicationId: 'A',
|
|
97
|
+
guestRoleName: 'Widget Guest',
|
|
98
|
+
nowSeconds: 1000,
|
|
99
|
+
ttlSeconds: 900,
|
|
100
|
+
hostIdentity: { email: 'jane@acme.com', firstName: 'Jane', lastName: 'Doe' },
|
|
101
|
+
});
|
|
102
|
+
// Principal-resolving email stays anonymous (no escalation into a real account).
|
|
103
|
+
expect(claims.email).toBe('anonymous@magic-link.local');
|
|
104
|
+
expect(claims.mj_anon).toBe(true);
|
|
105
|
+
// Host identity rides as informational claims.
|
|
106
|
+
expect(claims.mj_host_email).toBe('jane@acme.com');
|
|
107
|
+
expect(claims.given_name).toBe('Jane');
|
|
108
|
+
expect(claims.name).toBe('Jane Doe');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('extractHostIdentity', () => {
|
|
113
|
+
it('tolerates firstName/lastName fallbacks', () => {
|
|
114
|
+
const id = extractHostIdentity({ email: 'a@b.com', firstName: 'Al', lastName: 'Bo' });
|
|
115
|
+
expect(id).toMatchObject({ email: 'a@b.com', firstName: 'Al', lastName: 'Bo' });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Returning-visitor recap (RV2). When a returning-visitor-enabled conversation ends,
|
|
3
|
+
* summarize its transcript into an Active agent memory note so the visitor's NEXT session opens with
|
|
4
|
+
* prior context. The recap is written through the EXISTING agent-notes system (MJ: AI Agent Notes) and
|
|
5
|
+
* is scoped via the existing polymorphic note scope (PrimaryScopeEntityID + PrimaryScopeRecordID):
|
|
6
|
+
*
|
|
7
|
+
* - resolved visitor → scope = (Conversation.LinkedEntityID, Conversation.LinkedRecordID)
|
|
8
|
+
* - anonymous visitor → scope = (the "MJ: Conversations" entity, this conversation's ID)
|
|
9
|
+
*
|
|
10
|
+
* The anonymous scope lets the return-side injector (RV3) resolve the recap by following the
|
|
11
|
+
* VisitorKey chain (the next conversation's LastConversationID points back here). Either way the
|
|
12
|
+
* Memory Manager's existing hardening / consolidation / decay maintains the note from here on, and the
|
|
13
|
+
* existing memory injection (which already filters by the PrimaryScope pair) pulls it in — no parallel
|
|
14
|
+
* store and no new injection path (per AN-BC's "reuse the memory system" guidance).
|
|
15
|
+
*
|
|
16
|
+
* The recap TEXT is produced by the metadata-defined "Returning-Visitor Recap" AI prompt via
|
|
17
|
+
* AIPromptRunner (MJ's prompt infra) — not a hand-rolled summarizer. The prompt emits NO_RECAP when a
|
|
18
|
+
* conversation has nothing worth remembering, in which case no note is written.
|
|
19
|
+
*
|
|
20
|
+
* Everything here is BEST-EFFORT: a recap failure must never affect session teardown or the visitor.
|
|
21
|
+
*
|
|
22
|
+
* @module @memberjunction/server/agentSessions
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { RunView, UserInfo, LogError, LogStatus, type IMetadataProvider } from '@memberjunction/core';
|
|
26
|
+
import type { MJConversationEntity, MJConversationDetailEntity, MJAIAgentNoteEntity } from '@memberjunction/core-entities';
|
|
27
|
+
import { AIEngine } from '@memberjunction/aiengine';
|
|
28
|
+
import { AIPromptParams } from '@memberjunction/ai-core-plus';
|
|
29
|
+
import { AIPromptRunner } from '@memberjunction/ai-prompts';
|
|
30
|
+
|
|
31
|
+
const CONVERSATIONS_ENTITY = 'MJ: Conversations';
|
|
32
|
+
const CONVERSATION_DETAILS_ENTITY = 'MJ: Conversation Details';
|
|
33
|
+
const AGENT_NOTES_ENTITY = 'MJ: AI Agent Notes';
|
|
34
|
+
const RECAP_PROMPT_NAME = 'Returning-Visitor Recap';
|
|
35
|
+
|
|
36
|
+
/** The recap prompt returns this sentinel when a conversation has nothing worth remembering. */
|
|
37
|
+
const NO_RECAP_SENTINEL = 'NO_RECAP';
|
|
38
|
+
|
|
39
|
+
/** Cap the transcript we summarize (most recent turns) to bound prompt size + cost. */
|
|
40
|
+
const MAX_TRANSCRIPT_TURNS = 60;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Writes a returning-visitor recap note for a finished conversation. Idempotent (skips when a recap for
|
|
44
|
+
* this conversation already exists) and no-ops for conversations that aren't returning-visitor-enabled.
|
|
45
|
+
* Never throws.
|
|
46
|
+
*
|
|
47
|
+
* @param conversationId the conversation that just ended
|
|
48
|
+
* @param agentId the agent the recap is attributed to (the session/pinned agent), or null
|
|
49
|
+
* @param contextUser server context user
|
|
50
|
+
* @param provider the provider servicing this request/session
|
|
51
|
+
* @param retentionDays optional per-widget retention; when > 0 the recap note is stamped with an
|
|
52
|
+
* `ExpiresAt` that many days out (the Memory Manager's decay phase reaps expired notes). Omit/0
|
|
53
|
+
* for no expiry (system default retention applies).
|
|
54
|
+
*/
|
|
55
|
+
export async function writeReturningVisitorRecap(
|
|
56
|
+
conversationId: string | null | undefined,
|
|
57
|
+
agentId: string | null | undefined,
|
|
58
|
+
contextUser: UserInfo,
|
|
59
|
+
provider: IMetadataProvider,
|
|
60
|
+
retentionDays?: number | null,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
try {
|
|
63
|
+
if (!conversationId) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
LogStatus(`[ReturningVisitorRecap] START for prior conversation ${conversationId}`);
|
|
67
|
+
const conversation = await getEntity<MJConversationEntity>(provider, CONVERSATIONS_ENTITY, contextUser);
|
|
68
|
+
if (!conversation || !(await conversation.Load(conversationId))) {
|
|
69
|
+
LogError(`[ReturningVisitorRecap] could not load conversation ${conversationId} — bailing`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const scope = resolveRecapScope(conversation, provider);
|
|
74
|
+
if (!scope) {
|
|
75
|
+
// Not a returning-visitor conversation (no VisitorKey, no resolved identity) — nothing to remember.
|
|
76
|
+
LogStatus(`[ReturningVisitorRecap] no scope (no VisitorKey/resolved identity) for ${conversationId} — bailing`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (await recapAlreadyExists(conversationId, contextUser)) {
|
|
81
|
+
LogStatus(`[ReturningVisitorRecap] recap already exists for ${conversationId} — skipping`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const transcript = await loadTranscript(conversationId, contextUser);
|
|
86
|
+
if (!transcript) {
|
|
87
|
+
LogStatus(`[ReturningVisitorRecap] empty transcript for ${conversationId} — bailing`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
LogStatus(`[ReturningVisitorRecap] transcript loaded (${transcript.length} chars) for ${conversationId}; running recap prompt`);
|
|
91
|
+
|
|
92
|
+
const recap = await summarizeTranscript(transcript, contextUser);
|
|
93
|
+
if (!recap || recap.trim().toUpperCase().startsWith(NO_RECAP_SENTINEL)) {
|
|
94
|
+
LogStatus(`[ReturningVisitorRecap] prompt returned ${recap ? 'NO_RECAP' : 'no/empty result'} for ${conversationId} — no note written`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await persistRecapNote({ recap, scope, conversationId, agentId, contextUser, provider, retentionDays });
|
|
99
|
+
} catch (e) {
|
|
100
|
+
LogError(`[ReturningVisitorRecap] best-effort recap failed for conversation ${conversationId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The polymorphic scope a recap note is filed under. */
|
|
105
|
+
interface RecapScope {
|
|
106
|
+
entityId: string;
|
|
107
|
+
recordId: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolves the note scope for this conversation, or undefined when it isn't a returning-visitor
|
|
112
|
+
* conversation. Resolved identity wins (R1 polymorphic pair); otherwise an anonymous visitor's recap
|
|
113
|
+
* is filed against the conversation itself so the VisitorKey chain resolves it on the next visit.
|
|
114
|
+
*/
|
|
115
|
+
function resolveRecapScope(conversation: MJConversationEntity, provider: IMetadataProvider): RecapScope | undefined {
|
|
116
|
+
if (conversation.LinkedEntityID && conversation.LinkedRecordID) {
|
|
117
|
+
return { entityId: conversation.LinkedEntityID, recordId: conversation.LinkedRecordID };
|
|
118
|
+
}
|
|
119
|
+
if (conversation.VisitorKey) {
|
|
120
|
+
const conversationsEntityId = provider.Entities.find((e) => e.Name === CONVERSATIONS_ENTITY)?.ID;
|
|
121
|
+
if (conversationsEntityId) {
|
|
122
|
+
return { entityId: conversationsEntityId, recordId: conversation.ID };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when an auto-generated recap note already exists for this conversation (idempotency). */
|
|
129
|
+
async function recapAlreadyExists(conversationId: string, contextUser: UserInfo): Promise<boolean> {
|
|
130
|
+
const rv = new RunView();
|
|
131
|
+
const result = await rv.RunView<{ ID: string }>(
|
|
132
|
+
{
|
|
133
|
+
EntityName: AGENT_NOTES_ENTITY,
|
|
134
|
+
ExtraFilter: `SourceConversationID = '${conversationId}' AND IsAutoGenerated = 1 AND Type = 'Context'`,
|
|
135
|
+
Fields: ['ID'],
|
|
136
|
+
MaxRows: 1,
|
|
137
|
+
ResultType: 'simple',
|
|
138
|
+
},
|
|
139
|
+
contextUser,
|
|
140
|
+
);
|
|
141
|
+
return result.Success && (result.Results?.length ?? 0) > 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Builds a compact transcript string from the conversation's most-recent detail turns. */
|
|
145
|
+
async function loadTranscript(conversationId: string, contextUser: UserInfo): Promise<string | undefined> {
|
|
146
|
+
const rv = new RunView();
|
|
147
|
+
const result = await rv.RunView<MJConversationDetailEntity>(
|
|
148
|
+
{
|
|
149
|
+
EntityName: CONVERSATION_DETAILS_ENTITY,
|
|
150
|
+
ExtraFilter: `ConversationID = '${conversationId}'`,
|
|
151
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
152
|
+
MaxRows: MAX_TRANSCRIPT_TURNS,
|
|
153
|
+
ResultType: 'entity_object',
|
|
154
|
+
},
|
|
155
|
+
contextUser,
|
|
156
|
+
);
|
|
157
|
+
if (!result.Success || result.Results.length === 0) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
// RunView returned newest-first; reverse to chronological for a readable transcript.
|
|
161
|
+
const lines = result.Results.slice()
|
|
162
|
+
.reverse()
|
|
163
|
+
.map((d) => `${d.Role ?? 'User'}: ${(d.Message ?? '').trim()}`)
|
|
164
|
+
.filter((l) => l.length > 0);
|
|
165
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Runs the metadata-defined recap prompt over the transcript and returns its plain-text recap. */
|
|
169
|
+
async function summarizeTranscript(transcript: string, contextUser: UserInfo): Promise<string | undefined> {
|
|
170
|
+
await AIEngine.Instance.Config(false, contextUser);
|
|
171
|
+
const promptEntity = AIEngine.Instance.Prompts.find((p) => p.Name?.trim().toLowerCase() === RECAP_PROMPT_NAME.toLowerCase());
|
|
172
|
+
if (!promptEntity) {
|
|
173
|
+
LogError(`[ReturningVisitorRecap] recap prompt '${RECAP_PROMPT_NAME}' not found — push metadata/prompts. Skipping recap.`);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
LogStatus(`[ReturningVisitorRecap] using prompt '${promptEntity.Name}' (strategy=${promptEntity.SelectionStrategy}, status=${promptEntity.Status})`);
|
|
177
|
+
const params = new AIPromptParams();
|
|
178
|
+
params.prompt = promptEntity;
|
|
179
|
+
params.contextUser = contextUser;
|
|
180
|
+
params.data = { transcript };
|
|
181
|
+
const result = await new AIPromptRunner().ExecutePrompt(params);
|
|
182
|
+
if (!result.success) {
|
|
183
|
+
LogError(`[ReturningVisitorRecap] recap prompt failed: ${result.errorMessage ?? 'unknown'}`);
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
const text = typeof result.rawResult === 'string' ? result.rawResult : String(result.rawResult ?? '');
|
|
187
|
+
LogStatus(`[ReturningVisitorRecap] prompt succeeded; recap preview: "${text.trim().slice(0, 120)}"`);
|
|
188
|
+
return text.trim() || undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Persists the recap as an Active, auto-generated, Context-typed agent note under the resolved scope. */
|
|
192
|
+
async function persistRecapNote(args: {
|
|
193
|
+
recap: string;
|
|
194
|
+
scope: RecapScope;
|
|
195
|
+
conversationId: string;
|
|
196
|
+
agentId: string | null | undefined;
|
|
197
|
+
contextUser: UserInfo;
|
|
198
|
+
provider: IMetadataProvider;
|
|
199
|
+
retentionDays?: number | null;
|
|
200
|
+
}): Promise<void> {
|
|
201
|
+
const note = await getEntity<MJAIAgentNoteEntity>(args.provider, AGENT_NOTES_ENTITY, args.contextUser);
|
|
202
|
+
if (!note) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
note.NewRecord();
|
|
206
|
+
if (args.agentId) {
|
|
207
|
+
note.AgentID = args.agentId;
|
|
208
|
+
}
|
|
209
|
+
note.Note = args.recap;
|
|
210
|
+
note.Type = 'Context';
|
|
211
|
+
note.Status = 'Active';
|
|
212
|
+
note.AuthorType = 'MemoryManager';
|
|
213
|
+
note.IsAutoGenerated = true;
|
|
214
|
+
note.PrimaryScopeEntityID = args.scope.entityId;
|
|
215
|
+
note.PrimaryScopeRecordID = args.scope.recordId;
|
|
216
|
+
note.SourceConversationID = args.conversationId;
|
|
217
|
+
// Per-widget retention: stamp an expiry the Memory Manager's decay phase honors. Omitted/≤0 ⇒ no
|
|
218
|
+
// expiry (system-default retention). One day = 86_400_000 ms.
|
|
219
|
+
if (args.retentionDays && args.retentionDays > 0) {
|
|
220
|
+
note.ExpiresAt = new Date(Date.now() + args.retentionDays * 86_400_000);
|
|
221
|
+
}
|
|
222
|
+
const saved = await note.Save();
|
|
223
|
+
if (!saved) {
|
|
224
|
+
LogError(`[ReturningVisitorRecap] failed to save recap note: ${note.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
LogStatus(`[ReturningVisitorRecap] wrote recap note ${note.ID} scoped to ${args.scope.entityId}/${args.scope.recordId} for conversation ${args.conversationId}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** GetEntityObject via the request/session provider (multi-provider-safe — never the global Metadata). */
|
|
231
|
+
async function getEntity<T extends import('@memberjunction/core').BaseEntity>(
|
|
232
|
+
provider: IMetadataProvider,
|
|
233
|
+
entityName: string,
|
|
234
|
+
contextUser: UserInfo,
|
|
235
|
+
): Promise<T | null> {
|
|
236
|
+
try {
|
|
237
|
+
return await provider.GetEntityObject<T>(entityName, contextUser);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
LogError(`[ReturningVisitorRecap] GetEntityObject('${entityName}') failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|