@rehers/rehers-roleplay-sdk 3.0.0 → 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/index.d.ts CHANGED
@@ -75,13 +75,27 @@ export interface SeamlessRoleplaySDK {
75
75
  /** Open the roleplay modal for a contact (dialog mode). */
76
76
  open(data: SeamlessRoleplayOpenData): void;
77
77
  /** Mount the full Roleplay app into a container. */
78
- 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;
79
87
  /** Open the add-to-scenario dialog for bulk contact import. */
80
88
  addToScenario(options: AddToScenarioOptions): void;
81
89
  /** Close the active dialog. Does not affect mount. */
82
90
  close(): void;
83
91
  /** Unmount the mounted embed. Does not affect dialogs. */
84
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;
85
99
  /** Destroy the SDK — clears state, timers, and DOM (both mount and dialogs). */
86
100
  destroy(): void;
87
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehers/rehers-roleplay-sdk",
3
- "version": "3.0.0",
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,7 +8,7 @@ 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;
@@ -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,14 +12,6 @@ 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
17
  export function SeamlessRoleplayProvider({ getUserToken, origin, onReady, onError, children, }) {
@@ -27,8 +19,49 @@ export function SeamlessRoleplayProvider({ getUserToken, origin, onReady, onErro
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({ getUserToken, origin, onReady, onErro
37
70
  mountedRef.current = true;
38
71
  setState({ isReady: false, error: null });
39
72
  const initOptions = {
40
- getUserToken,
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({ getUserToken, origin, onReady, onErro
60
101
  providerMountCount--;
61
102
  sdk.destroy();
62
103
  };
63
- }, [sdk, getUserToken, 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
@@ -30,6 +30,8 @@
30
30
  var fetchingSession = null; // single-flight Promise
31
31
  var activeSessionXhr = null;
32
32
  var activeInitVersion = 0;
33
+ var sessionRefreshExpiredNotified = false;
34
+ var visibilityListener = null;
33
35
 
34
36
  var initCallbacks = { onReady: null, onError: null };
35
37
  var initCalled = false;
@@ -51,6 +53,11 @@
51
53
  var dialogListener = null;
52
54
  var dialogCloseTeardownTimer = null;
53
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
+
54
61
  // ── Safe logging ──────────────────────────────────────────────────
55
62
 
56
63
  function logError(method, err) {
@@ -73,6 +80,31 @@
73
80
  return DEFAULT_API_ORIGIN;
74
81
  }
75
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
+
76
108
  function buildIframeSrc(path) {
77
109
  var targetPath = path || "/embed/roleplay-call";
78
110
  var url = new URL(targetPath, getOrigin());
@@ -213,15 +245,18 @@
213
245
  if (refreshTimer) clearTimeout(refreshTimer);
214
246
 
215
247
  var remainingMs = sessionExpiresAt - Date.now();
216
- if (remainingMs <= sessionRefreshBufferMs) {
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;
217
256
  notifySessionRefreshFailed(err);
218
- return;
219
257
  }
220
258
 
221
- var delay = Math.min(
222
- refreshRetryDelayMs,
223
- Math.max(1000, remainingMs - sessionRefreshBufferMs)
224
- );
259
+ var delay = Math.max(1000, refreshRetryDelayMs);
225
260
  refreshRetryDelayMs = Math.min(refreshRetryDelayMs * 2, 60000);
226
261
 
227
262
  refreshTimer = setTimeout(function () {
@@ -229,6 +264,26 @@
229
264
  }, delay);
230
265
  }
231
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
+
232
287
  function fetchSession(options) {
233
288
  options = options || {};
234
289
  var requestInitVersion = activeInitVersion;
@@ -296,6 +351,7 @@
296
351
  sessionRefreshBufferMs = computeRefreshBuffer(ttl);
297
352
  sessionExpiresAt = Date.now() + ttl;
298
353
  refreshRetryDelayMs = 5000;
354
+ sessionRefreshExpiredNotified = false;
299
355
  scheduleRefresh(ttl);
300
356
  if (options.broadcastRefresh) broadcastSessionRefresh();
301
357
  resolve({ sessionToken: sessionToken });
@@ -306,6 +362,8 @@
306
362
  // Trial mode — not a fatal error
307
363
  sessionToken = null;
308
364
  sessionExpiresAt = 0;
365
+ refreshRetryDelayMs = 5000;
366
+ sessionRefreshExpiredNotified = false;
309
367
  if (refreshTimer) {
310
368
  clearTimeout(refreshTimer);
311
369
  refreshTimer = null;
@@ -375,24 +433,32 @@
375
433
  // ── Teardown (dialog only — mount is independent) ─────────────────
376
434
 
377
435
  function teardownDialog() {
378
- try {
379
- if (dialogCloseTeardownTimer) {
380
- clearTimeout(dialogCloseTeardownTimer);
381
- dialogCloseTeardownTimer = null;
382
- }
436
+ if (dialogCloseTeardownTimer) {
437
+ clearTimeout(dialogCloseTeardownTimer);
438
+ dialogCloseTeardownTimer = null;
439
+ }
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
+ }
383
449
 
450
+ try {
384
451
  if (dialogOverlay && dialogOverlay.parentNode) {
385
452
  dialogOverlay.parentNode.removeChild(dialogOverlay);
386
453
  }
387
- dialogOverlay = null;
388
-
389
- if (dialogListener) {
390
- window.removeEventListener("message", dialogListener);
391
- dialogListener = null;
392
- }
393
454
  } catch (e) {
394
455
  logError("teardownDialog", e);
395
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();
396
462
 
397
463
  dialogIframe = null;
398
464
  dialogContactData = null;
@@ -403,14 +469,18 @@
403
469
  }
404
470
 
405
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
+
406
480
  try {
407
481
  if (mountIframe && mountIframe.parentNode) {
408
482
  mountIframe.parentNode.removeChild(mountIframe);
409
483
  }
410
- if (mountListener) {
411
- window.removeEventListener("message", mountListener);
412
- mountListener = null;
413
- }
414
484
  } catch (e) {
415
485
  logError("teardownMount", e);
416
486
  }
@@ -420,6 +490,128 @@
420
490
  mountCallbacks = { onClose: null, onError: null };
421
491
  }
422
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
+
423
615
  // ── Message dispatch ──────────────────────────────────────────────
424
616
 
425
617
  function dispatchInitToTarget(targetIframe, contactData, atsContacts) {
@@ -646,11 +838,30 @@
646
838
  userTokenRequest = null;
647
839
 
648
840
  getUserToken = opts.getUserToken;
649
- appOrigin = opts.origin || null;
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
+ }
650
851
  initCallbacks.onReady = opts.onReady || null;
651
852
  initCallbacks.onError = opts.onError || null;
652
853
  initCalled = true;
653
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
+
654
865
  // Fetch session immediately
655
866
  fetchSession()
656
867
  .then(function (result) {
@@ -765,6 +976,7 @@
765
976
  dialogOverlay = el;
766
977
  dialogIframe = iframeEl;
767
978
  document.body.appendChild(dialogOverlay);
979
+ setupDialogAccessibility(el, closeBtn, "Roleplay call");
768
980
  } catch (e) {
769
981
  logError("open", e);
770
982
  teardownDialog();
@@ -777,7 +989,7 @@
777
989
  /**
778
990
  * Mount the full Roleplay app into a container element.
779
991
  */
780
- mount: function (container) {
992
+ mount: function (container, opts) {
781
993
  try {
782
994
  if (!initCalled) {
783
995
  logError("mount", "init() must be called first");
@@ -791,7 +1003,10 @@
791
1003
  // Tear down any existing mount (re-mount)
792
1004
  if (mountIframe) teardownMount();
793
1005
 
794
- mountCallbacks = { onClose: null, onError: null };
1006
+ mountCallbacks = {
1007
+ onClose: (opts && opts.onClose) || null,
1008
+ onError: (opts && opts.onError) || null,
1009
+ };
795
1010
  mountContainer = container;
796
1011
 
797
1012
  // Listen for messages
@@ -928,6 +1143,7 @@
928
1143
  dialogOverlay = el;
929
1144
  dialogIframe = iframeEl;
930
1145
  document.body.appendChild(dialogOverlay);
1146
+ setupDialogAccessibility(el, closeBtn, "Add contacts to scenario");
931
1147
  } catch (e) {
932
1148
  logError("addToScenario", e);
933
1149
  teardownDialog();
@@ -960,6 +1176,28 @@
960
1176
  }
961
1177
  },
962
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
+
963
1201
  /**
964
1202
  * Destroy the SDK — clears state, timers, and DOM (both mount and dialogs).
965
1203
  */
@@ -970,6 +1208,11 @@
970
1208
  clearTimeout(refreshTimer);
971
1209
  refreshTimer = null;
972
1210
  }
1211
+ if (visibilityListener && typeof document !== "undefined") {
1212
+ document.removeEventListener("visibilitychange", visibilityListener);
1213
+ visibilityListener = null;
1214
+ }
1215
+ sessionRefreshExpiredNotified = false;
973
1216
  clearSessionRequest();
974
1217
  teardownDialog();
975
1218
  teardownMount();