@memberjunction/server 5.44.0 → 5.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) 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/index.d.ts +1 -1
  17. package/dist/auth/magicLink/index.d.ts.map +1 -1
  18. package/dist/auth/magicLink/index.js +1 -1
  19. package/dist/auth/magicLink/index.js.map +1 -1
  20. package/dist/auth/magicLink/types.d.ts +26 -0
  21. package/dist/auth/magicLink/types.d.ts.map +1 -1
  22. package/dist/config.d.ts +1883 -144
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +160 -36
  25. package/dist/config.js.map +1 -1
  26. package/dist/context.d.ts.map +1 -1
  27. package/dist/context.js +45 -2
  28. package/dist/context.js.map +1 -1
  29. package/dist/generated/generated.d.ts +522 -0
  30. package/dist/generated/generated.d.ts.map +1 -1
  31. package/dist/generated/generated.js +2860 -0
  32. package/dist/generated/generated.js.map +1 -1
  33. package/dist/generic/ResolverBase.d.ts +13 -0
  34. package/dist/generic/ResolverBase.d.ts.map +1 -1
  35. package/dist/generic/ResolverBase.js +22 -0
  36. package/dist/generic/ResolverBase.js.map +1 -1
  37. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  38. package/dist/generic/RunViewResolver.js +4 -0
  39. package/dist/generic/RunViewResolver.js.map +1 -1
  40. package/dist/index.d.ts +0 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +101 -3
  43. package/dist/index.js.map +1 -1
  44. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  45. package/dist/integration/CustomColumnPromoter.js +6 -0
  46. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  47. package/dist/realtimeWidget/WidgetRouter.d.ts +26 -0
  48. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -0
  49. package/dist/realtimeWidget/WidgetRouter.js +182 -0
  50. package/dist/realtimeWidget/WidgetRouter.js.map +1 -0
  51. package/dist/realtimeWidget/WidgetSessionService.d.ts +265 -0
  52. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -0
  53. package/dist/realtimeWidget/WidgetSessionService.js +477 -0
  54. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -0
  55. package/dist/realtimeWidget/host-identity.d.ts +40 -0
  56. package/dist/realtimeWidget/host-identity.d.ts.map +1 -0
  57. package/dist/realtimeWidget/host-identity.js +58 -0
  58. package/dist/realtimeWidget/host-identity.js.map +1 -0
  59. package/dist/realtimeWidget/index.d.ts +10 -0
  60. package/dist/realtimeWidget/index.d.ts.map +1 -0
  61. package/dist/realtimeWidget/index.js +9 -0
  62. package/dist/realtimeWidget/index.js.map +1 -0
  63. package/dist/realtimeWidget/visitorIdentity.d.ts +76 -0
  64. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -0
  65. package/dist/realtimeWidget/visitorIdentity.js +202 -0
  66. package/dist/realtimeWidget/visitorIdentity.js.map +1 -0
  67. package/dist/realtimeWidget/widgetCore.d.ts +106 -0
  68. package/dist/realtimeWidget/widgetCore.d.ts.map +1 -0
  69. package/dist/realtimeWidget/widgetCore.js +173 -0
  70. package/dist/realtimeWidget/widgetCore.js.map +1 -0
  71. package/dist/realtimeWidget/widgetGuestElevation.d.ts +51 -0
  72. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -0
  73. package/dist/realtimeWidget/widgetGuestElevation.js +79 -0
  74. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -0
  75. package/dist/resolvers/ComponentRegistryResolver.d.ts +7 -0
  76. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  77. package/dist/resolvers/ComponentRegistryResolver.js +31 -8
  78. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  79. package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -1
  80. package/dist/resolvers/EntityPermissionResolver.js +1 -2
  81. package/dist/resolvers/EntityPermissionResolver.js.map +1 -1
  82. package/dist/resolvers/QuerySystemUserResolver.d.ts +9 -6
  83. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  84. package/dist/resolvers/QuerySystemUserResolver.js +15 -13
  85. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  86. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -0
  87. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  88. package/dist/resolvers/RealtimeClientSessionResolver.js +47 -3
  89. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  90. package/dist/resolvers/RingCentralTelephonyResolver.d.ts +27 -0
  91. package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +1 -0
  92. package/dist/resolvers/RingCentralTelephonyResolver.js +87 -0
  93. package/dist/resolvers/RingCentralTelephonyResolver.js.map +1 -0
  94. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  95. package/dist/resolvers/RunAIAgentResolver.js +14 -3
  96. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  97. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  98. package/dist/resolvers/SearchKnowledgeResolver.js +2 -0
  99. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  100. package/dist/resolvers/TeamsMeetingsResolver.d.ts +28 -0
  101. package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +1 -0
  102. package/dist/resolvers/TeamsMeetingsResolver.js +91 -0
  103. package/dist/resolvers/TeamsMeetingsResolver.js.map +1 -0
  104. package/dist/resolvers/TelephonyResolver.d.ts +26 -0
  105. package/dist/resolvers/TelephonyResolver.d.ts.map +1 -0
  106. package/dist/resolvers/TelephonyResolver.js +86 -0
  107. package/dist/resolvers/TelephonyResolver.js.map +1 -0
  108. package/dist/resolvers/TestQuerySQLResolver.d.ts +1 -1
  109. package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
  110. package/dist/resolvers/TestQuerySQLResolver.js +2 -3
  111. package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
  112. package/dist/resolvers/VonageTelephonyResolver.d.ts +26 -0
  113. package/dist/resolvers/VonageTelephonyResolver.d.ts.map +1 -0
  114. package/dist/resolvers/VonageTelephonyResolver.js +86 -0
  115. package/dist/resolvers/VonageTelephonyResolver.js.map +1 -0
  116. package/dist/rest/MediaStreamHandler.js +4 -1
  117. package/dist/rest/MediaStreamHandler.js.map +1 -1
  118. package/dist/telephony/RingCentralTelephonyService.d.ts +115 -0
  119. package/dist/telephony/RingCentralTelephonyService.d.ts.map +1 -0
  120. package/dist/telephony/RingCentralTelephonyService.js +262 -0
  121. package/dist/telephony/RingCentralTelephonyService.js.map +1 -0
  122. package/dist/telephony/TeamsMeetingsRouter.d.ts +40 -0
  123. package/dist/telephony/TeamsMeetingsRouter.d.ts.map +1 -0
  124. package/dist/telephony/TeamsMeetingsRouter.js +96 -0
  125. package/dist/telephony/TeamsMeetingsRouter.js.map +1 -0
  126. package/dist/telephony/TeamsMeetingsService.d.ts +113 -0
  127. package/dist/telephony/TeamsMeetingsService.d.ts.map +1 -0
  128. package/dist/telephony/TeamsMeetingsService.js +196 -0
  129. package/dist/telephony/TeamsMeetingsService.js.map +1 -0
  130. package/dist/telephony/TwilioTelephonyRouter.d.ts +36 -0
  131. package/dist/telephony/TwilioTelephonyRouter.d.ts.map +1 -0
  132. package/dist/telephony/TwilioTelephonyRouter.js +143 -0
  133. package/dist/telephony/TwilioTelephonyRouter.js.map +1 -0
  134. package/dist/telephony/TwilioTelephonyService.d.ts +95 -0
  135. package/dist/telephony/TwilioTelephonyService.d.ts.map +1 -0
  136. package/dist/telephony/TwilioTelephonyService.js +193 -0
  137. package/dist/telephony/TwilioTelephonyService.js.map +1 -0
  138. package/dist/telephony/VonageTelephonyRouter.d.ts +41 -0
  139. package/dist/telephony/VonageTelephonyRouter.d.ts.map +1 -0
  140. package/dist/telephony/VonageTelephonyRouter.js +201 -0
  141. package/dist/telephony/VonageTelephonyRouter.js.map +1 -0
  142. package/dist/telephony/VonageTelephonyService.d.ts +90 -0
  143. package/dist/telephony/VonageTelephonyService.d.ts.map +1 -0
  144. package/dist/telephony/VonageTelephonyService.js +191 -0
  145. package/dist/telephony/VonageTelephonyService.js.map +1 -0
  146. package/dist/telephony/calendar-scheduler.d.ts +67 -0
  147. package/dist/telephony/calendar-scheduler.d.ts.map +1 -0
  148. package/dist/telephony/calendar-scheduler.js +133 -0
  149. package/dist/telephony/calendar-scheduler.js.map +1 -0
  150. package/dist/telephony/index.d.ts +29 -0
  151. package/dist/telephony/index.d.ts.map +1 -0
  152. package/dist/telephony/index.js +38 -0
  153. package/dist/telephony/index.js.map +1 -0
  154. package/dist/telephony/media-upgrade-router.d.ts +33 -0
  155. package/dist/telephony/media-upgrade-router.d.ts.map +1 -0
  156. package/dist/telephony/media-upgrade-router.js +56 -0
  157. package/dist/telephony/media-upgrade-router.js.map +1 -0
  158. package/dist/telephony/ringcentral-runtime.d.ts +14 -0
  159. package/dist/telephony/ringcentral-runtime.d.ts.map +1 -0
  160. package/dist/telephony/ringcentral-runtime.js +18 -0
  161. package/dist/telephony/ringcentral-runtime.js.map +1 -0
  162. package/dist/telephony/teams-meetings-runtime.d.ts +16 -0
  163. package/dist/telephony/teams-meetings-runtime.d.ts.map +1 -0
  164. package/dist/telephony/teams-meetings-runtime.js +20 -0
  165. package/dist/telephony/teams-meetings-runtime.js.map +1 -0
  166. package/dist/telephony/teamsAcsMediaRegistry.d.ts +69 -0
  167. package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +1 -0
  168. package/dist/telephony/teamsAcsMediaRegistry.js +118 -0
  169. package/dist/telephony/teamsAcsMediaRegistry.js.map +1 -0
  170. package/dist/telephony/telephony-runtime.d.ts +14 -0
  171. package/dist/telephony/telephony-runtime.d.ts.map +1 -0
  172. package/dist/telephony/telephony-runtime.js +18 -0
  173. package/dist/telephony/telephony-runtime.js.map +1 -0
  174. package/dist/telephony/twilioMediaRegistry.d.ts +60 -0
  175. package/dist/telephony/twilioMediaRegistry.d.ts.map +1 -0
  176. package/dist/telephony/twilioMediaRegistry.js +106 -0
  177. package/dist/telephony/twilioMediaRegistry.js.map +1 -0
  178. package/dist/telephony/vonage-runtime.d.ts +14 -0
  179. package/dist/telephony/vonage-runtime.d.ts.map +1 -0
  180. package/dist/telephony/vonage-runtime.js +18 -0
  181. package/dist/telephony/vonage-runtime.js.map +1 -0
  182. package/dist/telephony/vonageMediaRegistry.d.ts +78 -0
  183. package/dist/telephony/vonageMediaRegistry.d.ts.map +1 -0
  184. package/dist/telephony/vonageMediaRegistry.js +158 -0
  185. package/dist/telephony/vonageMediaRegistry.js.map +1 -0
  186. package/package.json +88 -83
  187. package/src/__tests__/search-knowledge-tags.test.ts +9 -9
  188. package/src/__tests__/teams-meetings-service.test.ts +65 -0
  189. package/src/__tests__/teamsAcsMediaRegistry.test.ts +82 -0
  190. package/src/__tests__/twilio-telephony-service.test.ts +23 -0
  191. package/src/__tests__/twilioMediaRegistry.test.ts +103 -0
  192. package/src/__tests__/vonageMediaRegistry.test.ts +180 -0
  193. package/src/__tests__/widget.test.ts +204 -0
  194. package/src/__tests__/widgetGuestElevation.test.ts +57 -0
  195. package/src/__tests__/widgetHostIdentity.test.ts +117 -0
  196. package/src/agentSessions/ReturningVisitorRecap.ts +242 -0
  197. package/src/agentSessions/SessionJanitor.ts +66 -6
  198. package/src/agentSessions/SessionManager.ts +38 -0
  199. package/src/auth/magicLink/MagicLinkRouter.ts +17 -0
  200. package/src/auth/magicLink/index.ts +1 -0
  201. package/src/auth/magicLink/types.ts +26 -0
  202. package/src/config.ts +172 -38
  203. package/src/context.ts +50 -3
  204. package/src/generated/generated.ts +1991 -1
  205. package/src/generic/ResolverBase.ts +28 -0
  206. package/src/generic/RunViewResolver.ts +4 -0
  207. package/src/index.ts +109 -3
  208. package/src/integration/CustomColumnPromoter.ts +6 -0
  209. package/src/realtimeWidget/WidgetRouter.ts +204 -0
  210. package/src/realtimeWidget/WidgetSessionService.ts +714 -0
  211. package/src/realtimeWidget/host-identity.ts +84 -0
  212. package/src/realtimeWidget/index.ts +15 -0
  213. package/src/realtimeWidget/visitorIdentity.ts +258 -0
  214. package/src/realtimeWidget/widgetCore.ts +231 -0
  215. package/src/realtimeWidget/widgetGuestElevation.ts +115 -0
  216. package/src/resolvers/ComponentRegistryResolver.ts +36 -10
  217. package/src/resolvers/EntityPermissionResolver.ts +1 -2
  218. package/src/resolvers/QuerySystemUserResolver.ts +14 -10
  219. package/src/resolvers/RealtimeClientSessionResolver.ts +59 -2
  220. package/src/resolvers/RingCentralTelephonyResolver.ts +64 -0
  221. package/src/resolvers/RunAIAgentResolver.ts +16 -4
  222. package/src/resolvers/SearchKnowledgeResolver.ts +2 -0
  223. package/src/resolvers/TeamsMeetingsResolver.ts +68 -0
  224. package/src/resolvers/TelephonyResolver.ts +63 -0
  225. package/src/resolvers/TestQuerySQLResolver.ts +2 -3
  226. package/src/resolvers/VonageTelephonyResolver.ts +63 -0
  227. package/src/rest/MediaStreamHandler.ts +4 -1
  228. package/src/telephony/RingCentralTelephonyService.ts +342 -0
  229. package/src/telephony/TeamsMeetingsRouter.ts +124 -0
  230. package/src/telephony/TeamsMeetingsService.ts +268 -0
  231. package/src/telephony/TwilioTelephonyRouter.ts +184 -0
  232. package/src/telephony/TwilioTelephonyService.ts +261 -0
  233. package/src/telephony/VonageTelephonyRouter.ts +239 -0
  234. package/src/telephony/VonageTelephonyService.ts +259 -0
  235. package/src/telephony/calendar-scheduler.ts +176 -0
  236. package/src/telephony/index.ts +43 -0
  237. package/src/telephony/media-upgrade-router.ts +62 -0
  238. package/src/telephony/ringcentral-runtime.ts +22 -0
  239. package/src/telephony/teams-meetings-runtime.ts +24 -0
  240. package/src/telephony/teamsAcsMediaRegistry.ts +152 -0
  241. package/src/telephony/telephony-runtime.ts +22 -0
  242. package/src/telephony/twilioMediaRegistry.ts +137 -0
  243. package/src/telephony/vonage-runtime.ts +22 -0
  244. package/src/telephony/vonageMediaRegistry.ts +200 -0
  245. package/dist/agents/skip-agent.d.ts +0 -111
  246. package/dist/agents/skip-agent.d.ts.map +0 -1
  247. package/dist/agents/skip-agent.js +0 -1487
  248. package/dist/agents/skip-agent.js.map +0 -1
  249. package/dist/agents/skip-sdk.d.ts +0 -261
  250. package/dist/agents/skip-sdk.d.ts.map +0 -1
  251. package/dist/agents/skip-sdk.js +0 -909
  252. package/dist/agents/skip-sdk.js.map +0 -1
  253. package/src/agents/skip-agent.ts +0 -1594
  254. package/src/agents/skip-sdk.ts +0 -1210
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview Host-passed identity (D1 strategy `host-identity`). When a widget is
3
+ * embedded in an already-authenticated host portal, the host signs a short-lived
4
+ * assertion (its own RS256 key) describing the visitor; the widget posts it to
5
+ * `POST /widget/session`, which verifies it against the host's registered public key
6
+ * and folds the host-provided identity into the minted guest claims. The minted token
7
+ * is still validated by the same `magic-link` AuthProviderFactory path + synthesized by
8
+ * `buildMagicLinkSessionUser` — host-identity is a MINT-TIME strategy, not a second
9
+ * session-validation provider (it converges on the one pathway, per D1).
10
+ *
11
+ * Pure + unit-testable: verification takes the PEM as an argument.
12
+ *
13
+ * @module @memberjunction/server/widget
14
+ */
15
+
16
+ import type jwt from 'jsonwebtoken';
17
+ import { HostIdentityProvider, type HostAssertionError } from '@memberjunction/auth-providers';
18
+
19
+ export type { HostAssertionError };
20
+
21
+ /** The visitor identity a host asserts. Standard OIDC-ish claims so synthesis is uniform. */
22
+ export interface HostAssertedIdentity {
23
+ email: string;
24
+ firstName?: string;
25
+ lastName?: string;
26
+ /** The host's opaque user id for this visitor (audit correlation; not an MJ user id). */
27
+ hostUserId?: string;
28
+ }
29
+
30
+ /** Result of verifying a host assertion. `identity` is present iff `ok` is true. */
31
+ export interface HostAssertionResult {
32
+ ok: boolean;
33
+ identity?: HostAssertedIdentity;
34
+ errorCode?: HostAssertionError;
35
+ }
36
+
37
+ /**
38
+ * The single shared {@link HostIdentityProvider} instance the mint path verifies through. The base
39
+ * `BaseAuthProvider` constructor builds a (never-contacted) JWKS client; host-identity verifies against
40
+ * static per-widget PEM keys, so we pass a placeholder `jwksUri`. Built once — verification is stateless.
41
+ */
42
+ const hostIdentityProvider = new HostIdentityProvider({
43
+ name: 'host-identity',
44
+ type: 'host-identity',
45
+ issuer: 'host-identity',
46
+ audience: 'host-identity',
47
+ jwksUri: 'https://host-identity.local/unused',
48
+ });
49
+
50
+ /**
51
+ * Verifies a host-signed RS256 assertion against the host's registered public key (PEM) and extracts the
52
+ * asserted visitor identity. Thin adapter over {@link HostIdentityProvider.VerifyHostAssertion} (the single
53
+ * implementation, registered in the AuthProviderFactory) preserving this module's result shape. Never throws.
54
+ */
55
+ export function verifyHostAssertion(
56
+ assertion: string | undefined,
57
+ hostPublicKeyPem: string | undefined,
58
+ expectedAudience: string,
59
+ ): HostAssertionResult {
60
+ const result = hostIdentityProvider.VerifyHostAssertion(assertion, hostPublicKeyPem, expectedAudience);
61
+ if (!result.ok || !result.userInfo?.email) {
62
+ return { ok: false, errorCode: result.errorCode };
63
+ }
64
+ return {
65
+ ok: true,
66
+ identity: {
67
+ email: result.userInfo.email,
68
+ firstName: result.userInfo.firstName,
69
+ lastName: result.userInfo.lastName,
70
+ hostUserId: result.hostUserId,
71
+ },
72
+ };
73
+ }
74
+
75
+ /** Pulls the visitor identity from a verified host-assertion payload (delegates to the provider). */
76
+ export function extractHostIdentity(payload: jwt.JwtPayload): HostAssertedIdentity {
77
+ const info = hostIdentityProvider.extractUserInfo(payload);
78
+ return {
79
+ email: info.email ?? '',
80
+ firstName: info.firstName,
81
+ lastName: info.lastName,
82
+ hostUserId: typeof payload.sub === 'string' ? payload.sub : undefined,
83
+ };
84
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @fileoverview Public web widget guest-session module barrel.
3
+ * @module @memberjunction/server/widget
4
+ */
5
+ export * from './widgetCore.js';
6
+ export {
7
+ verifyHostAssertion,
8
+ extractHostIdentity,
9
+ type HostAssertedIdentity,
10
+ type HostAssertionResult,
11
+ type HostAssertionError,
12
+ } from './host-identity.js';
13
+ export { WidgetSessionService } from './WidgetSessionService.js';
14
+ export type { MintGuestSessionInput, MintGuestSessionResult, WidgetMintAuditContext } from './WidgetSessionService.js';
15
+ export { createWidgetHandler, WIDGET_MOUNT_PATH } from './WidgetRouter.js';
@@ -0,0 +1,258 @@
1
+ /**
2
+ * @fileoverview Returning-visitor identity resolution + merge (RV4) and "forget me" (RV5).
3
+ *
4
+ * A widget visitor starts anonymous: their memory (recap notes) is filed under the polymorphic pair
5
+ * `(MJ: Conversations entity, conversation.ID)` and chained across visits by the durable `VisitorKey`
6
+ * cookie (RV1/RV2). When the visitor later proves who they are — magic-link "verify it's you", or a
7
+ * host-asserted identity — RV4 promotes that anonymous trail to a real, deployment-configurable record:
8
+ *
9
+ * 1. resolve the verified email → a polymorphic `(entityId, recordId)` pair (NOT assumed to be a
10
+ * User — {@link resolveIdentityByEmail} honors the per-deployment `widget.identityResolution`
11
+ * target so a CRM `Persons` row works as well as the core `Users` row),
12
+ * 2. stamp the existing `LinkedEntityID/LinkedRecordID` on every conversation that shares the visitor's key
13
+ * in this application (back-fill), and
14
+ * 3. re-key the visitor's anonymous recap notes onto the resolved pair (merge), so the existing
15
+ * memory injector (which already filters by the PrimaryScope pair) now pulls the prior context
16
+ * via the resolved identity rather than the cookie chain.
17
+ *
18
+ * "Forget me" (RV5) is the inverse: archive the visitor's auto-generated memory and clear the
19
+ * `VisitorKey` linkage so neither the cookie nor a resolved pair resolves their trail anymore.
20
+ *
21
+ * Everything here is BEST-EFFORT and multi-provider-safe (it takes the request/session
22
+ * `IMetadataProvider`, never the global `Metadata`). A failure never blocks the auth flow.
23
+ *
24
+ * @module @memberjunction/server/widget
25
+ */
26
+
27
+ import { RunView, UserInfo, LogError, LogStatus, type IMetadataProvider } from '@memberjunction/core';
28
+ import type { MJConversationEntity, MJAIAgentNoteEntity } from '@memberjunction/core-entities';
29
+
30
+ const CONVERSATIONS_ENTITY = 'MJ: Conversations';
31
+ const AGENT_NOTES_ENTITY = 'MJ: AI Agent Notes';
32
+
33
+ /** A resolved polymorphic identity: the entity the visitor maps to, and that record's id. */
34
+ export interface ResolvedVisitorIdentity {
35
+ entityId: string;
36
+ recordId: string;
37
+ }
38
+
39
+ /** The deployment-configurable resolution target (defaults to the core `Users` entity keyed by `Email`). */
40
+ export interface IdentityResolutionTarget {
41
+ entityName?: string;
42
+ emailField?: string;
43
+ }
44
+
45
+ /**
46
+ * Resolves a verified email to a polymorphic `(entityId, recordId)` pair via the deployment-configured
47
+ * target (default: the `Users` entity keyed by `Email`). Returns undefined when no record matches — the
48
+ * visitor simply stays anonymous. Never throws; email is escaped for the filter literal.
49
+ */
50
+ export async function resolveIdentityByEmail(
51
+ email: string,
52
+ contextUser: UserInfo,
53
+ provider: IMetadataProvider,
54
+ target?: IdentityResolutionTarget,
55
+ ): Promise<ResolvedVisitorIdentity | undefined> {
56
+ try {
57
+ const trimmed = (email ?? '').trim();
58
+ if (!trimmed) {
59
+ return undefined;
60
+ }
61
+ const entityName = target?.entityName?.trim() || 'Users';
62
+ const emailField = target?.emailField?.trim() || 'Email';
63
+ const entityId = provider.EntityByName(entityName)?.ID;
64
+ if (!entityId) {
65
+ LogError(`[VisitorIdentity] identity-resolution entity '${entityName}' not found in metadata.`);
66
+ return undefined;
67
+ }
68
+ const rv = new RunView();
69
+ const result = await rv.RunView<{ ID: string }>(
70
+ {
71
+ EntityName: entityName,
72
+ ExtraFilter: `${emailField} = '${trimmed.replace(/'/g, "''")}'`,
73
+ Fields: ['ID'],
74
+ MaxRows: 1,
75
+ ResultType: 'simple',
76
+ },
77
+ contextUser,
78
+ );
79
+ if (!result.Success) {
80
+ LogError(`[VisitorIdentity] identity lookup failed for '${entityName}.${emailField}': ${result.ErrorMessage}`);
81
+ return undefined;
82
+ }
83
+ const recordId = result.Results?.[0]?.ID;
84
+ return recordId ? { entityId, recordId } : undefined;
85
+ } catch (e) {
86
+ LogError(`[VisitorIdentity] resolveIdentityByEmail failed: ${e instanceof Error ? e.message : String(e)}`);
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /** Loads every conversation sharing a VisitorKey within one application (entity objects, for mutation). */
92
+ async function loadVisitorConversations(
93
+ visitorKey: string,
94
+ applicationId: string,
95
+ contextUser: UserInfo,
96
+ ): Promise<MJConversationEntity[]> {
97
+ const rv = new RunView();
98
+ // visitorKey is validated base64url by the caller; applicationId is a server-trusted UUID.
99
+ const result = await rv.RunView<MJConversationEntity>(
100
+ {
101
+ EntityName: CONVERSATIONS_ENTITY,
102
+ ExtraFilter: `VisitorKey = '${visitorKey}' AND ApplicationID = '${applicationId.replace(/'/g, "''")}'`,
103
+ ResultType: 'entity_object',
104
+ },
105
+ contextUser,
106
+ );
107
+ if (!result.Success) {
108
+ LogError(`[VisitorIdentity] failed to load visitor conversations: ${result.ErrorMessage}`);
109
+ return [];
110
+ }
111
+ return result.Results ?? [];
112
+ }
113
+
114
+ /**
115
+ * Promotes a visitor's anonymous trail to a resolved identity (RV4): stamps the resolved pair on every
116
+ * conversation sharing the visitor's key in this application, then re-keys the visitor's anonymous
117
+ * recap notes (those filed under `(MJ: Conversations, conversationId)`) onto the resolved pair. Idempotent
118
+ * and best-effort — already-resolved conversations are skipped; per-record save failures are logged, not thrown.
119
+ *
120
+ * @returns the number of conversations stamped (0 when nothing matched the key).
121
+ */
122
+ export async function mergeVisitorIdentity(args: {
123
+ visitorKey: string;
124
+ applicationId: string;
125
+ identity: ResolvedVisitorIdentity;
126
+ contextUser: UserInfo;
127
+ provider: IMetadataProvider;
128
+ }): Promise<number> {
129
+ try {
130
+ const conversations = await loadVisitorConversations(args.visitorKey, args.applicationId, args.contextUser);
131
+ if (conversations.length === 0) {
132
+ return 0;
133
+ }
134
+ const conversationsEntityId = args.provider.EntityByName(CONVERSATIONS_ENTITY)?.ID;
135
+
136
+ let stamped = 0;
137
+ for (const convo of conversations) {
138
+ if (convo.LinkedEntityID === args.identity.entityId && convo.LinkedRecordID === args.identity.recordId) {
139
+ continue; // already resolved to this identity
140
+ }
141
+ convo.LinkedEntityID = args.identity.entityId;
142
+ convo.LinkedRecordID = args.identity.recordId;
143
+ if (await convo.Save()) {
144
+ stamped++;
145
+ } else {
146
+ LogError(`[VisitorIdentity] failed to stamp resolved identity on conversation ${convo.ID}: ${convo.LatestResult?.CompleteMessage ?? 'unknown'}`);
147
+ }
148
+ }
149
+
150
+ if (conversationsEntityId) {
151
+ await rekeyAnonymousNotes(conversations.map((c) => c.ID), conversationsEntityId, args.identity, args.contextUser, args.provider);
152
+ }
153
+
154
+ LogStatus(`[VisitorIdentity] merged visitor ${args.visitorKey} → ${args.identity.entityId}/${args.identity.recordId} across ${stamped} conversation(s)`);
155
+ return stamped;
156
+ } catch (e) {
157
+ LogError(`[VisitorIdentity] mergeVisitorIdentity failed: ${e instanceof Error ? e.message : String(e)}`);
158
+ return 0;
159
+ }
160
+ }
161
+
162
+ /** Re-keys notes scoped to `(Conversations entity, conversationId)` onto the resolved pair. */
163
+ async function rekeyAnonymousNotes(
164
+ conversationIds: string[],
165
+ conversationsEntityId: string,
166
+ identity: ResolvedVisitorIdentity,
167
+ contextUser: UserInfo,
168
+ provider: IMetadataProvider,
169
+ ): Promise<void> {
170
+ if (conversationIds.length === 0) {
171
+ return;
172
+ }
173
+ const inList = conversationIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(', ');
174
+ const rv = new RunView();
175
+ const result = await rv.RunView<MJAIAgentNoteEntity>(
176
+ {
177
+ EntityName: AGENT_NOTES_ENTITY,
178
+ ExtraFilter: `PrimaryScopeEntityID = '${conversationsEntityId}' AND PrimaryScopeRecordID IN (${inList})`,
179
+ ResultType: 'entity_object',
180
+ },
181
+ contextUser,
182
+ );
183
+ if (!result.Success) {
184
+ LogError(`[VisitorIdentity] failed to load anonymous notes for re-key: ${result.ErrorMessage}`);
185
+ return;
186
+ }
187
+ for (const note of result.Results ?? []) {
188
+ note.PrimaryScopeEntityID = identity.entityId;
189
+ note.PrimaryScopeRecordID = identity.recordId;
190
+ if (!(await note.Save())) {
191
+ LogError(`[VisitorIdentity] failed to re-key note ${note.ID}: ${note.LatestResult?.CompleteMessage ?? 'unknown'}`);
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * "Forget me" (RV5): archives the visitor's auto-generated memory and severs the cookie linkage.
198
+ * Archives every auto-generated note whose `SourceConversationID` is one of the visitor's conversations
199
+ * (so it reaps recaps whether they're still anonymous-scoped or already merged onto a resolved record —
200
+ * without touching unrelated memory on a shared resolved record), then clears `VisitorKey` on those
201
+ * conversations so neither the cookie nor a resolved pair re-resolves the trail. Best-effort.
202
+ *
203
+ * @returns a summary of what was archived/cleared.
204
+ */
205
+ export async function forgetVisitor(args: {
206
+ visitorKey: string;
207
+ applicationId: string;
208
+ contextUser: UserInfo;
209
+ provider: IMetadataProvider;
210
+ }): Promise<{ notesArchived: number; conversationsCleared: number }> {
211
+ let notesArchived = 0;
212
+ let conversationsCleared = 0;
213
+ try {
214
+ const conversations = await loadVisitorConversations(args.visitorKey, args.applicationId, args.contextUser);
215
+ if (conversations.length === 0) {
216
+ return { notesArchived, conversationsCleared };
217
+ }
218
+ const inList = conversations.map((c) => `'${c.ID.replace(/'/g, "''")}'`).join(', ');
219
+
220
+ // Archive the visitor's auto-generated recaps, keyed by source conversation (scope-agnostic).
221
+ const rv = new RunView();
222
+ const notes = await rv.RunView<MJAIAgentNoteEntity>(
223
+ {
224
+ EntityName: AGENT_NOTES_ENTITY,
225
+ ExtraFilter: `IsAutoGenerated = 1 AND SourceConversationID IN (${inList}) AND Status != 'Archived'`,
226
+ ResultType: 'entity_object',
227
+ },
228
+ args.contextUser,
229
+ );
230
+ if (notes.Success) {
231
+ for (const note of notes.Results ?? []) {
232
+ note.Status = 'Archived';
233
+ if (await note.Save()) {
234
+ notesArchived++;
235
+ } else {
236
+ LogError(`[VisitorIdentity] failed to archive note ${note.ID}: ${note.LatestResult?.CompleteMessage ?? 'unknown'}`);
237
+ }
238
+ }
239
+ } else {
240
+ LogError(`[VisitorIdentity] failed to load notes for forget: ${notes.ErrorMessage}`);
241
+ }
242
+
243
+ // Sever the cookie linkage: clear VisitorKey so the key no longer resolves these conversations.
244
+ for (const convo of conversations) {
245
+ convo.VisitorKey = null;
246
+ if (await convo.Save()) {
247
+ conversationsCleared++;
248
+ } else {
249
+ LogError(`[VisitorIdentity] failed to clear VisitorKey on conversation ${convo.ID}: ${convo.LatestResult?.CompleteMessage ?? 'unknown'}`);
250
+ }
251
+ }
252
+
253
+ LogStatus(`[VisitorIdentity] forgot visitor ${args.visitorKey}: archived ${notesArchived} note(s), cleared ${conversationsCleared} conversation link(s)`);
254
+ } catch (e) {
255
+ LogError(`[VisitorIdentity] forgetVisitor failed: ${e instanceof Error ? e.message : String(e)}`);
256
+ }
257
+ return { notesArchived, conversationsCleared };
258
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * @fileoverview Pure functional core for the public web widget guest-session
3
+ * mint — no DB, no MJ runtime, no Express. Deterministic given inputs (modulo
4
+ * the session-id randomness it delegates to magicLinkCore) and unit-testable
5
+ * with plain assertions. Mirrors the magic-link `magicLinkCore.ts` split.
6
+ *
7
+ * The widget reuses the magic-link RS256 mint + `anonymous-embed` synthesis. This
8
+ * module owns only the widget-specific pure logic: origin-allowlist enforcement,
9
+ * widget-instance eligibility, and assembling the guest claims (anonymous + the
10
+ * additive `mj_widget_id` claim) on top of magic-link's `buildSessionClaims`.
11
+ *
12
+ * @module @memberjunction/server/widget
13
+ */
14
+
15
+ import { buildSessionClaims } from '../auth/magicLink/magicLinkCore.js';
16
+ import type { MagicLinkJWTClaims } from '../auth/magicLink/types.js';
17
+
18
+ /**
19
+ * The magic-link resource-scope `resourceType` used for a widget guest session. Pairs with the
20
+ * opaque session id as `resourceId` so the synthesized principal's MagicLinkScope.ResourceID is
21
+ * the session id — the discriminator the Widget Guest RLS filters key on for per-session isolation.
22
+ */
23
+ export const WIDGET_SESSION_RESOURCE_TYPE = 'Widget Session';
24
+
25
+ /** Why a guest-session mint was rejected. Drives the HTTP status in the router. */
26
+ export type WidgetMintErrorCode =
27
+ | 'not_found'
28
+ | 'disabled'
29
+ | 'origin_not_allowed'
30
+ | 'modality_not_enabled'
31
+ | 'host_assertion_invalid'
32
+ | 'upgrade_not_enabled'
33
+ | 'invalid_email'
34
+ | 'server_error';
35
+
36
+ /** The minimal widget-instance shape the pure validators need (a subset of the entity). */
37
+ export interface WidgetInstanceEligibilityInput {
38
+ Status: string;
39
+ AllowedOrigins: string | null;
40
+ Modality: string;
41
+ }
42
+
43
+ /**
44
+ * Parses the widget's `AllowedOrigins` column into a normalized origin list.
45
+ * Accepts a JSON array (preferred, e.g. `["https://a.com","https://b.com"]`) or a
46
+ * comma-separated string (tolerated). Returns an empty array for null/blank/garbage
47
+ * — which makes the allowlist FAIL-CLOSED (no origin is allowed) by construction.
48
+ */
49
+ export function parseAllowedOrigins(allowedOrigins: string | null | undefined): string[] {
50
+ const raw = allowedOrigins?.trim();
51
+ if (!raw) {
52
+ return [];
53
+ }
54
+ let list: unknown;
55
+ try {
56
+ list = JSON.parse(raw);
57
+ } catch {
58
+ // Not JSON — tolerate a CSV form.
59
+ return raw.split(',').map((s) => normalizeOrigin(s)).filter((s): s is string => !!s);
60
+ }
61
+ if (!Array.isArray(list)) {
62
+ return [];
63
+ }
64
+ return list.filter((s): s is string => typeof s === 'string').map(normalizeOrigin).filter((s): s is string => !!s);
65
+ }
66
+
67
+ /** Lowercases + trims an origin and strips a trailing slash for stable comparison. */
68
+ function normalizeOrigin(origin: string): string {
69
+ return origin.trim().toLowerCase().replace(/\/+$/, '');
70
+ }
71
+
72
+ /**
73
+ * Parses the widget's `EnabledChannels` column into a clean list of channel names (Phase 2). Accepts a
74
+ * JSON array (preferred, e.g. `["Whiteboard"]`) or a comma-separated string (tolerated). Returns an
75
+ * empty array for null/blank/garbage — the backwards-compatible default (no channels attached).
76
+ */
77
+ export function parseEnabledChannels(enabledChannels: string | null | undefined): string[] {
78
+ const raw = enabledChannels?.trim();
79
+ if (!raw) {
80
+ return [];
81
+ }
82
+ const clean = (list: unknown[]): string[] =>
83
+ list.filter((s): s is string => typeof s === 'string').map((s) => s.trim()).filter((s) => s.length > 0);
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ return Array.isArray(parsed) ? clean(parsed) : [];
87
+ } catch {
88
+ return clean(raw.split(','));
89
+ }
90
+ }
91
+
92
+ /**
93
+ * FAIL-CLOSED origin check. The request's `Origin` header must exactly match one of
94
+ * the widget's allowed origins (after normalization). A missing request origin, or
95
+ * an empty allowlist, is rejected — a public mint endpoint must never accept "*".
96
+ */
97
+ export function isOriginAllowed(requestOrigin: string | null | undefined, allowedOrigins: readonly string[]): boolean {
98
+ if (!requestOrigin || allowedOrigins.length === 0) {
99
+ return false;
100
+ }
101
+ const candidate = normalizeOrigin(requestOrigin);
102
+ return allowedOrigins.some((o) => o === candidate);
103
+ }
104
+
105
+ /** True when the requested modality (or the widget's default render) is enabled by the instance. */
106
+ export function isModalityEnabled(widgetModality: string, requested: 'Text' | 'Voice'): boolean {
107
+ const m = widgetModality.trim().toLowerCase();
108
+ if (m === 'both') {
109
+ return true;
110
+ }
111
+ return m === requested.toLowerCase();
112
+ }
113
+
114
+ /**
115
+ * Pure eligibility check for a mint: the widget must be Active and the request's
116
+ * origin must be on the allowlist. Returns an error code on the first failure so the
117
+ * router can map it to a precise HTTP status. Modality is checked separately by the
118
+ * voice path (W4) since a text mint is always permitted for an enabled widget.
119
+ */
120
+ export function evaluateWidgetMint(
121
+ widget: WidgetInstanceEligibilityInput,
122
+ requestOrigin: string | null | undefined,
123
+ ): { ok: boolean; errorCode?: WidgetMintErrorCode } {
124
+ if (widget.Status.trim().toLowerCase() !== 'active') {
125
+ return { ok: false, errorCode: 'disabled' };
126
+ }
127
+ if (!isOriginAllowed(requestOrigin, parseAllowedOrigins(widget.AllowedOrigins))) {
128
+ return { ok: false, errorCode: 'origin_not_allowed' };
129
+ }
130
+ return { ok: true };
131
+ }
132
+
133
+ /**
134
+ * Known automated-client User-Agent substrings (lowercased). A public mint endpoint is a cheap
135
+ * target for crawlers and scripted abuse; this catches the obvious non-browser callers. It is a
136
+ * coarse FIRST line of defense (W6 "basic behavioural and user-agent checks"), NOT a CAPTCHA — the
137
+ * real boundaries remain the origin allowlist, rate limits, the restricted guest role, and short
138
+ * token TTLs. Legitimate headless integrations should embed via host-identity, not the public mint.
139
+ */
140
+ const BOT_USER_AGENT_MARKERS = [
141
+ 'bot', 'crawler', 'spider', 'scrape', 'curl', 'wget', 'python-requests',
142
+ 'httpclient', 'okhttp', 'java/', 'go-http-client', 'libwww', 'headlesschrome', 'phantomjs',
143
+ ];
144
+
145
+ /**
146
+ * Coarse bot heuristic for the public mint route. Returns `true` when the request should be treated as
147
+ * an automated client: a missing/blank User-Agent (real browsers always send one) or a UA containing a
148
+ * known automation marker. Deliberately conservative to avoid false positives on real browsers; pair
149
+ * with the origin allowlist + rate limits, which are the hard controls.
150
+ */
151
+ export function looksLikeBot(userAgent: string | null | undefined): boolean {
152
+ const ua = (userAgent ?? '').trim().toLowerCase();
153
+ if (!ua) {
154
+ return true;
155
+ }
156
+ return BOT_USER_AGENT_MARKERS.some((marker) => ua.includes(marker));
157
+ }
158
+
159
+ /**
160
+ * Builds the guest-session JWT claims for a widget visitor. Reuses magic-link's
161
+ * `buildSessionClaims` (anonymous mode → the shared Anonymous principal + claims-based
162
+ * role synthesis) and layers on the additive `mj_widget_id` claim so the synthesized
163
+ * principal is locked to one widget instance. The role name carried here is the
164
+ * widget's restricted guest role (D5 backstop).
165
+ */
166
+ export function buildWidgetGuestClaims(args: {
167
+ issuer: string;
168
+ audience: string;
169
+ widgetId: string;
170
+ sessionId: string;
171
+ anonymousEmail: string;
172
+ applicationId: string;
173
+ guestRoleName: string;
174
+ nowSeconds: number;
175
+ ttlSeconds: number;
176
+ /**
177
+ * Host-asserted identity for a `host-identity` session. Its email/name are carried as
178
+ * INFORMATIONAL claims only — the principal-resolving `email`/`sub` remain the shared
179
+ * Anonymous principal, so a host can never escalate a guest into a real account.
180
+ */
181
+ hostIdentity?: { email: string; firstName?: string; lastName?: string };
182
+ /**
183
+ * Returning-visitor anchor + linked identity (RV1/RV2/RV4) carried as claims so the VOICE path
184
+ * (server-created conversation) stamps the same fields the text path stamps client-side. Set only
185
+ * when the widget remembers returning visitors. Omitted ⇒ no returning-visitor claims (default off).
186
+ */
187
+ returningVisitor?: {
188
+ visitorKey?: string;
189
+ lastConversationId?: string;
190
+ linkedEntityId?: string;
191
+ linkedRecordId?: string;
192
+ };
193
+ }): MagicLinkJWTClaims {
194
+ const claims = buildSessionClaims({
195
+ issuer: args.issuer,
196
+ audience: args.audience,
197
+ // No invite row for a direct-mint guest; the opaque session id stands in as the
198
+ // subject discriminator (sub = `magic-link|<sessionId>`) and scope-entry id.
199
+ inviteId: args.sessionId,
200
+ // ALWAYS the Anonymous principal email — never the host-provided one (that would let
201
+ // the auth middleware resolve to a real user and leak its permissions).
202
+ email: args.anonymousEmail,
203
+ applicationId: args.applicationId,
204
+ roleName: args.guestRoleName,
205
+ anonymous: true,
206
+ sessionId: args.sessionId,
207
+ // Per-session resource scope → MagicLinkScope.ResourceID on the synthesized principal.
208
+ // This is what the Widget Guest RLS filters key on ({{ScopeResourceID}}) to isolate one
209
+ // anonymous guest's conversations from another's despite the shared Anonymous UserID.
210
+ // It rides the SIGNED token, so a guest cannot forge another session's scope.
211
+ resourceType: WIDGET_SESSION_RESOURCE_TYPE,
212
+ resourceId: args.sessionId,
213
+ nowSeconds: args.nowSeconds,
214
+ ttlSeconds: args.ttlSeconds,
215
+ });
216
+ // Additive widget claim — lets the synthesized principal be bound to one widget.
217
+ claims.mj_widget_id = args.widgetId;
218
+ if (args.hostIdentity) {
219
+ claims.given_name = args.hostIdentity.firstName ?? claims.given_name;
220
+ claims.family_name = args.hostIdentity.lastName ?? claims.family_name;
221
+ claims.name = [args.hostIdentity.firstName, args.hostIdentity.lastName].filter(Boolean).join(' ') || claims.name;
222
+ claims.mj_host_email = args.hostIdentity.email;
223
+ }
224
+ if (args.returningVisitor) {
225
+ claims.mj_visitor_key = args.returningVisitor.visitorKey;
226
+ claims.mj_last_conversation_id = args.returningVisitor.lastConversationId;
227
+ claims.mj_linked_entity_id = args.returningVisitor.linkedEntityId;
228
+ claims.mj_linked_record_id = args.returningVisitor.linkedRecordId;
229
+ }
230
+ return claims;
231
+ }