@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.
Files changed (267) hide show
  1. package/dist/agentSessions/ReturningVisitorRecap.d.ts +39 -0
  2. package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -0
  3. package/dist/agentSessions/ReturningVisitorRecap.js +198 -0
  4. package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -0
  5. package/dist/agentSessions/SessionJanitor.d.ts +13 -0
  6. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  7. package/dist/agentSessions/SessionJanitor.js +65 -7
  8. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  9. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  10. package/dist/agentSessions/SessionManager.js +37 -0
  11. package/dist/agentSessions/SessionManager.js.map +1 -1
  12. package/dist/auth/magicLink/MagicLinkRouter.d.ts +10 -0
  13. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
  14. package/dist/auth/magicLink/MagicLinkRouter.js +16 -0
  15. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  16. package/dist/auth/magicLink/MagicLinkService.d.ts +7 -0
  17. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  18. package/dist/auth/magicLink/MagicLinkService.js +13 -3
  19. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  20. package/dist/auth/magicLink/index.d.ts +1 -1
  21. package/dist/auth/magicLink/index.d.ts.map +1 -1
  22. package/dist/auth/magicLink/index.js +1 -1
  23. package/dist/auth/magicLink/index.js.map +1 -1
  24. package/dist/auth/magicLink/magicLinkCore.d.ts +38 -1
  25. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  26. package/dist/auth/magicLink/magicLinkCore.js +42 -19
  27. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  28. package/dist/auth/magicLink/types.d.ts +26 -0
  29. package/dist/auth/magicLink/types.d.ts.map +1 -1
  30. package/dist/config.d.ts +1883 -144
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +160 -36
  33. package/dist/config.js.map +1 -1
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/context.js +45 -2
  36. package/dist/context.js.map +1 -1
  37. package/dist/generated/generated.d.ts +522 -0
  38. package/dist/generated/generated.d.ts.map +1 -1
  39. package/dist/generated/generated.js +2860 -0
  40. package/dist/generated/generated.js.map +1 -1
  41. package/dist/generic/ResolverBase.d.ts +13 -0
  42. package/dist/generic/ResolverBase.d.ts.map +1 -1
  43. package/dist/generic/ResolverBase.js +22 -0
  44. package/dist/generic/ResolverBase.js.map +1 -1
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +4 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +0 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +101 -3
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  53. package/dist/integration/CustomColumnPromoter.js +6 -0
  54. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  55. package/dist/realtimeWidget/WidgetRouter.d.ts +26 -0
  56. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -0
  57. package/dist/realtimeWidget/WidgetRouter.js +182 -0
  58. package/dist/realtimeWidget/WidgetRouter.js.map +1 -0
  59. package/dist/realtimeWidget/WidgetSessionService.d.ts +265 -0
  60. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -0
  61. package/dist/realtimeWidget/WidgetSessionService.js +477 -0
  62. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -0
  63. package/dist/realtimeWidget/host-identity.d.ts +40 -0
  64. package/dist/realtimeWidget/host-identity.d.ts.map +1 -0
  65. package/dist/realtimeWidget/host-identity.js +58 -0
  66. package/dist/realtimeWidget/host-identity.js.map +1 -0
  67. package/dist/realtimeWidget/index.d.ts +10 -0
  68. package/dist/realtimeWidget/index.d.ts.map +1 -0
  69. package/dist/realtimeWidget/index.js +9 -0
  70. package/dist/realtimeWidget/index.js.map +1 -0
  71. package/dist/realtimeWidget/visitorIdentity.d.ts +76 -0
  72. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -0
  73. package/dist/realtimeWidget/visitorIdentity.js +202 -0
  74. package/dist/realtimeWidget/visitorIdentity.js.map +1 -0
  75. package/dist/realtimeWidget/widgetCore.d.ts +106 -0
  76. package/dist/realtimeWidget/widgetCore.d.ts.map +1 -0
  77. package/dist/realtimeWidget/widgetCore.js +173 -0
  78. package/dist/realtimeWidget/widgetCore.js.map +1 -0
  79. package/dist/realtimeWidget/widgetGuestElevation.d.ts +51 -0
  80. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -0
  81. package/dist/realtimeWidget/widgetGuestElevation.js +79 -0
  82. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -0
  83. package/dist/resolvers/ComponentRegistryResolver.d.ts +7 -0
  84. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  85. package/dist/resolvers/ComponentRegistryResolver.js +31 -8
  86. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  87. package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -1
  88. package/dist/resolvers/EntityPermissionResolver.js +1 -2
  89. package/dist/resolvers/EntityPermissionResolver.js.map +1 -1
  90. package/dist/resolvers/QuerySystemUserResolver.d.ts +9 -6
  91. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.js +15 -13
  93. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  94. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -0
  95. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  96. package/dist/resolvers/RealtimeClientSessionResolver.js +47 -3
  97. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  98. package/dist/resolvers/RingCentralTelephonyResolver.d.ts +27 -0
  99. package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +1 -0
  100. package/dist/resolvers/RingCentralTelephonyResolver.js +87 -0
  101. package/dist/resolvers/RingCentralTelephonyResolver.js.map +1 -0
  102. package/dist/resolvers/RunAIAgentResolver.d.ts +9 -0
  103. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RunAIAgentResolver.js +20 -4
  105. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  106. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  107. package/dist/resolvers/SearchKnowledgeResolver.js +2 -0
  108. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  109. package/dist/resolvers/TeamsMeetingsResolver.d.ts +28 -0
  110. package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +1 -0
  111. package/dist/resolvers/TeamsMeetingsResolver.js +91 -0
  112. package/dist/resolvers/TeamsMeetingsResolver.js.map +1 -0
  113. package/dist/resolvers/TelephonyResolver.d.ts +26 -0
  114. package/dist/resolvers/TelephonyResolver.d.ts.map +1 -0
  115. package/dist/resolvers/TelephonyResolver.js +86 -0
  116. package/dist/resolvers/TelephonyResolver.js.map +1 -0
  117. package/dist/resolvers/TestQuerySQLResolver.d.ts +1 -1
  118. package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
  119. package/dist/resolvers/TestQuerySQLResolver.js +2 -3
  120. package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
  121. package/dist/resolvers/VonageTelephonyResolver.d.ts +26 -0
  122. package/dist/resolvers/VonageTelephonyResolver.d.ts.map +1 -0
  123. package/dist/resolvers/VonageTelephonyResolver.js +86 -0
  124. package/dist/resolvers/VonageTelephonyResolver.js.map +1 -0
  125. package/dist/rest/MediaStreamHandler.js +4 -1
  126. package/dist/rest/MediaStreamHandler.js.map +1 -1
  127. package/dist/telephony/RingCentralTelephonyService.d.ts +115 -0
  128. package/dist/telephony/RingCentralTelephonyService.d.ts.map +1 -0
  129. package/dist/telephony/RingCentralTelephonyService.js +262 -0
  130. package/dist/telephony/RingCentralTelephonyService.js.map +1 -0
  131. package/dist/telephony/TeamsMeetingsRouter.d.ts +40 -0
  132. package/dist/telephony/TeamsMeetingsRouter.d.ts.map +1 -0
  133. package/dist/telephony/TeamsMeetingsRouter.js +96 -0
  134. package/dist/telephony/TeamsMeetingsRouter.js.map +1 -0
  135. package/dist/telephony/TeamsMeetingsService.d.ts +113 -0
  136. package/dist/telephony/TeamsMeetingsService.d.ts.map +1 -0
  137. package/dist/telephony/TeamsMeetingsService.js +196 -0
  138. package/dist/telephony/TeamsMeetingsService.js.map +1 -0
  139. package/dist/telephony/TwilioTelephonyRouter.d.ts +36 -0
  140. package/dist/telephony/TwilioTelephonyRouter.d.ts.map +1 -0
  141. package/dist/telephony/TwilioTelephonyRouter.js +143 -0
  142. package/dist/telephony/TwilioTelephonyRouter.js.map +1 -0
  143. package/dist/telephony/TwilioTelephonyService.d.ts +95 -0
  144. package/dist/telephony/TwilioTelephonyService.d.ts.map +1 -0
  145. package/dist/telephony/TwilioTelephonyService.js +193 -0
  146. package/dist/telephony/TwilioTelephonyService.js.map +1 -0
  147. package/dist/telephony/VonageTelephonyRouter.d.ts +41 -0
  148. package/dist/telephony/VonageTelephonyRouter.d.ts.map +1 -0
  149. package/dist/telephony/VonageTelephonyRouter.js +201 -0
  150. package/dist/telephony/VonageTelephonyRouter.js.map +1 -0
  151. package/dist/telephony/VonageTelephonyService.d.ts +90 -0
  152. package/dist/telephony/VonageTelephonyService.d.ts.map +1 -0
  153. package/dist/telephony/VonageTelephonyService.js +191 -0
  154. package/dist/telephony/VonageTelephonyService.js.map +1 -0
  155. package/dist/telephony/calendar-scheduler.d.ts +67 -0
  156. package/dist/telephony/calendar-scheduler.d.ts.map +1 -0
  157. package/dist/telephony/calendar-scheduler.js +133 -0
  158. package/dist/telephony/calendar-scheduler.js.map +1 -0
  159. package/dist/telephony/index.d.ts +29 -0
  160. package/dist/telephony/index.d.ts.map +1 -0
  161. package/dist/telephony/index.js +38 -0
  162. package/dist/telephony/index.js.map +1 -0
  163. package/dist/telephony/media-upgrade-router.d.ts +33 -0
  164. package/dist/telephony/media-upgrade-router.d.ts.map +1 -0
  165. package/dist/telephony/media-upgrade-router.js +56 -0
  166. package/dist/telephony/media-upgrade-router.js.map +1 -0
  167. package/dist/telephony/ringcentral-runtime.d.ts +14 -0
  168. package/dist/telephony/ringcentral-runtime.d.ts.map +1 -0
  169. package/dist/telephony/ringcentral-runtime.js +18 -0
  170. package/dist/telephony/ringcentral-runtime.js.map +1 -0
  171. package/dist/telephony/teams-meetings-runtime.d.ts +16 -0
  172. package/dist/telephony/teams-meetings-runtime.d.ts.map +1 -0
  173. package/dist/telephony/teams-meetings-runtime.js +20 -0
  174. package/dist/telephony/teams-meetings-runtime.js.map +1 -0
  175. package/dist/telephony/teamsAcsMediaRegistry.d.ts +69 -0
  176. package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +1 -0
  177. package/dist/telephony/teamsAcsMediaRegistry.js +118 -0
  178. package/dist/telephony/teamsAcsMediaRegistry.js.map +1 -0
  179. package/dist/telephony/telephony-runtime.d.ts +14 -0
  180. package/dist/telephony/telephony-runtime.d.ts.map +1 -0
  181. package/dist/telephony/telephony-runtime.js +18 -0
  182. package/dist/telephony/telephony-runtime.js.map +1 -0
  183. package/dist/telephony/twilioMediaRegistry.d.ts +60 -0
  184. package/dist/telephony/twilioMediaRegistry.d.ts.map +1 -0
  185. package/dist/telephony/twilioMediaRegistry.js +106 -0
  186. package/dist/telephony/twilioMediaRegistry.js.map +1 -0
  187. package/dist/telephony/vonage-runtime.d.ts +14 -0
  188. package/dist/telephony/vonage-runtime.d.ts.map +1 -0
  189. package/dist/telephony/vonage-runtime.js +18 -0
  190. package/dist/telephony/vonage-runtime.js.map +1 -0
  191. package/dist/telephony/vonageMediaRegistry.d.ts +78 -0
  192. package/dist/telephony/vonageMediaRegistry.d.ts.map +1 -0
  193. package/dist/telephony/vonageMediaRegistry.js +158 -0
  194. package/dist/telephony/vonageMediaRegistry.js.map +1 -0
  195. package/package.json +88 -83
  196. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +128 -0
  197. package/src/__tests__/magicLink.test.ts +53 -3
  198. package/src/__tests__/search-knowledge-tags.test.ts +9 -9
  199. package/src/__tests__/teams-meetings-service.test.ts +65 -0
  200. package/src/__tests__/teamsAcsMediaRegistry.test.ts +82 -0
  201. package/src/__tests__/twilio-telephony-service.test.ts +23 -0
  202. package/src/__tests__/twilioMediaRegistry.test.ts +103 -0
  203. package/src/__tests__/vonageMediaRegistry.test.ts +180 -0
  204. package/src/__tests__/widget.test.ts +204 -0
  205. package/src/__tests__/widgetGuestElevation.test.ts +57 -0
  206. package/src/__tests__/widgetHostIdentity.test.ts +117 -0
  207. package/src/agentSessions/ReturningVisitorRecap.ts +242 -0
  208. package/src/agentSessions/SessionJanitor.ts +66 -6
  209. package/src/agentSessions/SessionManager.ts +38 -0
  210. package/src/auth/magicLink/MagicLinkRouter.ts +17 -0
  211. package/src/auth/magicLink/MagicLinkService.ts +13 -3
  212. package/src/auth/magicLink/index.ts +1 -0
  213. package/src/auth/magicLink/magicLinkCore.ts +48 -20
  214. package/src/auth/magicLink/types.ts +26 -0
  215. package/src/config.ts +172 -38
  216. package/src/context.ts +50 -3
  217. package/src/generated/generated.ts +1991 -1
  218. package/src/generic/ResolverBase.ts +28 -0
  219. package/src/generic/RunViewResolver.ts +4 -0
  220. package/src/index.ts +109 -3
  221. package/src/integration/CustomColumnPromoter.ts +6 -0
  222. package/src/realtimeWidget/WidgetRouter.ts +204 -0
  223. package/src/realtimeWidget/WidgetSessionService.ts +714 -0
  224. package/src/realtimeWidget/host-identity.ts +84 -0
  225. package/src/realtimeWidget/index.ts +15 -0
  226. package/src/realtimeWidget/visitorIdentity.ts +258 -0
  227. package/src/realtimeWidget/widgetCore.ts +231 -0
  228. package/src/realtimeWidget/widgetGuestElevation.ts +115 -0
  229. package/src/resolvers/ComponentRegistryResolver.ts +36 -10
  230. package/src/resolvers/EntityPermissionResolver.ts +1 -2
  231. package/src/resolvers/QuerySystemUserResolver.ts +14 -10
  232. package/src/resolvers/RealtimeClientSessionResolver.ts +59 -2
  233. package/src/resolvers/RingCentralTelephonyResolver.ts +64 -0
  234. package/src/resolvers/RunAIAgentResolver.ts +32 -8
  235. package/src/resolvers/SearchKnowledgeResolver.ts +2 -0
  236. package/src/resolvers/TeamsMeetingsResolver.ts +68 -0
  237. package/src/resolvers/TelephonyResolver.ts +63 -0
  238. package/src/resolvers/TestQuerySQLResolver.ts +2 -3
  239. package/src/resolvers/VonageTelephonyResolver.ts +63 -0
  240. package/src/rest/MediaStreamHandler.ts +4 -1
  241. package/src/telephony/RingCentralTelephonyService.ts +342 -0
  242. package/src/telephony/TeamsMeetingsRouter.ts +124 -0
  243. package/src/telephony/TeamsMeetingsService.ts +268 -0
  244. package/src/telephony/TwilioTelephonyRouter.ts +184 -0
  245. package/src/telephony/TwilioTelephonyService.ts +261 -0
  246. package/src/telephony/VonageTelephonyRouter.ts +239 -0
  247. package/src/telephony/VonageTelephonyService.ts +259 -0
  248. package/src/telephony/calendar-scheduler.ts +176 -0
  249. package/src/telephony/index.ts +43 -0
  250. package/src/telephony/media-upgrade-router.ts +62 -0
  251. package/src/telephony/ringcentral-runtime.ts +22 -0
  252. package/src/telephony/teams-meetings-runtime.ts +24 -0
  253. package/src/telephony/teamsAcsMediaRegistry.ts +152 -0
  254. package/src/telephony/telephony-runtime.ts +22 -0
  255. package/src/telephony/twilioMediaRegistry.ts +137 -0
  256. package/src/telephony/vonage-runtime.ts +22 -0
  257. package/src/telephony/vonageMediaRegistry.ts +200 -0
  258. package/dist/agents/skip-agent.d.ts +0 -111
  259. package/dist/agents/skip-agent.d.ts.map +0 -1
  260. package/dist/agents/skip-agent.js +0 -1487
  261. package/dist/agents/skip-agent.js.map +0 -1
  262. package/dist/agents/skip-sdk.d.ts +0 -261
  263. package/dist/agents/skip-sdk.d.ts.map +0 -1
  264. package/dist/agents/skip-sdk.js +0 -909
  265. package/dist/agents/skip-sdk.js.map +0 -1
  266. package/src/agents/skip-agent.ts +0 -1594
  267. 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 page = await this.fetchPage(filter, afterKey, provider, systemUser);
225
- if (page == null) {
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 (page.length === 0) {
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 (page.length < SWEEP_PAGE_SIZE) {
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(page[page.length - 1].ID);
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 table = `[${entityInfo.SchemaName}].[${entityInfo.BaseTable}]`;
653
- // OUTPUT returns one row iff the WHERE matched — the atomic single-use gate.
654
- const sql = buildConsumeInviteSQL(table);
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,