@rehers/rehers-roleplay-sdk 2.5.7 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ Seamless Roleplay SDK
2
+ Copyright 2026 Scriptify, Inc.
3
+
4
+ This product is licensed under the Apache License, Version 2.0.
5
+ See the LICENSE file for the full license text.
package/README.md CHANGED
@@ -17,8 +17,8 @@ Add this once, above all your routes. It initializes the SDK for the logged-in S
17
17
  Production flow:
18
18
 
19
19
  1. Your backend requests a short-lived `userToken` from `POST /api/seamless/auth/user-token`
20
- 2. Your frontend receives that `userToken`
21
- 3. You pass `userToken` into the SDK
20
+ 2. Your frontend provides a `getUserToken()` callback that calls your backend route
21
+ 3. The SDK calls `getUserToken()` on startup and before the iframe session expires
22
22
 
23
23
  The browser should not mint sessions from raw identity fields in production.
24
24
 
@@ -26,11 +26,18 @@ The browser should not mint sessions from raw identity fields in production.
26
26
  import { SeamlessRoleplayProvider } from "@rehers/rehers-roleplay-sdk/react";
27
27
 
28
28
  function App() {
29
- const userToken = useRoleplayUserToken(); // fetched from your backend
29
+ async function getUserToken() {
30
+ const tokenRes = await fetch("/api/roleplay/user-token", {
31
+ method: "POST",
32
+ credentials: "include",
33
+ }).then((r) => r.json());
34
+
35
+ return tokenRes.userToken;
36
+ }
30
37
 
31
38
  return (
32
39
  <SeamlessRoleplayProvider
33
- userToken={userToken}
40
+ getUserToken={getUserToken}
34
41
  onReady={() => console.log("Roleplay SDK ready")}
35
42
  onError={(err) => console.error("Roleplay SDK error", err)}
36
43
  >
@@ -55,7 +62,9 @@ const tokenRes = await fetch("/api/roleplay/user-token", {
55
62
  const userToken = tokenRes.userToken;
56
63
  ```
57
64
 
58
- That's the only setup. Everything below just works.
65
+ The SDK owns session refresh timing and will call `getUserToken()` again before
66
+ the embedded app session expires. That's the only setup. Everything below just
67
+ works.
59
68
 
60
69
  ---
61
70
 
@@ -221,7 +230,14 @@ import "@rehers/rehers-roleplay-sdk";
221
230
 
222
231
  // Initialize once
223
232
  SeamlessRoleplay.init({
224
- userToken: "...",
233
+ getUserToken: async () => {
234
+ const res = await fetch("/api/roleplay/user-token", {
235
+ method: "POST",
236
+ credentials: "include",
237
+ });
238
+ const data = await res.json();
239
+ return data.userToken;
240
+ },
225
241
  onReady() { console.log("ready"); },
226
242
  });
227
243
 
package/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
+ export type SeamlessRoleplayUserTokenProvider = () => string | Promise<string>;
2
+
1
3
  interface SeamlessRoleplayInitBase {
2
- /** Short-lived signed JWT minted by your backend for the signed-in user */
3
- userToken: string;
4
+ /**
5
+ * Returns a fresh short-lived signed user JWT minted by your backend for the
6
+ * currently signed-in Seamless user. The SDK calls this on startup and before
7
+ * its iframe session expires.
8
+ */
9
+ getUserToken: SeamlessRoleplayUserTokenProvider;
4
10
  /** Override the app origin — where the iframe loads from (for dev/testing only) */
5
11
  origin?: string;
6
12
  /** Called when the SDK session is ready */
@@ -64,18 +70,32 @@ export interface AddToScenarioOptions {
64
70
  }
65
71
 
66
72
  export interface SeamlessRoleplaySDK {
67
- /** Initialize the SDK with a short-lived user token. */
73
+ /** Initialize the SDK with a host-provided user token callback. */
68
74
  init(options: SeamlessRoleplayInitOptions): void;
69
75
  /** Open the roleplay modal for a contact (dialog mode). */
70
76
  open(data: SeamlessRoleplayOpenData): void;
71
77
  /** Mount the full Roleplay app into a container. */
72
- mount(container: HTMLElement): void;
78
+ mount(
79
+ container: HTMLElement,
80
+ options?: {
81
+ /** Called if the embedded app closes itself. */
82
+ onClose?: () => void;
83
+ /** Called if the embedded app reports an error. */
84
+ onError?: (error: { code: string; message: string }) => void;
85
+ }
86
+ ): void;
73
87
  /** Open the add-to-scenario dialog for bulk contact import. */
74
88
  addToScenario(options: AddToScenarioOptions): void;
75
89
  /** Close the active dialog. Does not affect mount. */
76
90
  close(): void;
77
91
  /** Unmount the mounted embed. Does not affect dialogs. */
78
92
  unmount(): void;
93
+ /**
94
+ * Force a session re-resolution — e.g. after the user completes payment in
95
+ * another tab. Re-fetches and broadcasts the session to any mounted embed or
96
+ * open dialog. (The SDK also auto-recovers when the tab is refocused.)
97
+ */
98
+ reauth(): void;
79
99
  /** Destroy the SDK — clears state, timers, and DOM (both mount and dialogs). */
80
100
  destroy(): void;
81
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehers/rehers-roleplay-sdk",
3
- "version": "2.5.7",
3
+ "version": "3.1.0",
4
4
  "sideEffects": true,
5
5
  "description": "Seamless Roleplay SDK — embed roleplay call sessions via a modal + iframe",
6
6
  "main": "roleplay-sdk.js",
@@ -19,7 +19,9 @@
19
19
  "roleplay-sdk.js",
20
20
  "index.d.ts",
21
21
  "react.js",
22
- "react.d.ts"
22
+ "react.d.ts",
23
+ "LICENSE",
24
+ "NOTICE"
23
25
  ],
24
26
  "peerDependencies": {
25
27
  "react": ">=18.0.0",
@@ -30,7 +32,7 @@
30
32
  "react-dom": { "optional": true }
31
33
  },
32
34
  "keywords": ["seamless", "roleplay", "sdk", "sales", "training", "react"],
33
- "license": "UNLICENSED",
35
+ "license": "Apache-2.0",
34
36
  "repository": {
35
37
  "type": "git",
36
38
  "url": "git+https://github.com/rehers/seamless-frontend-independent.git"
package/react.d.ts CHANGED
@@ -8,12 +8,12 @@ interface SeamlessRoleplayContextValue {
8
8
  code: string;
9
9
  message: string;
10
10
  } | null;
11
- sdk: SeamlessRoleplaySDK;
11
+ sdk: SeamlessRoleplaySDK | null;
12
12
  }
13
13
  export type SeamlessRoleplayProviderProps = SeamlessRoleplayInitOptions & {
14
14
  children: ReactNode;
15
15
  };
16
- export declare function SeamlessRoleplayProvider({ userToken, origin, onReady, onError, children, }: SeamlessRoleplayProviderProps): import("react/jsx-runtime").JSX.Element;
16
+ export declare function SeamlessRoleplayProvider({ getUserToken, origin, onReady, onError, children, }: SeamlessRoleplayProviderProps): import("react/jsx-runtime").JSX.Element;
17
17
  export declare function useSeamlessRoleplay(): SeamlessRoleplayContextValue;
18
18
  export interface RoleplayDialogProps {
19
19
  open: boolean;
@@ -33,8 +33,15 @@ export declare function RoleplayDialog({ open: isOpen, name, domain, company, ti
33
33
  export interface RoleplayEmbedProps {
34
34
  className?: string;
35
35
  style?: React.CSSProperties;
36
+ /** Called if the embedded app reports an error */
37
+ onError?: (error: {
38
+ code: string;
39
+ message: string;
40
+ }) => void;
41
+ /** Called if the embedded app closes itself */
42
+ onClose?: () => void;
36
43
  }
37
- export declare function RoleplayEmbed({ className, style }: RoleplayEmbedProps): import("react/jsx-runtime").JSX.Element;
44
+ export declare function RoleplayEmbed({ className, style, onError, onClose }: RoleplayEmbedProps): import("react/jsx-runtime").JSX.Element;
38
45
  export interface AddToScenarioDialogProps {
39
46
  open: boolean;
40
47
  contacts: AddToScenarioContact[];
package/react.js CHANGED
@@ -12,23 +12,56 @@ function useCallbackRef(cb) {
12
12
  });
13
13
  return ref;
14
14
  }
15
- // ── SDK singleton access ────────────────────────────────────────────
16
- function getSDK() {
17
- if (typeof window !== "undefined" && window.SeamlessRoleplay) {
18
- return window.SeamlessRoleplay;
19
- }
20
- throw new Error("[SeamlessRoleplay/React] Could not find SeamlessRoleplay SDK. " +
21
- 'Make sure "@rehers/rehers-roleplay-sdk" is installed and loaded before the React wrapper.');
22
- }
23
15
  const SeamlessRoleplayContext = createContext(null);
24
16
  let providerMountCount = 0;
25
- export function SeamlessRoleplayProvider({ userToken, origin, onReady, onError, children, }) {
17
+ export function SeamlessRoleplayProvider({ getUserToken, origin, onReady, onError, children, }) {
26
18
  const [state, setState] = useState({ isReady: false, error: null });
27
19
  const mountedRef = useRef(false);
28
20
  const onReadyRef = useCallbackRef(onReady);
29
21
  const onErrorRef = useCallbackRef(onError);
30
- const sdk = useMemo(() => getSDK(), []);
22
+ const getUserTokenRef = useCallbackRef(getUserToken);
23
+ const [sdk, setSdk] = useState(null);
24
+ // Resolve the SDK on the CLIENT only. `window` is absent during SSR, and an
25
+ // async <script> tag may not have executed at first render — so we never call
26
+ // getSDK() during render (that would throw and break SSR/hydration). Poll
27
+ // briefly for an async load, and surface a clear error if it never appears.
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ const resolve = () => {
31
+ if (typeof window !== "undefined" && window.SeamlessRoleplay) {
32
+ if (!cancelled)
33
+ setSdk(window.SeamlessRoleplay);
34
+ return true;
35
+ }
36
+ return false;
37
+ };
38
+ if (resolve())
39
+ return;
40
+ const poll = setInterval(() => {
41
+ if (resolve())
42
+ clearInterval(poll);
43
+ }, 50);
44
+ const giveUp = setTimeout(() => {
45
+ clearInterval(poll);
46
+ if (!cancelled && !(typeof window !== "undefined" && window.SeamlessRoleplay)) {
47
+ setState({
48
+ isReady: false,
49
+ error: {
50
+ code: "SDK_NOT_LOADED",
51
+ message: "@rehers/rehers-roleplay-sdk was not found on window. Make sure it is installed and loaded before the React wrapper.",
52
+ },
53
+ });
54
+ }
55
+ }, 10000);
56
+ return () => {
57
+ cancelled = true;
58
+ clearInterval(poll);
59
+ clearTimeout(giveUp);
60
+ };
61
+ }, []);
31
62
  useEffect(() => {
63
+ if (!sdk)
64
+ return;
32
65
  providerMountCount++;
33
66
  if (providerMountCount > 1 && process.env.NODE_ENV !== "production") {
34
67
  console.warn("[SeamlessRoleplay/React] Multiple SeamlessRoleplayProvider instances detected. " +
@@ -37,7 +70,15 @@ export function SeamlessRoleplayProvider({ userToken, origin, onReady, onError,
37
70
  mountedRef.current = true;
38
71
  setState({ isReady: false, error: null });
39
72
  const initOptions = {
40
- userToken,
73
+ // Ref-guarded so an inline `getUserToken` prop (the common, default way to
74
+ // write it) does NOT re-init the SDK on every parent render. init runs
75
+ // once per SDK/origin, not once per render.
76
+ getUserToken: () => {
77
+ const fn = getUserTokenRef.current;
78
+ if (!fn)
79
+ return Promise.reject(new Error("getUserToken is not available"));
80
+ return fn();
81
+ },
41
82
  origin,
42
83
  onReady: () => {
43
84
  var _a;
@@ -60,7 +101,7 @@ export function SeamlessRoleplayProvider({ userToken, origin, onReady, onError,
60
101
  providerMountCount--;
61
102
  sdk.destroy();
62
103
  };
63
- }, [sdk, userToken, origin]);
104
+ }, [sdk, origin]);
64
105
  const contextValue = useMemo(() => ({ ...state, sdk }), [state, sdk]);
65
106
  return (_jsx(SeamlessRoleplayContext.Provider, { value: contextValue, children: children }));
66
107
  }
@@ -77,18 +118,26 @@ export function RoleplayDialog({ open: isOpen, name, domain, company, title, com
77
118
  const onCloseRef = useCallbackRef(onClose);
78
119
  const onErrorRef = useCallbackRef(onError);
79
120
  const isOpenRef = useRef(false);
121
+ // Keep the latest contact data in a ref so changing a field while the dialog
122
+ // is open does NOT re-run the effect and tear down / restart the live call.
123
+ // The dialog opens once per open-transition; data is read at open time.
124
+ const dataRef = useRef({ name, domain, company, title, companyDescription, liUrl });
125
+ useLayoutEffect(() => {
126
+ dataRef.current = { name, domain, company, title, companyDescription, liUrl };
127
+ });
80
128
  useEffect(() => {
81
- if (!isReady)
129
+ if (!isReady || !sdk)
82
130
  return;
83
131
  if (isOpen) {
84
132
  isOpenRef.current = true;
133
+ const d = dataRef.current;
85
134
  sdk.open({
86
- name,
87
- domain,
88
- company,
89
- title,
90
- companyDescription,
91
- liUrl,
135
+ name: d.name,
136
+ domain: d.domain,
137
+ company: d.company,
138
+ title: d.title,
139
+ companyDescription: d.companyDescription,
140
+ liUrl: d.liUrl,
92
141
  onClose: () => {
93
142
  var _a;
94
143
  isOpenRef.current = false;
@@ -107,16 +156,21 @@ export function RoleplayDialog({ open: isOpen, name, domain, company, title, com
107
156
  sdk.close();
108
157
  }
109
158
  };
110
- }, [isReady, isOpen, name, domain, company, title, companyDescription, liUrl, sdk]);
159
+ }, [isReady, isOpen, sdk]);
111
160
  return null;
112
161
  }
113
- export function RoleplayEmbed({ className, style }) {
162
+ export function RoleplayEmbed({ className, style, onError, onClose }) {
114
163
  const { isReady, sdk } = useSeamlessRoleplay();
115
164
  const containerRef = useRef(null);
165
+ const onErrorRef = useCallbackRef(onError);
166
+ const onCloseRef = useCallbackRef(onClose);
116
167
  useEffect(() => {
117
- if (!isReady || !containerRef.current)
168
+ if (!isReady || !sdk || !containerRef.current)
118
169
  return;
119
- sdk.mount(containerRef.current);
170
+ sdk.mount(containerRef.current, {
171
+ onError: (e) => { var _a; return (_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, e); },
172
+ onClose: () => { var _a; return (_a = onCloseRef.current) === null || _a === void 0 ? void 0 : _a.call(onCloseRef); },
173
+ });
120
174
  return () => {
121
175
  sdk.unmount();
122
176
  };
@@ -134,7 +188,7 @@ export function AddToScenarioDialog({ open: isOpen, contacts, onComplete, onClos
134
188
  contactsRef.current = contacts;
135
189
  });
136
190
  useEffect(() => {
137
- if (!isReady)
191
+ if (!isReady || !sdk)
138
192
  return;
139
193
  if (isOpen) {
140
194
  isOpenRef.current = true;
package/roleplay-sdk.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * SeamlessRoleplay SDK v2
2
+ * SeamlessRoleplay SDK v3
3
3
  *
4
4
  * User-token auth model. No build step required.
5
5
  *
6
6
  * Usage:
7
- * SeamlessRoleplay.init({ userToken: 'jwt...' });
7
+ * SeamlessRoleplay.init({ getUserToken: async () => 'jwt...' });
8
8
  * SeamlessRoleplay.open({ name: '...', domain: '...', company: '...', title: '...' });
9
9
  */
10
10
  (function () {
@@ -16,16 +16,22 @@
16
16
  var SDK_LOG_PREFIX = "[SeamlessRoleplay]";
17
17
 
18
18
  // ── Auth state ────────────────────────────────────────────────────
19
- var userToken = null;
19
+ var getUserToken = null;
20
+ var latestUserToken = null;
21
+ var userTokenRequest = null;
20
22
  var paymentLink = null;
21
23
  var appOrigin = null;
22
24
 
23
25
  var sessionToken = null;
24
26
  var sessionExpiresAt = 0; // epoch ms
27
+ var sessionRefreshBufferMs = 30000;
25
28
  var refreshTimer = null;
29
+ var refreshRetryDelayMs = 5000;
26
30
  var fetchingSession = null; // single-flight Promise
27
31
  var activeSessionXhr = null;
28
32
  var activeInitVersion = 0;
33
+ var sessionRefreshExpiredNotified = false;
34
+ var visibilityListener = null;
29
35
 
30
36
  var initCallbacks = { onReady: null, onError: null };
31
37
  var initCalled = false;
@@ -47,6 +53,11 @@
47
53
  var dialogListener = null;
48
54
  var dialogCloseTeardownTimer = null;
49
55
 
56
+ // ── Dialog accessibility state ────────────────────────────────────
57
+ var dialogPreviousFocus = null; // element to restore focus to on close
58
+ var dialogKeydownListener = null; // Escape handler
59
+ var dialogInertedSiblings = null; // background nodes hidden while modal is open
60
+
50
61
  // ── Safe logging ──────────────────────────────────────────────────
51
62
 
52
63
  function logError(method, err) {
@@ -69,6 +80,31 @@
69
80
  return DEFAULT_API_ORIGIN;
70
81
  }
71
82
 
83
+ // The app origin is used BOTH as the iframe src and as the postMessage target
84
+ // for the session JWT, so an untrusted override would exfiltrate the token.
85
+ // Only allow the production app domain (apex/subdomains, https) or localhost.
86
+ function isAllowedAppOrigin(origin) {
87
+ try {
88
+ var u = new URL(origin);
89
+ if (
90
+ u.protocol === "https:" &&
91
+ (u.hostname === "roleplaywithseamless.ai" ||
92
+ u.hostname.endsWith(".roleplaywithseamless.ai"))
93
+ ) {
94
+ return true;
95
+ }
96
+ if (
97
+ (u.protocol === "http:" || u.protocol === "https:") &&
98
+ (u.hostname === "localhost" || u.hostname === "127.0.0.1")
99
+ ) {
100
+ return true;
101
+ }
102
+ return false;
103
+ } catch (_) {
104
+ return false;
105
+ }
106
+ }
107
+
72
108
  function buildIframeSrc(path) {
73
109
  var targetPath = path || "/embed/roleplay-call";
74
110
  var url = new URL(targetPath, getOrigin());
@@ -89,6 +125,16 @@
89
125
  }
90
126
  }
91
127
 
128
+ function normalizeToken(value) {
129
+ if (typeof value !== "string") return null;
130
+ var trimmed = value.trim();
131
+ return trimmed ? trimmed : null;
132
+ }
133
+
134
+ function makeError(code, message) {
135
+ return { code: code, message: message };
136
+ }
137
+
92
138
  // ── Session management ────────────────────────────────────────────
93
139
 
94
140
  function clearSessionRequest() {
@@ -101,88 +147,262 @@
101
147
  fetchingSession = null;
102
148
  }
103
149
 
104
- function fetchSession() {
150
+ function resolveUserToken() {
151
+ if (typeof getUserToken !== "function") {
152
+ return Promise.reject(
153
+ makeError("INVALID_INIT", "requires { getUserToken }")
154
+ );
155
+ }
156
+
157
+ if (userTokenRequest) return userTokenRequest;
158
+
159
+ userTokenRequest = Promise.resolve()
160
+ .then(function () {
161
+ return getUserToken();
162
+ })
163
+ .then(
164
+ function (value) {
165
+ userTokenRequest = null;
166
+ var token = normalizeToken(value);
167
+ if (!token) {
168
+ throw makeError(
169
+ "USER_TOKEN_ERROR",
170
+ "getUserToken() did not return a valid userToken"
171
+ );
172
+ }
173
+ latestUserToken = token;
174
+ return token;
175
+ },
176
+ function (err) {
177
+ userTokenRequest = null;
178
+ throw makeError(
179
+ (err && err.code) || "USER_TOKEN_ERROR",
180
+ (err && err.message) || "Failed to get a fresh userToken"
181
+ );
182
+ }
183
+ );
184
+
185
+ return userTokenRequest;
186
+ }
187
+
188
+ function dispatchRefreshToTarget(targetIframe) {
189
+ if (!sessionToken) return;
190
+ sendMsg(targetIframe, {
191
+ type: "seamless-session-refresh",
192
+ sessionToken: sessionToken,
193
+ });
194
+ }
195
+
196
+ function broadcastSessionRefresh() {
197
+ if (mountIframe) dispatchRefreshToTarget(mountIframe);
198
+ if (dialogIframe) dispatchRefreshToTarget(dialogIframe);
199
+ }
200
+
201
+ function notifySessionRefreshFailed(err) {
202
+ var error = {
203
+ code: "SESSION_REFRESH_FAILED",
204
+ message:
205
+ (err && err.message) ||
206
+ "Failed to refresh the SDK session before it expired",
207
+ };
208
+
209
+ try {
210
+ if (initCallbacks.onError) initCallbacks.onError(error);
211
+ } catch (_) {}
212
+ try {
213
+ if (mountCallbacks.onError) mountCallbacks.onError(error);
214
+ } catch (_) {}
215
+ try {
216
+ if (dialogCallbacks.onError) dialogCallbacks.onError(error);
217
+ } catch (_) {}
218
+ try {
219
+ if (dialogAddToScenarioCallbacks.onError) {
220
+ dialogAddToScenarioCallbacks.onError(error);
221
+ }
222
+ } catch (_) {}
223
+ }
224
+
225
+ function computeRefreshBuffer(ttlMs) {
226
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) return 30000;
227
+ return Math.min(30000, Math.max(1000, Math.floor(ttlMs * 0.1)));
228
+ }
229
+
230
+ function computeRefreshDelay(ttlMs) {
231
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) return 5000;
232
+
233
+ var eightyPercent = Math.floor(ttlMs * 0.8);
234
+ var oneMinuteBeforeExpiry = ttlMs - 60000;
235
+ var target =
236
+ oneMinuteBeforeExpiry > 0
237
+ ? Math.min(eightyPercent, oneMinuteBeforeExpiry)
238
+ : Math.floor(ttlMs * 0.5);
239
+
240
+ return Math.max(target, 1000);
241
+ }
242
+
243
+ function scheduleRefreshRetry(err) {
244
+ if (!initCalled || !sessionExpiresAt) return;
245
+ if (refreshTimer) clearTimeout(refreshTimer);
246
+
247
+ var remainingMs = sessionExpiresAt - Date.now();
248
+
249
+ // The token has expired: a fresh one is now most needed, so DO NOT give up.
250
+ // Notify the host once that a refresh missed the window, then keep retrying
251
+ // with capped backoff until the session is restored (or the SDK is
252
+ // destroyed / re-initialized). Previously this returned here permanently,
253
+ // so one post-expiry failure killed refresh forever.
254
+ if (remainingMs <= 0 && !sessionRefreshExpiredNotified) {
255
+ sessionRefreshExpiredNotified = true;
256
+ notifySessionRefreshFailed(err);
257
+ }
258
+
259
+ var delay = Math.max(1000, refreshRetryDelayMs);
260
+ refreshRetryDelayMs = Math.min(refreshRetryDelayMs * 2, 60000);
261
+
262
+ refreshTimer = setTimeout(function () {
263
+ fetchSession({ broadcastRefresh: true }).catch(scheduleRefreshRetry);
264
+ }, delay);
265
+ }
266
+
267
+ // Re-resolve the session when the tab becomes visible again. Background tabs
268
+ // throttle timers, so a long-lived session can lapse while hidden; and a user
269
+ // who was in trial mode may have completed payment in another tab. Either way,
270
+ // refreshing on foreground recovers a mounted embed without a page reload.
271
+ function handleVisibilityChange() {
272
+ try {
273
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") {
274
+ return;
275
+ }
276
+ if (!initCalled) return;
277
+ var needsRefresh =
278
+ !sessionToken || Date.now() >= sessionExpiresAt - sessionRefreshBufferMs;
279
+ if (needsRefresh) {
280
+ fetchSession({ broadcastRefresh: true }).catch(function () {});
281
+ }
282
+ } catch (e) {
283
+ logError("handleVisibilityChange", e);
284
+ }
285
+ }
286
+
287
+ function fetchSession(options) {
288
+ options = options || {};
105
289
  var requestInitVersion = activeInitVersion;
106
290
 
107
291
  if (fetchingSession && fetchingSession.initVersion === requestInitVersion) {
292
+ if (options.broadcastRefresh) {
293
+ return fetchingSession.promise.then(function (result) {
294
+ if (
295
+ requestInitVersion === activeInitVersion &&
296
+ result &&
297
+ result.sessionToken
298
+ ) {
299
+ broadcastSessionRefresh();
300
+ }
301
+ return result;
302
+ });
303
+ }
108
304
  return fetchingSession.promise;
109
305
  }
110
306
 
111
- var requestPromise = new Promise(function (resolve, reject) {
112
- var url = getApiOrigin() + "/api/sdk/session";
113
- var body = { userToken: userToken };
307
+ var requestPromise = resolveUserToken().then(function (freshUserToken) {
308
+ if (requestInitVersion !== activeInitVersion) {
309
+ throw makeError("ABORTED", "Stale session request ignored");
310
+ }
114
311
 
115
- var xhr = new XMLHttpRequest();
116
- activeSessionXhr = xhr;
117
- xhr.open("POST", url, true);
118
- xhr.setRequestHeader("Content-Type", "application/json");
119
- xhr.withCredentials = false;
120
- xhr.timeout = SESSION_TIMEOUT_MS;
312
+ return new Promise(function (resolve, reject) {
313
+ var url = getApiOrigin() + "/api/sdk/session";
314
+ var body = { userToken: freshUserToken };
121
315
 
122
- function cleanupRequest() {
123
- if (activeSessionXhr === xhr) {
124
- activeSessionXhr = null;
125
- }
126
- if (fetchingSession && fetchingSession.promise === requestPromise) {
127
- fetchingSession = null;
316
+ var xhr = new XMLHttpRequest();
317
+ activeSessionXhr = xhr;
318
+ xhr.open("POST", url, true);
319
+ xhr.setRequestHeader("Content-Type", "application/json");
320
+ xhr.withCredentials = false;
321
+ xhr.timeout = SESSION_TIMEOUT_MS;
322
+
323
+ function cleanupRequest() {
324
+ if (activeSessionXhr === xhr) {
325
+ activeSessionXhr = null;
326
+ }
327
+ if (fetchingSession && fetchingSession.promise === requestPromise) {
328
+ fetchingSession = null;
329
+ }
128
330
  }
129
- }
130
331
 
131
- xhr.onload = function () {
132
- cleanupRequest();
332
+ xhr.onload = function () {
333
+ cleanupRequest();
133
334
 
134
- if (requestInitVersion !== activeInitVersion) {
135
- reject({ code: "ABORTED", message: "Stale session response ignored" });
136
- return;
137
- }
335
+ if (requestInitVersion !== activeInitVersion) {
336
+ reject({ code: "ABORTED", message: "Stale session response ignored" });
337
+ return;
338
+ }
138
339
 
139
- var data;
140
- try {
141
- data = JSON.parse(xhr.responseText);
142
- } catch (e) {
143
- reject({ code: "PARSE_ERROR", message: "Invalid response from session endpoint" });
144
- return;
145
- }
340
+ var data;
341
+ try {
342
+ data = JSON.parse(xhr.responseText);
343
+ } catch (e) {
344
+ reject({ code: "PARSE_ERROR", message: "Invalid response from session endpoint" });
345
+ return;
346
+ }
146
347
 
147
- if (xhr.status === 200 && data.sessionToken) {
148
- sessionToken = data.sessionToken;
149
- var ttl = (data.expiresIn || 3600) * 1000;
150
- sessionExpiresAt = Date.now() + ttl;
151
- scheduleRefresh(ttl);
152
- resolve({ sessionToken: sessionToken });
153
- return;
154
- }
348
+ if (xhr.status === 200 && data.sessionToken) {
349
+ sessionToken = data.sessionToken;
350
+ var ttl = (data.expiresIn || 3600) * 1000;
351
+ sessionRefreshBufferMs = computeRefreshBuffer(ttl);
352
+ sessionExpiresAt = Date.now() + ttl;
353
+ refreshRetryDelayMs = 5000;
354
+ sessionRefreshExpiredNotified = false;
355
+ scheduleRefresh(ttl);
356
+ if (options.broadcastRefresh) broadcastSessionRefresh();
357
+ resolve({ sessionToken: sessionToken });
358
+ return;
359
+ }
155
360
 
156
- if (data.error === "USER_NOT_FOUND") {
157
- // Trial mode — not a fatal error
158
- sessionToken = null;
159
- if (data.paymentLink) paymentLink = data.paymentLink;
160
- resolve({ trialMode: true });
161
- return;
162
- }
361
+ if (data.error === "USER_NOT_FOUND") {
362
+ // Trial mode — not a fatal error
363
+ sessionToken = null;
364
+ sessionExpiresAt = 0;
365
+ refreshRetryDelayMs = 5000;
366
+ sessionRefreshExpiredNotified = false;
367
+ if (refreshTimer) {
368
+ clearTimeout(refreshTimer);
369
+ refreshTimer = null;
370
+ }
371
+ if (data.paymentLink) paymentLink = data.paymentLink;
372
+ resolve({ trialMode: true });
373
+ return;
374
+ }
163
375
 
164
- reject({
165
- code: data.error || "SESSION_ERROR",
166
- message: data.message || "Failed to create session (HTTP " + xhr.status + ")",
167
- });
168
- };
376
+ reject({
377
+ code: data.error || "SESSION_ERROR",
378
+ message: data.message || "Failed to create session (HTTP " + xhr.status + ")",
379
+ });
380
+ };
169
381
 
170
- xhr.onerror = function () {
171
- cleanupRequest();
172
- reject({ code: "NETWORK_ERROR", message: "Network error contacting session endpoint" });
173
- };
382
+ xhr.onerror = function () {
383
+ cleanupRequest();
384
+ reject({ code: "NETWORK_ERROR", message: "Network error contacting session endpoint" });
385
+ };
174
386
 
175
- xhr.ontimeout = function () {
176
- cleanupRequest();
177
- reject({ code: "TIMEOUT", message: "Session request timed out after " + SESSION_TIMEOUT_MS + "ms" });
178
- };
387
+ xhr.ontimeout = function () {
388
+ cleanupRequest();
389
+ reject({ code: "TIMEOUT", message: "Session request timed out after " + SESSION_TIMEOUT_MS + "ms" });
390
+ };
179
391
 
180
- xhr.onabort = function () {
181
- cleanupRequest();
182
- reject({ code: "ABORTED", message: "Session request was aborted" });
183
- };
392
+ xhr.onabort = function () {
393
+ cleanupRequest();
394
+ reject({ code: "ABORTED", message: "Session request was aborted" });
395
+ };
184
396
 
185
- xhr.send(JSON.stringify(body));
397
+ xhr.send(JSON.stringify(body));
398
+ });
399
+ });
400
+
401
+ requestPromise = requestPromise.catch(function (err) {
402
+ if (fetchingSession && fetchingSession.promise === requestPromise) {
403
+ fetchingSession = null;
404
+ }
405
+ throw err;
186
406
  });
187
407
 
188
408
  fetchingSession = {
@@ -194,7 +414,7 @@
194
414
  }
195
415
 
196
416
  function getSessionToken() {
197
- if (sessionToken && Date.now() < sessionExpiresAt - 30000) {
417
+ if (sessionToken && Date.now() < sessionExpiresAt - sessionRefreshBufferMs) {
198
418
  return Promise.resolve(sessionToken);
199
419
  }
200
420
  return fetchSession().then(function (result) {
@@ -204,35 +424,41 @@
204
424
 
205
425
  function scheduleRefresh(ttlMs) {
206
426
  if (refreshTimer) clearTimeout(refreshTimer);
207
- var delay = Math.max(ttlMs * 0.8, 5000);
427
+ var delay = computeRefreshDelay(ttlMs);
208
428
  refreshTimer = setTimeout(function () {
209
- fetchSession().catch(function () {
210
- // Silent — next open() will retry
211
- });
429
+ fetchSession({ broadcastRefresh: true }).catch(scheduleRefreshRetry);
212
430
  }, delay);
213
431
  }
214
432
 
215
433
  // ── Teardown (dialog only — mount is independent) ─────────────────
216
434
 
217
435
  function teardownDialog() {
218
- try {
219
- if (dialogCloseTeardownTimer) {
220
- clearTimeout(dialogCloseTeardownTimer);
221
- dialogCloseTeardownTimer = null;
222
- }
436
+ if (dialogCloseTeardownTimer) {
437
+ clearTimeout(dialogCloseTeardownTimer);
438
+ dialogCloseTeardownTimer = null;
439
+ }
223
440
 
441
+ // Remove the listener FIRST, in its own try, so a DOM exception below can
442
+ // never skip it (which would leak listeners across many open/close cycles).
443
+ if (dialogListener) {
444
+ try {
445
+ window.removeEventListener("message", dialogListener);
446
+ } catch (_) {}
447
+ dialogListener = null;
448
+ }
449
+
450
+ try {
224
451
  if (dialogOverlay && dialogOverlay.parentNode) {
225
452
  dialogOverlay.parentNode.removeChild(dialogOverlay);
226
453
  }
227
- dialogOverlay = null;
228
-
229
- if (dialogListener) {
230
- window.removeEventListener("message", dialogListener);
231
- dialogListener = null;
232
- }
233
454
  } catch (e) {
234
455
  logError("teardownDialog", e);
235
456
  }
457
+ dialogOverlay = null;
458
+
459
+ // Restore focus to where it was, un-hide/un-inert the background, drop the
460
+ // Escape handler. Safe no-op if accessibility was never set up.
461
+ teardownDialogAccessibility();
236
462
 
237
463
  dialogIframe = null;
238
464
  dialogContactData = null;
@@ -243,14 +469,18 @@
243
469
  }
244
470
 
245
471
  function teardownMount() {
472
+ // Remove the listener FIRST so a DOM exception below can't leak it.
473
+ if (mountListener) {
474
+ try {
475
+ window.removeEventListener("message", mountListener);
476
+ } catch (_) {}
477
+ mountListener = null;
478
+ }
479
+
246
480
  try {
247
481
  if (mountIframe && mountIframe.parentNode) {
248
482
  mountIframe.parentNode.removeChild(mountIframe);
249
483
  }
250
- if (mountListener) {
251
- window.removeEventListener("message", mountListener);
252
- mountListener = null;
253
- }
254
484
  } catch (e) {
255
485
  logError("teardownMount", e);
256
486
  }
@@ -260,6 +490,128 @@
260
490
  mountCallbacks = { onClose: null, onError: null };
261
491
  }
262
492
 
493
+ // ── Dialog accessibility ──────────────────────────────────────────
494
+ // The dialog is a modal over the host page, so it follows the ARIA dialog
495
+ // pattern: labelled role="dialog" aria-modal, focus moved in on open and
496
+ // restored on close, background hidden from assistive tech + made inert, Tab
497
+ // focus trapped via edge sentinels, and Escape to close. NOTE: the content is
498
+ // a cross-origin iframe, so the parent cannot observe keystrokes while focus
499
+ // is INSIDE it — Escape works from the dialog chrome, and the embedded app
500
+ // forwards Escape from within the iframe for full coverage.
501
+
502
+ function hideBackgroundFromAssistiveTech(overlayEl) {
503
+ dialogInertedSiblings = [];
504
+ if (typeof document === "undefined" || !document.body) return;
505
+ var children = document.body.children;
506
+ for (var i = 0; i < children.length; i++) {
507
+ var el = children[i];
508
+ if (el === overlayEl) continue;
509
+ dialogInertedSiblings.push({
510
+ el: el,
511
+ ariaHidden: el.getAttribute("aria-hidden"),
512
+ wasInert: el.hasAttribute("inert"),
513
+ });
514
+ el.setAttribute("aria-hidden", "true");
515
+ try {
516
+ el.inert = true;
517
+ } catch (_) {}
518
+ }
519
+ }
520
+
521
+ function restoreBackgroundFromAssistiveTech() {
522
+ if (!dialogInertedSiblings) return;
523
+ for (var i = 0; i < dialogInertedSiblings.length; i++) {
524
+ var rec = dialogInertedSiblings[i];
525
+ try {
526
+ if (rec.ariaHidden === null) rec.el.removeAttribute("aria-hidden");
527
+ else rec.el.setAttribute("aria-hidden", rec.ariaHidden);
528
+ if (!rec.wasInert) rec.el.inert = false;
529
+ } catch (_) {}
530
+ }
531
+ dialogInertedSiblings = null;
532
+ }
533
+
534
+ function makeFocusSentinel() {
535
+ var s = document.createElement("div");
536
+ s.tabIndex = 0;
537
+ s.setAttribute("aria-hidden", "true");
538
+ var st = s.style;
539
+ st.position = "absolute";
540
+ st.width = "1px";
541
+ st.height = "1px";
542
+ st.padding = "0";
543
+ st.margin = "-1px";
544
+ st.overflow = "hidden";
545
+ st.border = "0";
546
+ st.clip = "rect(0 0 0 0)";
547
+ return s;
548
+ }
549
+
550
+ function setupDialogAccessibility(overlayEl, closeBtn, labelText) {
551
+ try {
552
+ overlayEl.setAttribute("role", "dialog");
553
+ overlayEl.setAttribute("aria-modal", "true");
554
+ if (labelText) overlayEl.setAttribute("aria-label", labelText);
555
+ if (closeBtn) closeBtn.setAttribute("aria-label", "Close");
556
+
557
+ dialogPreviousFocus =
558
+ document.activeElement && document.activeElement.focus
559
+ ? document.activeElement
560
+ : null;
561
+
562
+ hideBackgroundFromAssistiveTech(overlayEl);
563
+
564
+ // Edge sentinels wrap Tab focus back into the dialog. This works across
565
+ // the cross-origin iframe boundary (focus exiting the iframe lands on the
566
+ // trailing sentinel in the parent), where a keydown-based trap cannot.
567
+ var lead = makeFocusSentinel();
568
+ var trail = makeFocusSentinel();
569
+ lead.addEventListener("focus", function () {
570
+ var f = overlayEl.querySelector("iframe");
571
+ if (f) f.focus();
572
+ else if (closeBtn) closeBtn.focus();
573
+ });
574
+ trail.addEventListener("focus", function () {
575
+ if (closeBtn) closeBtn.focus();
576
+ });
577
+ overlayEl.insertBefore(lead, overlayEl.firstChild);
578
+ overlayEl.appendChild(trail);
579
+
580
+ if (closeBtn) {
581
+ try {
582
+ closeBtn.focus();
583
+ } catch (_) {}
584
+ }
585
+
586
+ dialogKeydownListener = function (e) {
587
+ if (e.key === "Escape" || e.keyCode === 27) {
588
+ e.stopPropagation();
589
+ closeDialog();
590
+ }
591
+ };
592
+ document.addEventListener("keydown", dialogKeydownListener, true);
593
+ } catch (e) {
594
+ logError("setupDialogAccessibility", e);
595
+ }
596
+ }
597
+
598
+ function teardownDialogAccessibility() {
599
+ if (dialogKeydownListener) {
600
+ try {
601
+ document.removeEventListener("keydown", dialogKeydownListener, true);
602
+ } catch (_) {}
603
+ dialogKeydownListener = null;
604
+ }
605
+ restoreBackgroundFromAssistiveTech();
606
+ var toRestore = dialogPreviousFocus;
607
+ dialogPreviousFocus = null;
608
+ if (toRestore && toRestore.focus) {
609
+ try {
610
+ toRestore.focus();
611
+ } catch (_) {}
612
+ }
613
+ }
614
+
263
615
  // ── Message dispatch ──────────────────────────────────────────────
264
616
 
265
617
  function dispatchInitToTarget(targetIframe, contactData, atsContacts) {
@@ -451,17 +803,17 @@
451
803
 
452
804
  var SeamlessRoleplay = {
453
805
  /**
454
- * Initialize the SDK with a short-lived user token.
806
+ * Initialize the SDK with a host-provided user token callback.
455
807
  */
456
808
  init: function (opts) {
457
809
  try {
458
810
  var initVersion = activeInitVersion + 1;
459
- var hasUserToken = !!(opts && opts.userToken && String(opts.userToken).trim());
811
+ var hasUserTokenProvider = !!(opts && typeof opts.getUserToken === "function");
460
812
 
461
- if (!opts || !hasUserToken) {
813
+ if (!opts || !hasUserTokenProvider) {
462
814
  var error = {
463
815
  code: "INVALID_INIT",
464
- message: "requires { userToken }",
816
+ message: "requires { getUserToken }",
465
817
  };
466
818
  logError("init", error.message);
467
819
  if (opts && typeof opts.onError === "function") {
@@ -479,14 +831,37 @@
479
831
  clearSessionRequest();
480
832
  sessionToken = null;
481
833
  sessionExpiresAt = 0;
834
+ sessionRefreshBufferMs = 30000;
835
+ refreshRetryDelayMs = 5000;
482
836
  paymentLink = null;
483
-
484
- userToken = opts.userToken || null;
485
- appOrigin = opts.origin || null;
837
+ latestUserToken = null;
838
+ userTokenRequest = null;
839
+
840
+ getUserToken = opts.getUserToken;
841
+ if (opts.origin) {
842
+ if (isAllowedAppOrigin(opts.origin)) {
843
+ appOrigin = opts.origin;
844
+ } else {
845
+ appOrigin = null;
846
+ logError("init", "Ignoring untrusted origin override: " + opts.origin);
847
+ }
848
+ } else {
849
+ appOrigin = null;
850
+ }
486
851
  initCallbacks.onReady = opts.onReady || null;
487
852
  initCallbacks.onError = opts.onError || null;
488
853
  initCalled = true;
489
854
 
855
+ // Re-resolve the session when the tab is refocused (recovers throttled
856
+ // refresh timers and trial->paid transitions). Re-armed on every init().
857
+ if (typeof document !== "undefined" && document.addEventListener) {
858
+ if (visibilityListener) {
859
+ document.removeEventListener("visibilitychange", visibilityListener);
860
+ }
861
+ visibilityListener = handleVisibilityChange;
862
+ document.addEventListener("visibilitychange", visibilityListener);
863
+ }
864
+
490
865
  // Fetch session immediately
491
866
  fetchSession()
492
867
  .then(function (result) {
@@ -601,6 +976,7 @@
601
976
  dialogOverlay = el;
602
977
  dialogIframe = iframeEl;
603
978
  document.body.appendChild(dialogOverlay);
979
+ setupDialogAccessibility(el, closeBtn, "Roleplay call");
604
980
  } catch (e) {
605
981
  logError("open", e);
606
982
  teardownDialog();
@@ -613,7 +989,7 @@
613
989
  /**
614
990
  * Mount the full Roleplay app into a container element.
615
991
  */
616
- mount: function (container) {
992
+ mount: function (container, opts) {
617
993
  try {
618
994
  if (!initCalled) {
619
995
  logError("mount", "init() must be called first");
@@ -627,7 +1003,10 @@
627
1003
  // Tear down any existing mount (re-mount)
628
1004
  if (mountIframe) teardownMount();
629
1005
 
630
- mountCallbacks = { onClose: null, onError: null };
1006
+ mountCallbacks = {
1007
+ onClose: (opts && opts.onClose) || null,
1008
+ onError: (opts && opts.onError) || null,
1009
+ };
631
1010
  mountContainer = container;
632
1011
 
633
1012
  // Listen for messages
@@ -764,6 +1143,7 @@
764
1143
  dialogOverlay = el;
765
1144
  dialogIframe = iframeEl;
766
1145
  document.body.appendChild(dialogOverlay);
1146
+ setupDialogAccessibility(el, closeBtn, "Add contacts to scenario");
767
1147
  } catch (e) {
768
1148
  logError("addToScenario", e);
769
1149
  teardownDialog();
@@ -796,6 +1176,28 @@
796
1176
  }
797
1177
  },
798
1178
 
1179
+ /**
1180
+ * Force a session re-resolution — e.g. after the user completes payment in
1181
+ * another tab. Re-fetches the session and broadcasts it to any mounted embed
1182
+ * or open dialog, recovering trial->paid without a page reload.
1183
+ */
1184
+ reauth: function () {
1185
+ try {
1186
+ if (!initCalled) {
1187
+ logError("reauth", "init() must be called first");
1188
+ return;
1189
+ }
1190
+ refreshRetryDelayMs = 5000;
1191
+ sessionRefreshExpiredNotified = false;
1192
+ fetchSession({ broadcastRefresh: true }).catch(function (err) {
1193
+ if (err && err.code === "ABORTED") return;
1194
+ scheduleRefreshRetry(err);
1195
+ });
1196
+ } catch (e) {
1197
+ logError("reauth", e);
1198
+ }
1199
+ },
1200
+
799
1201
  /**
800
1202
  * Destroy the SDK — clears state, timers, and DOM (both mount and dialogs).
801
1203
  */
@@ -806,13 +1208,22 @@
806
1208
  clearTimeout(refreshTimer);
807
1209
  refreshTimer = null;
808
1210
  }
1211
+ if (visibilityListener && typeof document !== "undefined") {
1212
+ document.removeEventListener("visibilitychange", visibilityListener);
1213
+ visibilityListener = null;
1214
+ }
1215
+ sessionRefreshExpiredNotified = false;
809
1216
  clearSessionRequest();
810
1217
  teardownDialog();
811
1218
  teardownMount();
812
- userToken = null;
1219
+ getUserToken = null;
1220
+ latestUserToken = null;
1221
+ userTokenRequest = null;
813
1222
  paymentLink = null;
814
1223
  sessionToken = null;
815
1224
  sessionExpiresAt = 0;
1225
+ sessionRefreshBufferMs = 30000;
1226
+ refreshRetryDelayMs = 5000;
816
1227
  initCallbacks = { onReady: null, onError: null };
817
1228
  initCalled = false;
818
1229
  } catch (e) {