@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,714 @@
1
+ /**
2
+ * @fileoverview Imperative shell for public web widget guest sessions: resolves a
3
+ * widget instance by its public key, enforces the origin allowlist + status, and
4
+ * mints a short-lived anonymous guest session JWT by reusing the magic-link RS256
5
+ * key + `anonymous-embed` synthesis. The pure logic lives in `widgetCore.ts`.
6
+ *
7
+ * Design (per public-web-widget.md W1 + open-Q #1): DIRECT-MINT for anonymous
8
+ * guests — no MagicLinkInvite row is created (those model single-use invites; a
9
+ * widget guest is ephemeral). Forensics ride the opaque per-session id (`mj_sid`)
10
+ * carried in the token, plus best-effort structured audit logging. The minted
11
+ * token is validated by the SAME `magic-link` auth provider (same issuer/audience/
12
+ * JWKS) and synthesized into a constrained guest principal by `buildMagicLinkSessionUser`.
13
+ *
14
+ * @module @memberjunction/server/widget
15
+ */
16
+
17
+ import { Metadata, RunView, UserInfo, LogError, LogStatus } from '@memberjunction/core';
18
+ import { UUIDsEqual } from '@memberjunction/global';
19
+ import type { MJConversationWidgetInstanceEntity, MJConversationEntity } from '@memberjunction/core-entities';
20
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
21
+ import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
22
+ import { generateSessionId } from '../auth/magicLink/magicLinkCore.js';
23
+ import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
24
+ import { configInfo, type WidgetConfig } from '../config.js';
25
+ import { buildWidgetGuestClaims, evaluateWidgetMint, parseEnabledChannels, type WidgetMintErrorCode } from './widgetCore.js';
26
+ import { verifyHostAssertion, type HostAssertedIdentity } from './host-identity.js';
27
+ import { writeReturningVisitorRecap } from '../agentSessions/ReturningVisitorRecap.js';
28
+ import { resolveIdentityByEmail, mergeVisitorIdentity, forgetVisitor, type ResolvedVisitorIdentity } from './visitorIdentity.js';
29
+
30
+ const WIDGET_ENTITY = 'MJ: Conversation Widget Instances';
31
+ const CONVERSATIONS_ENTITY = 'MJ: Conversations';
32
+
33
+ /** Shape of the durable visitor anchor key (opaque base64url, same alphabet as generateSessionId). */
34
+ const VISITOR_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
35
+
36
+ /** Resolved returning-visitor anchor for a mint: the durable key + the prior conversation to chain from. */
37
+ interface ReturningVisitorResolution {
38
+ /** The durable VisitorKey to persist as a cookie (validated-presented or freshly minted). */
39
+ visitorKey: string;
40
+ /** The most-recent prior conversation for this anchor in the widget's app, when this is a returning visit. */
41
+ lastConversationId?: string;
42
+ }
43
+
44
+ /** Per-request forensic context captured for audit (sourced from the HTTP request). */
45
+ export interface WidgetMintAuditContext {
46
+ ipAddress?: string;
47
+ userAgent?: string;
48
+ origin?: string;
49
+ }
50
+
51
+ /** Input to mint a guest session. */
52
+ export interface MintGuestSessionInput {
53
+ /** The widget's public embed key (pk_live_…). */
54
+ widgetKey: string;
55
+ /** The request's Origin header — enforced against the instance allowlist. */
56
+ origin?: string;
57
+ /**
58
+ * A host-signed RS256 identity assertion (D1 `host-identity` strategy). Required when the
59
+ * resolved widget's AuthStrategy is `HostIdentity`; ignored otherwise. Verified against the
60
+ * host's registered public key; its identity is carried as informational claims only.
61
+ */
62
+ hostAssertion?: string;
63
+ /**
64
+ * The durable returning-visitor anchor (RV1) the client presents from its first-party cookie.
65
+ * Honored only when the widget's `RememberReturningVisitors` toggle is on: a valid presented key
66
+ * chains this visit to the visitor's prior conversation; absent/invalid on a first visit, the
67
+ * server mints a fresh one. Ignored entirely when remembering is off.
68
+ */
69
+ visitorKey?: string;
70
+ audit?: WidgetMintAuditContext;
71
+ }
72
+
73
+ /** Result of a guest-session mint. */
74
+ export interface MintGuestSessionResult {
75
+ success: boolean;
76
+ /** The minted session JWT (RS256), held by the widget in memory and refreshed before expiry. */
77
+ token?: string;
78
+ /** ISO expiry of the session token. */
79
+ expiresAt?: string;
80
+ /** The widget instance id (also carried as the `mj_widget_id` claim). */
81
+ widgetId?: string;
82
+ /** The Application the session is scoped to. */
83
+ applicationId?: string;
84
+ /** The pinned support agent the widget passes as explicitAgentId for every turn (D5). */
85
+ pinnedAgentId?: string;
86
+ /** Which modalities this widget exposes (drives the widget UI). */
87
+ modality?: string;
88
+ /**
89
+ * The opaque per-session id (also the signed scope resourceId). The widget stamps
90
+ * Conversation.ExternalID with this so the Widget Guest RLS filters isolate this guest's rows.
91
+ */
92
+ sessionId?: string;
93
+ /**
94
+ * Optional hard ceiling (minutes) on a voice session for this widget. Surfaced so the client
95
+ * voice-abuse guard uses the deployment-configured limit; also enforced server-side at mint.
96
+ */
97
+ voiceMaxSessionMinutes?: number;
98
+ /** Whether returning-visitor memory is enabled for this widget (gates the cookie + chaining). */
99
+ rememberReturningVisitors?: boolean;
100
+ /**
101
+ * The durable visitor anchor to persist as a cookie (RV1). Set only when remembering is on:
102
+ * the validated key the client presented, or a freshly minted one on a first visit.
103
+ */
104
+ visitorKey?: string;
105
+ /** The visitor's prior conversation for this anchor (RV2 chain); set only on a returning visit. */
106
+ lastConversationId?: string;
107
+ /**
108
+ * The resolved polymorphic identity entity id (RV4), set only when a host-identity widget asserted a
109
+ * resolvable identity at mint. The client stamps it (with {@link MintGuestSessionResult.linkedRecordId})
110
+ * onto the new conversation so memory injection (RV3) keys off the resolved record, not the cookie.
111
+ */
112
+ linkedEntityId?: string;
113
+ /** The resolved polymorphic identity record id (RV4); paired with {@link MintGuestSessionResult.linkedEntityId}. */
114
+ linkedRecordId?: string;
115
+ /**
116
+ * Interactive channels (by name, e.g. `["Whiteboard"]`) this widget may attach when voice is active
117
+ * (Phase 2). Mirrors the widget instance's `EnabledChannels`; empty (the default) = no channels.
118
+ */
119
+ enabledChannels?: string[];
120
+ error?: string;
121
+ errorCode?: WidgetMintErrorCode;
122
+ }
123
+
124
+ /** Input to resolve a returning visitor's identity after they verify (RV4, magic-link upgrade path). */
125
+ export interface ResolveVisitorIdentityInput {
126
+ /** The widget's public embed key. */
127
+ widgetKey: string;
128
+ /** The durable returning-visitor anchor whose trail should be promoted to the verified identity. */
129
+ visitorKey: string;
130
+ /** The verified visitor's email (from the authenticated session) — resolved to a record via config. */
131
+ verifiedEmail: string;
132
+ /** The request's Origin header — enforced against the instance allowlist. */
133
+ origin?: string;
134
+ }
135
+
136
+ /** Result of an RV4 identity resolve+merge. */
137
+ export interface ResolveVisitorIdentityResult {
138
+ success: boolean;
139
+ /** The resolved polymorphic pair, when a record matched the verified email. */
140
+ resolved?: ResolvedVisitorIdentity;
141
+ /** How many conversations were stamped with the resolved identity. */
142
+ mergedConversations?: number;
143
+ error?: string;
144
+ errorCode?: WidgetMintErrorCode;
145
+ }
146
+
147
+ /** Input to a "forget me" request (RV5). */
148
+ export interface ForgetVisitorInput {
149
+ /** The widget's public embed key. */
150
+ widgetKey: string;
151
+ /** The durable returning-visitor anchor to forget. */
152
+ visitorKey: string;
153
+ /** The request's Origin header — enforced against the instance allowlist. */
154
+ origin?: string;
155
+ }
156
+
157
+ /**
158
+ * Result of the shared RV4/RV5 visitor-privacy gate. A single flat shape (not a discriminated union)
159
+ * because MJServer compiles without strictNullChecks, where literal-discriminant narrowing is disabled.
160
+ * On `ok`, `widget`/`contextUser` are set; otherwise `errorCode`/`error` are set.
161
+ */
162
+ interface VisitorPrivacyGateResult {
163
+ ok: boolean;
164
+ widget?: MJConversationWidgetInstanceEntity;
165
+ contextUser?: UserInfo;
166
+ errorCode?: WidgetMintErrorCode;
167
+ error?: string;
168
+ }
169
+
170
+ /** Result of a "forget me" request (RV5). */
171
+ export interface ForgetVisitorResult {
172
+ success: boolean;
173
+ /** Count of memory notes archived. */
174
+ notesArchived?: number;
175
+ /** Count of conversations whose VisitorKey linkage was cleared. */
176
+ conversationsCleared?: number;
177
+ error?: string;
178
+ errorCode?: WidgetMintErrorCode;
179
+ }
180
+
181
+ /** Input to request a magic-link identity upgrade for a live guest session (W5). */
182
+ export interface UpgradeGuestSessionInput {
183
+ /** The widget's public embed key. */
184
+ widgetKey: string;
185
+ /** The email to send the verification link to (becomes the provisioned user's email). */
186
+ email: string;
187
+ /** The request's Origin header — enforced against the instance allowlist. */
188
+ origin?: string;
189
+ }
190
+
191
+ /** Result of a magic-link upgrade request. Never carries the token (delivered by email, out-of-band). */
192
+ export interface UpgradeGuestSessionResult {
193
+ success: boolean;
194
+ /** Whether the verification email was dispatched (false when no comms provider is configured). */
195
+ emailSent?: boolean;
196
+ error?: string;
197
+ errorCode?: WidgetMintErrorCode;
198
+ }
199
+
200
+ /** Minimal email sanity check (presence + single `@` with a dotted domain) — not full RFC validation. */
201
+ function isLikelyEmail(email: string): boolean {
202
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
203
+ }
204
+
205
+ /**
206
+ * Mints constrained, short-lived guest sessions for the public web widget.
207
+ * Stateless; constructed once at server startup alongside the magic-link service.
208
+ */
209
+ export class WidgetSessionService {
210
+ constructor(
211
+ private readonly publicUrl: string,
212
+ private readonly config: WidgetConfig,
213
+ ) {}
214
+
215
+ /**
216
+ * Resolves the widget instance, enforces guardrails, and mints a guest JWT.
217
+ * Never throws for business failures — returns `{ success: false, errorCode }`.
218
+ */
219
+ public async MintGuestSession(input: MintGuestSessionInput): Promise<MintGuestSessionResult> {
220
+ try {
221
+ const contextUser = this.resolveLookupUser();
222
+ if (!contextUser) {
223
+ LogError('[Widget] No lookup context user available; server not configured.');
224
+ return { success: false, errorCode: 'server_error', error: 'Server not configured for widget sessions.' };
225
+ }
226
+
227
+ const widget = await this.loadWidgetByKey(input.widgetKey, contextUser);
228
+ if (!widget) {
229
+ return this.audited({ success: false, errorCode: 'not_found', error: 'Unknown widget key.' }, input, undefined);
230
+ }
231
+
232
+ const eligibility = evaluateWidgetMint(
233
+ { Status: widget.Status, AllowedOrigins: widget.AllowedOrigins, Modality: widget.Modality },
234
+ input.origin,
235
+ );
236
+ if (!eligibility.ok) {
237
+ return this.audited({ success: false, errorCode: eligibility.errorCode, error: 'Widget mint rejected.' }, input, widget);
238
+ }
239
+
240
+ const guestRoleName = this.resolveGuestRoleName(widget.GuestRoleID);
241
+ if (!guestRoleName) {
242
+ LogError(`[Widget] Guest role ${widget.GuestRoleID} for widget ${widget.ID} not found in metadata.`);
243
+ return this.audited({ success: false, errorCode: 'server_error', error: 'Guest role not resolvable.' }, input, widget);
244
+ }
245
+
246
+ // D1 host-identity strategy: a HostIdentity widget MUST present a valid host assertion.
247
+ let hostIdentity: HostAssertedIdentity | undefined;
248
+ if (widget.AuthStrategy === 'HostIdentity') {
249
+ const verified = this.verifyHost(widget, input.hostAssertion);
250
+ if (!verified) {
251
+ return this.audited({ success: false, errorCode: 'host_assertion_invalid', error: 'Host identity assertion invalid.' }, input, widget);
252
+ }
253
+ hostIdentity = verified;
254
+ }
255
+
256
+ // Returning-visitor anchor (RV1): resolve/mint the durable VisitorKey and chain to the prior
257
+ // conversation — only when this widget opts in. Never blocks the mint (best-effort on lookup failure).
258
+ const returningVisitor = await this.resolveReturningVisitor(widget, input.visitorKey, contextUser);
259
+
260
+ // RV2 (text path): the text widget runs client-side and has no server "session closed" event, so
261
+ // we recap the visitor's PRIOR conversation lazily, at the moment they return — which is exactly
262
+ // when the recap is needed for RV3 to inject it into this new session. Idempotent (skips when a
263
+ // recap already exists) and best-effort (never blocks or fails the mint).
264
+ await this.recapPriorConversationIfNeeded(widget, returningVisitor, contextUser);
265
+
266
+ // RV4 (host-identity path): when a host asserts a resolvable identity, promote the visitor's prior
267
+ // anonymous trail to that record and surface the pair so the client stamps the new conversation.
268
+ const resolvedIdentity = await this.resolveHostIdentityIfApplicable(widget, hostIdentity, returningVisitor, contextUser);
269
+
270
+ return this.audited(this.mintToken(widget, guestRoleName, hostIdentity, returningVisitor, resolvedIdentity), input, widget);
271
+ } catch (e) {
272
+ LogError(e);
273
+ return { success: false, errorCode: 'server_error', error: e instanceof Error ? e.message : String(e) };
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Refreshes a live guest session. The widget always holds its public key, so a
279
+ * refresh is a fresh mint by the same key + origin — short-TTL tokens with no
280
+ * refresh-token machinery (mirrors magic-link's no-refresh-token stance).
281
+ */
282
+ public async RefreshGuestSession(input: MintGuestSessionInput): Promise<MintGuestSessionResult> {
283
+ return this.MintGuestSession(input);
284
+ }
285
+
286
+ /** Short-TTL cache of resolved per-instance rate limits, so the limiter doesn't hit the DB per request. */
287
+ private readonly rateLimitCache = new Map<string, { limit: number; expiresAtMs: number }>();
288
+ private static readonly RATE_LIMIT_CACHE_TTL_MS = 60_000;
289
+
290
+ /**
291
+ * Resolves the per-instance `RateLimitPerMinute` for a widget key (W6 hardening), falling back to the
292
+ * deployment-wide `defaultRateLimitPerMinute` for an unknown/invalid key (so an enumeration probe is
293
+ * still bounded by the coarse default). Cached for a short TTL so the dynamic limiter — which runs on
294
+ * EVERY mint request — does not issue a DB read per request. Never throws.
295
+ */
296
+ public async ResolvePerInstanceRateLimit(widgetKey: string): Promise<number> {
297
+ const key = (widgetKey ?? '').trim();
298
+ const fallback = this.config.defaultRateLimitPerMinute;
299
+ if (!key) {
300
+ return fallback;
301
+ }
302
+ const cached = this.rateLimitCache.get(key);
303
+ const now = Date.now();
304
+ if (cached && cached.expiresAtMs > now) {
305
+ return cached.limit;
306
+ }
307
+ let limit = fallback;
308
+ try {
309
+ const contextUser = this.resolveLookupUser();
310
+ if (contextUser) {
311
+ const widget = await this.loadWidgetByKey(key, contextUser);
312
+ if (widget && widget.RateLimitPerMinute > 0) {
313
+ limit = widget.RateLimitPerMinute;
314
+ }
315
+ }
316
+ } catch (e) {
317
+ LogError(e);
318
+ }
319
+ this.rateLimitCache.set(key, { limit, expiresAtMs: now + WidgetSessionService.RATE_LIMIT_CACHE_TTL_MS });
320
+ return limit;
321
+ }
322
+
323
+ /**
324
+ * W5 magic-link upgrade — "Verify it's you". For a widget whose AuthStrategy is `MagicLinkUpgrade`,
325
+ * a guest supplies their email and we issue a single-use **email-mode** magic-link invite scoped to
326
+ * the widget's Application (under the server principal — `CreateInvite` is Owner-gated). The visitor
327
+ * clicks the emailed link → the existing public `POST /magic-link/redeem` mints a VERIFIED session
328
+ * JWT → the widget swaps it in via `transport.UpdateToken`, preserving the live conversationId.
329
+ *
330
+ * This converges on the SAME `AuthProviderFactory` + `buildMagicLinkSessionUser` path as the guest
331
+ * mint (D1), so the verified session is just a more-privileged principal on the one pathway. Never
332
+ * throws for business failures — returns a structured result.
333
+ */
334
+ public async RequestUpgrade(input: UpgradeGuestSessionInput): Promise<UpgradeGuestSessionResult> {
335
+ try {
336
+ const email = (input.email ?? '').trim();
337
+ if (!isLikelyEmail(email)) {
338
+ return { success: false, errorCode: 'invalid_email', error: 'A valid email is required.' };
339
+ }
340
+ const contextUser = this.resolveLookupUser();
341
+ if (!contextUser) {
342
+ return { success: false, errorCode: 'server_error', error: 'Server not configured for widget sessions.' };
343
+ }
344
+ const widget = await this.loadWidgetByKey(input.widgetKey, contextUser);
345
+ if (!widget) {
346
+ return { success: false, errorCode: 'not_found', error: 'Unknown widget key.' };
347
+ }
348
+ const eligibility = evaluateWidgetMint({ Status: widget.Status, AllowedOrigins: widget.AllowedOrigins, Modality: widget.Modality }, input.origin);
349
+ if (!eligibility.ok) {
350
+ return { success: false, errorCode: eligibility.errorCode, error: 'Widget upgrade rejected.' };
351
+ }
352
+ if (widget.AuthStrategy !== 'MagicLinkUpgrade') {
353
+ return { success: false, errorCode: 'upgrade_not_enabled', error: 'This widget does not offer magic-link upgrade.' };
354
+ }
355
+ const magicLinkConfig = configInfo.magicLink;
356
+ if (!magicLinkConfig) {
357
+ LogError('[Widget] Upgrade requested but magicLink config is absent.');
358
+ return { success: false, errorCode: 'server_error', error: 'Identity verification is not configured.' };
359
+ }
360
+ const service = new MagicLinkService(this.publicUrl, magicLinkConfig);
361
+ const result = await service.CreateInvite({ email, applicationId: widget.ApplicationID }, contextUser);
362
+ if (!result.success) {
363
+ LogError(`[Widget] Upgrade invite failed for widget ${widget.ID}: ${result.error ?? result.errorCode ?? 'unknown'}`);
364
+ return { success: false, errorCode: 'server_error', error: 'Could not start identity verification.' };
365
+ }
366
+ // Deliberately do NOT echo the raw token/redemption URL to the public caller — the verified link
367
+ // is delivered out-of-band by email so a widget-key holder cannot mint verified sessions at will.
368
+ return { success: true, emailSent: result.emailSent ?? false };
369
+ } catch (e) {
370
+ LogError(e);
371
+ return { success: false, errorCode: 'server_error', error: e instanceof Error ? e.message : String(e) };
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Verifies a host-identity assertion for a HostIdentity widget against the host's
377
+ * registered public key (config interim store, keyed by widget PublicKey). Fails closed
378
+ * when the key is absent or the assertion is missing/invalid.
379
+ */
380
+ private verifyHost(widget: MJConversationWidgetInstanceEntity, assertion: string | undefined): HostAssertedIdentity | null {
381
+ // Prefer the per-instance HostPublicKey column (Phase 3 — no config-resident keys); fall back to the
382
+ // interim config map (keyed by PublicKey) for deployments that haven't migrated their key yet.
383
+ const hostKey = widget.HostPublicKey ?? this.config.hostPublicKeys?.[widget.PublicKey];
384
+ const result = verifyHostAssertion(assertion, hostKey, widget.PublicKey);
385
+ if (result.ok && result.identity) {
386
+ return result.identity;
387
+ }
388
+ LogError(`[Widget] Host assertion rejected for widget ${widget.ID}: ${result.errorCode ?? 'unknown'}`);
389
+ return null;
390
+ }
391
+
392
+ /** Builds + signs the guest JWT for an eligible widget instance. */
393
+ private mintToken(
394
+ widget: MJConversationWidgetInstanceEntity,
395
+ guestRoleName: string,
396
+ hostIdentity?: HostAssertedIdentity,
397
+ returningVisitor?: ReturningVisitorResolution,
398
+ resolvedIdentity?: ResolvedVisitorIdentity,
399
+ ): MintGuestSessionResult {
400
+ const nowSeconds = Math.floor(Date.now() / 1000);
401
+ const ttlMinutes = widget.SessionTTLMinutes || this.config.defaultSessionTtlMinutes;
402
+ // Generated once and returned to the client: the widget stamps Conversation.ExternalID with
403
+ // this id so the Widget Guest RLS filters ({{ScopeResourceID}}) isolate this guest's rows.
404
+ const sessionId = generateSessionId();
405
+ const claims = buildWidgetGuestClaims({
406
+ issuer: this.publicUrl,
407
+ audience: this.config.audience,
408
+ widgetId: widget.ID,
409
+ sessionId,
410
+ anonymousEmail: this.config.anonymousEmail,
411
+ applicationId: widget.ApplicationID,
412
+ guestRoleName,
413
+ nowSeconds,
414
+ ttlSeconds: ttlMinutes * 60,
415
+ hostIdentity,
416
+ // RV1/RV2/RV4: carry the returning-visitor anchor + resolved identity as claims so the voice path
417
+ // (server-created conversation) stamps the same fields the text path stamps client-side.
418
+ returningVisitor: returningVisitor
419
+ ? {
420
+ visitorKey: returningVisitor.visitorKey,
421
+ lastConversationId: returningVisitor.lastConversationId,
422
+ linkedEntityId: resolvedIdentity?.entityId,
423
+ linkedRecordId: resolvedIdentity?.recordId,
424
+ }
425
+ : undefined,
426
+ });
427
+ const token = MagicLinkKeyManager.Instance.Sign(claims);
428
+ return {
429
+ success: true,
430
+ token,
431
+ expiresAt: new Date(claims.exp * 1000).toISOString(),
432
+ widgetId: widget.ID,
433
+ applicationId: widget.ApplicationID,
434
+ pinnedAgentId: widget.PinnedAgentID,
435
+ modality: widget.Modality,
436
+ sessionId,
437
+ // Phase 2: which interactive channels this widget may attach during a voice session. Read from the
438
+ // per-instance EnabledChannels column (added by the Widget_Public_Hardening migration + CodeGen).
439
+ enabledChannels: parseEnabledChannels(widget.EnabledChannels),
440
+ voiceMaxSessionMinutes: widget.VoiceMaxSessionMinutes ?? undefined,
441
+ rememberReturningVisitors: !!returningVisitor,
442
+ visitorKey: returningVisitor?.visitorKey,
443
+ lastConversationId: returningVisitor?.lastConversationId,
444
+ linkedEntityId: resolvedIdentity?.entityId,
445
+ linkedRecordId: resolvedIdentity?.recordId,
446
+ };
447
+ }
448
+
449
+ /**
450
+ * RV4 (host-identity path): when a `HostIdentity` widget asserts a resolvable identity AND remembering
451
+ * is on, resolve the asserted email to a polymorphic record and merge the visitor's prior anonymous
452
+ * trail onto it. Returns the resolved pair (so the client stamps the new conversation), or undefined
453
+ * when not applicable or no record matched. Best-effort — never blocks the mint.
454
+ */
455
+ private async resolveHostIdentityIfApplicable(
456
+ widget: MJConversationWidgetInstanceEntity,
457
+ hostIdentity: HostAssertedIdentity | undefined,
458
+ returningVisitor: ReturningVisitorResolution | undefined,
459
+ contextUser: UserInfo,
460
+ ): Promise<ResolvedVisitorIdentity | undefined> {
461
+ if (!widget.RememberReturningVisitors || !hostIdentity?.email || !returningVisitor?.visitorKey) {
462
+ return undefined;
463
+ }
464
+ const identity = await resolveIdentityByEmail(hostIdentity.email, contextUser, Metadata.Provider, this.config.identityResolution); // global-provider-ok: server-side mint under the single default provider
465
+ if (!identity) {
466
+ return undefined;
467
+ }
468
+ await mergeVisitorIdentity({
469
+ visitorKey: returningVisitor.visitorKey,
470
+ applicationId: widget.ApplicationID,
471
+ identity,
472
+ contextUser,
473
+ provider: Metadata.Provider, // global-provider-ok: server-side mint under the single default provider
474
+ });
475
+ return identity;
476
+ }
477
+
478
+ /**
479
+ * RV4 (magic-link upgrade path): after a visitor verifies their identity, promote their anonymous
480
+ * trail to the verified record. Called from the AUTHENTICATED route with the verified session's email,
481
+ * so the resolved record is the deployment-configured target for that email (default: the MJ User).
482
+ * Validates the widget + origin + opt-in + key, then resolves and merges. Never throws.
483
+ */
484
+ public async ResolveVisitorIdentity(input: ResolveVisitorIdentityInput): Promise<ResolveVisitorIdentityResult> {
485
+ try {
486
+ const gate = await this.gateVisitorPrivacyRequest(input.widgetKey, input.visitorKey, input.origin);
487
+ if (!gate.ok) {
488
+ return { success: false, errorCode: gate.errorCode, error: gate.error };
489
+ }
490
+ const identity = await resolveIdentityByEmail(input.verifiedEmail, gate.contextUser, Metadata.Provider, this.config.identityResolution); // global-provider-ok: server-side mint under the single default provider
491
+ if (!identity) {
492
+ // Verified, but no record matches the email under the configured target — nothing to merge.
493
+ return { success: true, mergedConversations: 0 };
494
+ }
495
+ const mergedConversations = await mergeVisitorIdentity({
496
+ visitorKey: input.visitorKey,
497
+ applicationId: gate.widget.ApplicationID,
498
+ identity,
499
+ contextUser: gate.contextUser,
500
+ provider: Metadata.Provider, // global-provider-ok: server-side mint under the single default provider
501
+ });
502
+ return { success: true, resolved: identity, mergedConversations };
503
+ } catch (e) {
504
+ LogError(e);
505
+ return { success: false, errorCode: 'server_error', error: e instanceof Error ? e.message : String(e) };
506
+ }
507
+ }
508
+
509
+ /**
510
+ * RV5 "forget me": archives the visitor's auto-generated memory and clears the VisitorKey linkage for
511
+ * this anchor in the widget's application. Public (the visitor proves possession of the durable key,
512
+ * not an identity). Validates the widget + origin + opt-in + key, then delegates to {@link forgetVisitor}.
513
+ */
514
+ public async ForgetVisitor(input: ForgetVisitorInput): Promise<ForgetVisitorResult> {
515
+ try {
516
+ const gate = await this.gateVisitorPrivacyRequest(input.widgetKey, input.visitorKey, input.origin);
517
+ if (!gate.ok) {
518
+ return { success: false, errorCode: gate.errorCode, error: gate.error };
519
+ }
520
+ const { notesArchived, conversationsCleared } = await forgetVisitor({
521
+ visitorKey: input.visitorKey,
522
+ applicationId: gate.widget.ApplicationID,
523
+ contextUser: gate.contextUser,
524
+ provider: Metadata.Provider, // global-provider-ok: server-side mint under the single default provider
525
+ });
526
+ return { success: true, notesArchived, conversationsCleared };
527
+ } catch (e) {
528
+ LogError(e);
529
+ return { success: false, errorCode: 'server_error', error: e instanceof Error ? e.message : String(e) };
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Shared guard for the RV4/RV5 visitor-privacy endpoints: resolves the lookup user, loads + validates
535
+ * the widget (status/origin), requires returning-visitor memory to be ON, and validates the presented
536
+ * VisitorKey shape. Returns the resolved widget + context user on success, or a structured failure.
537
+ */
538
+ private async gateVisitorPrivacyRequest(
539
+ widgetKey: string,
540
+ visitorKey: string,
541
+ origin: string | undefined,
542
+ ): Promise<VisitorPrivacyGateResult> {
543
+ const contextUser = this.resolveLookupUser();
544
+ if (!contextUser) {
545
+ return { ok: false, errorCode: 'server_error', error: 'Server not configured for widget sessions.' };
546
+ }
547
+ const widget = await this.loadWidgetByKey(widgetKey, contextUser);
548
+ if (!widget) {
549
+ return { ok: false, errorCode: 'not_found', error: 'Unknown widget key.' };
550
+ }
551
+ const eligibility = evaluateWidgetMint(
552
+ { Status: widget.Status, AllowedOrigins: widget.AllowedOrigins, Modality: widget.Modality },
553
+ origin,
554
+ );
555
+ if (!eligibility.ok) {
556
+ return { ok: false, errorCode: eligibility.errorCode ?? 'server_error', error: 'Widget request rejected.' };
557
+ }
558
+ if (!widget.RememberReturningVisitors) {
559
+ return { ok: false, errorCode: 'upgrade_not_enabled', error: 'Returning-visitor memory is not enabled for this widget.' };
560
+ }
561
+ if (!VISITOR_KEY_PATTERN.test((visitorKey ?? '').trim())) {
562
+ return { ok: false, errorCode: 'not_found', error: 'A valid visitorKey is required.' };
563
+ }
564
+ return { ok: true, widget, contextUser };
565
+ }
566
+
567
+ /**
568
+ * Resolves the returning-visitor anchor for a mint (RV1). Returns undefined when the widget does NOT
569
+ * opt in (RememberReturningVisitors off) — the default, fully-off path: no cookie, no chaining.
570
+ * When on: validates the presented key (else mints a fresh one) and, for a returning key, finds the
571
+ * visitor's most-recent prior conversation in this widget's application to chain from. Best-effort:
572
+ * a lookup failure degrades to "no prior conversation" rather than blocking the session.
573
+ */
574
+ private async resolveReturningVisitor(
575
+ widget: MJConversationWidgetInstanceEntity,
576
+ presentedKey: string | undefined,
577
+ contextUser: UserInfo,
578
+ ): Promise<ReturningVisitorResolution | undefined> {
579
+ if (!widget.RememberReturningVisitors) {
580
+ return undefined;
581
+ }
582
+ const presented = (presentedKey ?? '').trim();
583
+ const isReturning = VISITOR_KEY_PATTERN.test(presented);
584
+ const visitorKey = isReturning ? presented : generateSessionId();
585
+ const lastConversationId = isReturning
586
+ ? await this.findPreviousConversationByVisitorKey(visitorKey, widget.ApplicationID, contextUser)
587
+ : undefined;
588
+ return { visitorKey, lastConversationId };
589
+ }
590
+
591
+ /**
592
+ * Recaps the visitor's prior conversation when they return (RV2, text path). No-op unless this is a
593
+ * returning visit (a resolved `lastConversationId`). Delegates to the shared, idempotent,
594
+ * best-effort {@link writeReturningVisitorRecap}, attributing the recap to the widget's pinned agent
595
+ * and stamping the widget's `VisitorMemoryRetentionDays` as the note's expiry. Awaited so the recap
596
+ * note exists before the new session's memory injection (RV3) runs; the recap's own idempotency
597
+ * bounds the LLM cost to the first return after each prior conversation.
598
+ */
599
+ private async recapPriorConversationIfNeeded(
600
+ widget: MJConversationWidgetInstanceEntity,
601
+ returningVisitor: ReturningVisitorResolution | undefined,
602
+ contextUser: UserInfo,
603
+ ): Promise<void> {
604
+ const priorConversationId = returningVisitor?.lastConversationId;
605
+ if (!priorConversationId) {
606
+ return;
607
+ }
608
+ // global-provider-ok: server-side mint under the single default provider (same rationale as resolveGuestRoleName).
609
+ await writeReturningVisitorRecap(
610
+ priorConversationId,
611
+ widget.PinnedAgentID,
612
+ contextUser,
613
+ Metadata.Provider, // global-provider-ok: server-side mint under the single default provider
614
+ widget.VisitorMemoryRetentionDays,
615
+ );
616
+ }
617
+
618
+ /**
619
+ * Finds the visitor's most-recent prior conversation for a VisitorKey, scoped to the widget's
620
+ * application so a key can never resolve a conversation from a different deployment. Returns the
621
+ * id or undefined (first visit, or read failure — never throws).
622
+ */
623
+ private async findPreviousConversationByVisitorKey(
624
+ visitorKey: string,
625
+ applicationId: string,
626
+ contextUser: UserInfo,
627
+ ): Promise<string | undefined> {
628
+ const rv = new RunView();
629
+ const result = await rv.RunView<MJConversationEntity>(
630
+ {
631
+ EntityName: CONVERSATIONS_ENTITY,
632
+ // visitorKey is validated base64url ([A-Za-z0-9_-]) so the literal is injection-safe; applicationId
633
+ // is a server-trusted UUID. Scope to the application to prevent cross-widget anchor reuse.
634
+ ExtraFilter: `VisitorKey = '${visitorKey}' AND ApplicationID = '${applicationId.replace(/'/g, "''")}'`,
635
+ OrderBy: '__mj_CreatedAt DESC',
636
+ MaxRows: 1,
637
+ Fields: ['ID'],
638
+ ResultType: 'simple',
639
+ },
640
+ contextUser,
641
+ );
642
+ if (!result.Success) {
643
+ LogError(`[Widget] Returning-visitor lookup failed: ${result.ErrorMessage}`);
644
+ return undefined;
645
+ }
646
+ return result.Results?.[0]?.ID ?? undefined;
647
+ }
648
+
649
+ /** Loads a single widget instance by its public key (read-only, simple result). */
650
+ private async loadWidgetByKey(widgetKey: string, contextUser: UserInfo): Promise<MJConversationWidgetInstanceEntity | null> {
651
+ const key = (widgetKey ?? '').trim();
652
+ if (!key) {
653
+ return null;
654
+ }
655
+ const rv = new RunView();
656
+ const result = await rv.RunView<MJConversationWidgetInstanceEntity>(
657
+ {
658
+ EntityName: WIDGET_ENTITY,
659
+ ExtraFilter: `PublicKey = '${key.replace(/'/g, "''")}'`,
660
+ MaxRows: 1,
661
+ ResultType: 'entity_object',
662
+ },
663
+ contextUser,
664
+ );
665
+ if (!result.Success) {
666
+ LogError(`[Widget] Failed to load widget by key: ${result.ErrorMessage}`);
667
+ return null;
668
+ }
669
+ return result.Results?.[0] ?? null;
670
+ }
671
+
672
+ /** Resolves the guest role's NAME from its id (claims carry the name, not the id). */
673
+ private resolveGuestRoleName(guestRoleId: string): string | null {
674
+ const md = Metadata.Provider; // global-provider-ok: pre-auth server-side mint under the single default provider
675
+ const role = md?.Roles.find((r) => UUIDsEqual(r.ID, guestRoleId));
676
+ return role?.Name ?? null;
677
+ }
678
+
679
+ /** Resolves the server-side context user used to READ widget config (not the guest principal). */
680
+ private resolveLookupUser(): UserInfo | null {
681
+ const candidate = this.config.contextUserForLookup;
682
+ if (candidate) {
683
+ const byName = UserCache.Instance.UserByName(candidate);
684
+ if (byName) {
685
+ return byName;
686
+ }
687
+ LogError(`[Widget] Configured lookup user '${candidate}' not found; falling back to system/Owner.`);
688
+ }
689
+ return (
690
+ UserCache.Instance.GetSystemUser() ??
691
+ UserCache.Users.find((u) => u.IsActive && u.Type?.trim().toLowerCase() === 'owner') ??
692
+ null
693
+ );
694
+ }
695
+
696
+ /**
697
+ * Best-effort structured audit of a mint attempt. Never alters the result — losing
698
+ * an audit line must not change whether a visitor got (or was denied) a session.
699
+ * (A dedicated `MJ: Widget Session` audit entity is a follow-on; for now this is a
700
+ * structured log line carrying the same forensics as the magic-link redemption row.)
701
+ */
702
+ private audited(result: MintGuestSessionResult, input: MintGuestSessionInput, widget: MJConversationWidgetInstanceEntity | null): MintGuestSessionResult {
703
+ try {
704
+ LogStatus(
705
+ `[Widget][audit] mint outcome=${result.success ? 'success' : result.errorCode} ` +
706
+ `widget=${widget?.ID ?? 'unknown'} origin=${input.audit?.origin ?? input.origin ?? '-'} ` +
707
+ `ip=${input.audit?.ipAddress ?? '-'}`,
708
+ );
709
+ } catch {
710
+ /* audit is best-effort */
711
+ }
712
+ return result;
713
+ }
714
+ }