@memberjunction/server 5.44.0 → 5.45.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.
- 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 +37 -0
- package/dist/agentSessions/SessionManager.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/MagicLinkService.d.ts +7 -0
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +13 -3
- package/dist/auth/magicLink/MagicLinkService.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/magicLinkCore.d.ts +38 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +42 -19
- package/dist/auth/magicLink/magicLinkCore.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 +522 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2860 -0
- 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 +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -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/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/RealtimeClientSessionResolver.d.ts +10 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +47 -3
- package/dist/resolvers/RealtimeClientSessionResolver.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 +9 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -4
- 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/rest/MediaStreamHandler.js +4 -1
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- 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 -83
- package/src/__tests__/RunAIAgentResolver.streaming.test.ts +128 -0
- package/src/__tests__/magicLink.test.ts +53 -3
- 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 +38 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +17 -0
- package/src/auth/magicLink/MagicLinkService.ts +13 -3
- package/src/auth/magicLink/index.ts +1 -0
- package/src/auth/magicLink/magicLinkCore.ts +48 -20
- 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 +1991 -1
- package/src/generic/ResolverBase.ts +28 -0
- package/src/generic/RunViewResolver.ts +4 -0
- package/src/index.ts +109 -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/QuerySystemUserResolver.ts +14 -10
- package/src/resolvers/RealtimeClientSessionResolver.ts +59 -2
- package/src/resolvers/RingCentralTelephonyResolver.ts +64 -0
- package/src/resolvers/RunAIAgentResolver.ts +32 -8
- 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/rest/MediaStreamHandler.ts +4 -1
- 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,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
|
+
}
|
|
@@ -170,6 +170,49 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
|
|
|
170
170
|
return closed;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Server-authoritative MAX-DURATION enforcement (public web-widget voice cap, public-web-widget.md
|
|
175
|
+
* W4). Closes any `Active`/`Idle` session whose stored absolute deadline (`Config_.maxSessionDeadlineIso`)
|
|
176
|
+
* has passed — a hard wall-clock ceiling that fires regardless of activity, so a public voice guest
|
|
177
|
+
* cannot run a session indefinitely (cost-bombing) even if the client ignores its own abuse guard.
|
|
178
|
+
*
|
|
179
|
+
* Only sessions that CARRY a deadline are loaded (a cheap `Config_ LIKE` pre-filter), then the exact
|
|
180
|
+
* ISO deadline is parsed and compared in JS (`Config_` is JSON, not a queryable column). Stamped
|
|
181
|
+
* `CloseReason = 'Janitor'`, idempotent + concurrency-safe like the other sweeps. Returns the count closed.
|
|
182
|
+
*/
|
|
183
|
+
public async RunMaxDurationSweep(provider: IMetadataProvider, systemUser: UserInfo): Promise<number> {
|
|
184
|
+
const nowMs = Date.now();
|
|
185
|
+
// Only consider sessions that carry a deadline marker — keeps this sweep cheap (it never touches
|
|
186
|
+
// the far-more-common uncapped sessions) while the JS check below enforces the exact deadline.
|
|
187
|
+
const filter = `Status IN ('Active','Idle') AND Config LIKE '%maxSessionDeadlineIso%'`;
|
|
188
|
+
const isExpired = (session: MJAIAgentSessionEntity): boolean => {
|
|
189
|
+
const deadlineMs = this.parseSessionDeadlineMs(session.Config_);
|
|
190
|
+
return deadlineMs != null && deadlineMs <= nowMs;
|
|
191
|
+
};
|
|
192
|
+
const closed = await this.sweepAndClose(filter, provider, systemUser, 'Janitor', isExpired);
|
|
193
|
+
if (closed > 0) {
|
|
194
|
+
LogStatus(`[SessionJanitor] Max-duration sweep closed ${closed} session(s) past their hard duration cap`);
|
|
195
|
+
}
|
|
196
|
+
return closed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Parses the absolute deadline (ms) from a session's `Config_` JSON, or null when absent/malformed. */
|
|
200
|
+
private parseSessionDeadlineMs(configJson: string | null | undefined): number | null {
|
|
201
|
+
if (!configJson) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(configJson) as { maxSessionDeadlineIso?: string };
|
|
206
|
+
if (typeof parsed.maxSessionDeadlineIso !== 'string') {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const ms = Date.parse(parsed.maxSessionDeadlineIso);
|
|
210
|
+
return Number.isNaN(ms) ? null : ms;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
173
216
|
// ----- internals -------------------------------------------------------------------------
|
|
174
217
|
|
|
175
218
|
/** Register for graceful shutdown exactly once. */
|
|
@@ -197,6 +240,9 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
|
|
|
197
240
|
this._sweepRunning = true;
|
|
198
241
|
try {
|
|
199
242
|
await this.RunStalenessSweep(this._provider, this._systemUser);
|
|
243
|
+
// Hard wall-clock cap (public web-widget voice). Runs alongside the idle sweep so a capped
|
|
244
|
+
// session is finalized within one sweep interval of its deadline even if it's still "active".
|
|
245
|
+
await this.RunMaxDurationSweep(this._provider, this._systemUser);
|
|
200
246
|
} catch (err) {
|
|
201
247
|
LogError(`SessionJanitor periodic sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
202
248
|
} finally {
|
|
@@ -215,19 +261,33 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
|
|
|
215
261
|
provider: IMetadataProvider,
|
|
216
262
|
systemUser: UserInfo,
|
|
217
263
|
closeReason: SessionCloseReason,
|
|
264
|
+
shouldClose?: (session: MJAIAgentSessionEntity) => boolean,
|
|
218
265
|
): Promise<number> {
|
|
219
266
|
let closedCount = 0;
|
|
220
267
|
let afterKey: CompositeKey | undefined;
|
|
221
268
|
|
|
222
269
|
// eslint-disable-next-line no-constant-condition
|
|
223
270
|
while (true) {
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
271
|
+
const fetched = await this.fetchPage(filter, afterKey, provider, systemUser);
|
|
272
|
+
if (fetched == null) {
|
|
226
273
|
break; // load failure already logged
|
|
227
274
|
}
|
|
228
|
-
if (
|
|
275
|
+
if (fetched.length === 0) {
|
|
229
276
|
break;
|
|
230
277
|
}
|
|
278
|
+
// Advance the keyset BEFORE the optional JS predicate narrows the page (we must page by the
|
|
279
|
+
// SQL-matched set, not the post-filtered subset, or pagination would stall/skip).
|
|
280
|
+
const lastFetchedId = fetched[fetched.length - 1].ID;
|
|
281
|
+
const fetchedCount = fetched.length;
|
|
282
|
+
// Optional in-JS narrowing (e.g. exact deadline check the SQL pre-filter can't express).
|
|
283
|
+
const page = shouldClose ? fetched.filter(shouldClose) : fetched;
|
|
284
|
+
if (page.length === 0) {
|
|
285
|
+
if (fetchedCount < SWEEP_PAGE_SIZE) {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
afterKey = CompositeKey.FromID(lastFetchedId);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
231
291
|
// Batch-load every channel for this whole page of sessions in ONE query, then hand each
|
|
232
292
|
// session its own slice to CloseSession — avoids the N+1 channel read (one RunView per
|
|
233
293
|
// closing session) that tripped the sequential / multiple-same-entity telemetry.
|
|
@@ -248,10 +308,10 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
|
|
|
248
308
|
closedCount++;
|
|
249
309
|
}
|
|
250
310
|
}
|
|
251
|
-
if (
|
|
252
|
-
break; // partial page => end of data
|
|
311
|
+
if (fetchedCount < SWEEP_PAGE_SIZE) {
|
|
312
|
+
break; // partial page (by the SQL-matched set) => end of data
|
|
253
313
|
}
|
|
254
|
-
afterKey = CompositeKey.FromID(
|
|
314
|
+
afterKey = CompositeKey.FromID(lastFetchedId);
|
|
255
315
|
}
|
|
256
316
|
return closedCount;
|
|
257
317
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
|
|
8
8
|
import { RealtimeClientSessionService, RealtimeChannelServerHost } from '@memberjunction/ai-agents';
|
|
9
9
|
import { GetHostInstanceID } from './HostInstance.js';
|
|
10
|
+
import { writeReturningVisitorRecap } from './ReturningVisitorRecap.js';
|
|
10
11
|
|
|
11
12
|
/** Entity names — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
|
|
12
13
|
const SESSION_ENTITY = 'MJ: AI Agent Sessions';
|
|
@@ -160,6 +161,10 @@ export class SessionManager {
|
|
|
160
161
|
await this.disconnectChannels(agentSessionID, contextUser, provider, preloadedChannels);
|
|
161
162
|
await this.finalizeObservabilityRuns(session, contextUser, provider);
|
|
162
163
|
await this.notifyChannelPluginsSessionClosed(agentSessionID, closeReason);
|
|
164
|
+
// Returning-visitor recap (RV2): if this session's conversation is returning-visitor-enabled,
|
|
165
|
+
// summarize it into an Active memory note so the visitor's next session opens with prior context.
|
|
166
|
+
// Best-effort + no-op for non-returning-visitor conversations; never blocks teardown.
|
|
167
|
+
await writeReturningVisitorRecap(session.ConversationID, session.AgentID, contextUser, provider);
|
|
163
168
|
return true;
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -316,6 +321,31 @@ export class SessionManager {
|
|
|
316
321
|
conversation.NewRecord();
|
|
317
322
|
conversation.UserID = input.userID;
|
|
318
323
|
conversation.Name = 'Agent Session';
|
|
324
|
+
// For a magic-link-anonymous principal (e.g. a public web-widget voice guest), stamp the
|
|
325
|
+
// session's conversation with the signed per-session scope so the Widget Guest RLS filters
|
|
326
|
+
// ({{ScopeResourceID}}) isolate this session — and everything chained to it (AI Agent
|
|
327
|
+
// Sessions via ConversationID, Session Channels via the session) — from other guests
|
|
328
|
+
// sharing the Anonymous UserID. No-op for named principals (no scope → default ExternalID).
|
|
329
|
+
if (contextUser.MagicLinkScope?.ResourceID) {
|
|
330
|
+
conversation.ExternalID = contextUser.MagicLinkScope.ResourceID;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Returning-visitor memory (RV1/RV2/RV4) for the VOICE path: the widget guest token carries the
|
|
334
|
+
// resolved VisitorKey / prior-conversation / linked identity (surfaced on ReturningVisitorContext),
|
|
335
|
+
// so a server-created voice conversation gets the same anchor the text path stamps client-side.
|
|
336
|
+
// The resolved counterparty reuses the existing polymorphic LinkedEntityID/LinkedRecordID pair.
|
|
337
|
+
// Absent for non-widget sessions and widgets with returning-visitor memory off — a no-op default.
|
|
338
|
+
const visitor = contextUser.ReturningVisitorContext;
|
|
339
|
+
if (visitor?.VisitorKey) {
|
|
340
|
+
conversation.VisitorKey = visitor.VisitorKey;
|
|
341
|
+
if (visitor.LastConversationID) {
|
|
342
|
+
conversation.LastConversationID = visitor.LastConversationID;
|
|
343
|
+
}
|
|
344
|
+
if (visitor.LinkedEntityID && visitor.LinkedRecordID) {
|
|
345
|
+
conversation.LinkedEntityID = visitor.LinkedEntityID;
|
|
346
|
+
conversation.LinkedRecordID = visitor.LinkedRecordID;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
319
349
|
|
|
320
350
|
const saved = await conversation.Save();
|
|
321
351
|
if (!saved) {
|
|
@@ -343,6 +373,14 @@ export class SessionManager {
|
|
|
343
373
|
session.HostInstanceID = GetHostInstanceID();
|
|
344
374
|
session.Config_ = input.config ?? null;
|
|
345
375
|
session.LastActiveAt = new Date();
|
|
376
|
+
// Mirror the conversation's resolved counterparty onto the session's own polymorphic pair, so a
|
|
377
|
+
// session carries its linked identity directly (parallels Conversation.LinkedEntityID/RecordID).
|
|
378
|
+
// Sourced from the same widget visitor context; a no-op for non-widget / anonymous sessions.
|
|
379
|
+
const visitor = contextUser.ReturningVisitorContext;
|
|
380
|
+
if (visitor?.LinkedEntityID && visitor.LinkedRecordID) {
|
|
381
|
+
session.LinkedEntityID = visitor.LinkedEntityID;
|
|
382
|
+
session.LinkedRecordID = visitor.LinkedRecordID;
|
|
383
|
+
}
|
|
346
384
|
|
|
347
385
|
const saved = await session.Save();
|
|
348
386
|
if (!saved) {
|
|
@@ -177,6 +177,23 @@ export function createMagicLinkHandler(publicUrl: string, config: MagicLinkConfi
|
|
|
177
177
|
return { publicRouter, authenticatedRouter };
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* A minimal PUBLIC router that serves ONLY the JWKS endpoint (the magic-link
|
|
182
|
+
* signing key's public half). Mount this when the full magic-link flow is
|
|
183
|
+
* disabled but another feature reuses the same RS256 key + `magic-link` auth
|
|
184
|
+
* provider — today, the public web widget. Without the published public key the
|
|
185
|
+
* unified auth middleware cannot validate the reused-key guest tokens and every
|
|
186
|
+
* request 401s. Assumes the key is already initialized (the widget handler does
|
|
187
|
+
* this via `MagicLinkKeyManager.Instance.Initialize` at startup).
|
|
188
|
+
*/
|
|
189
|
+
export function createMagicLinkJwksRouter(): Router {
|
|
190
|
+
const router = Router();
|
|
191
|
+
router.get('/jwks.json', (_req: Request, res: Response) => {
|
|
192
|
+
res.status(200).json(MagicLinkKeyManager.Instance.GetJWKS());
|
|
193
|
+
});
|
|
194
|
+
return router;
|
|
195
|
+
}
|
|
196
|
+
|
|
180
197
|
/**
|
|
181
198
|
* Registers the `magic-link` auth provider so MJServer's issuer-driven JWT
|
|
182
199
|
* validation accepts MJ-issued session tokens. Issuer = public URL, JWKS URL =
|
|
@@ -640,6 +640,13 @@ export class MagicLinkService {
|
|
|
640
640
|
* a single atomic operation. Returns true ONLY when this call updated the row
|
|
641
641
|
* (won the race); concurrent redemptions of a single-use link see 0 rows.
|
|
642
642
|
*
|
|
643
|
+
* Dialect-aware: SQL Server uses an `OUTPUT`-into-table-variable win-detect;
|
|
644
|
+
* PostgreSQL uses `UPDATE … WHERE … RETURNING` (the WHERE guard is itself the
|
|
645
|
+
* atomic single-use gate). The platform is read from the provider itself
|
|
646
|
+
* (`PlatformKey`), not from process env, so this is correct even under a
|
|
647
|
+
* non-default per-connection provider. The invite ID always binds as a
|
|
648
|
+
* parameter (`@p0` / `$1`), never interpolated.
|
|
649
|
+
*
|
|
643
650
|
* Fail-closed: any DB error returns false and the redemption is rejected.
|
|
644
651
|
*/
|
|
645
652
|
private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<boolean> {
|
|
@@ -649,9 +656,12 @@ export class MagicLinkService {
|
|
|
649
656
|
LogError(`[MagicLink] Entity metadata for '${INVITE_ENTITY}' not found; cannot consume invite.`);
|
|
650
657
|
return false;
|
|
651
658
|
}
|
|
652
|
-
const
|
|
653
|
-
//
|
|
654
|
-
|
|
659
|
+
const isPg = provider.PlatformKey === 'postgresql';
|
|
660
|
+
// PG identifiers are auto-quoted by PostgreSQLDataProvider.ExecuteSQL, so
|
|
661
|
+
// pass the bare `schema.table`; SQL Server takes the bracket-quoted form.
|
|
662
|
+
const table = isPg ? `${entityInfo.SchemaName}.${entityInfo.BaseTable}` : `[${entityInfo.SchemaName}].[${entityInfo.BaseTable}]`;
|
|
663
|
+
// OUTPUT/RETURNING yields one row iff the WHERE matched — the atomic single-use gate.
|
|
664
|
+
const sql = buildConsumeInviteSQL(table, isPg ? 'postgresql' : 'sqlserver');
|
|
655
665
|
const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true }, contextUser);
|
|
656
666
|
return Array.isArray(rows) && rows.length === 1;
|
|
657
667
|
} catch (e) {
|
|
@@ -11,6 +11,7 @@ export { MagicLinkKeyManager } from './MagicLinkKeys.js';
|
|
|
11
11
|
export { MagicLinkService } from './MagicLinkService.js';
|
|
12
12
|
export {
|
|
13
13
|
createMagicLinkHandler,
|
|
14
|
+
createMagicLinkJwksRouter,
|
|
14
15
|
registerMagicLinkAuthProvider,
|
|
15
16
|
MAGIC_LINK_MOUNT_PATH,
|
|
16
17
|
MAGIC_LINK_JWKS_PATH,
|