@hainiva/agent-sdk 0.2.0 → 0.3.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 (41) hide show
  1. package/CHANGELOG.md +23 -4
  2. package/README.md +85 -8
  3. package/dist/cjs/agent.cjs +4 -0
  4. package/dist/cjs/agent.d.cts +5 -1
  5. package/dist/cjs/contract.d.cts +10 -0
  6. package/dist/cjs/embed.cjs +249 -7
  7. package/dist/cjs/embed.d.cts +181 -7
  8. package/dist/cjs/index.cjs +3 -3
  9. package/dist/cjs/index.d.cts +3 -3
  10. package/dist/cjs/react-native.cjs +6 -1
  11. package/dist/cjs/react-native.d.cts +1 -0
  12. package/dist/cjs/reactNativeDesk.cjs +107 -0
  13. package/dist/cjs/reactNativeDesk.d.cts +53 -0
  14. package/dist/cjs/rest.cjs +6 -0
  15. package/dist/cjs/rest.d.cts +7 -1
  16. package/dist/esm/agent.d.ts +5 -1
  17. package/dist/esm/agent.d.ts.map +1 -1
  18. package/dist/esm/agent.js +4 -0
  19. package/dist/esm/agent.js.map +1 -1
  20. package/dist/esm/contract.d.ts +10 -0
  21. package/dist/esm/contract.d.ts.map +1 -1
  22. package/dist/esm/contract.js.map +1 -1
  23. package/dist/esm/embed.d.ts +181 -7
  24. package/dist/esm/embed.d.ts.map +1 -1
  25. package/dist/esm/embed.js +243 -5
  26. package/dist/esm/embed.js.map +1 -1
  27. package/dist/esm/index.d.ts +3 -3
  28. package/dist/esm/index.js +3 -3
  29. package/dist/esm/react-native.d.ts +1 -0
  30. package/dist/esm/react-native.d.ts.map +1 -1
  31. package/dist/esm/react-native.js +3 -0
  32. package/dist/esm/react-native.js.map +1 -1
  33. package/dist/esm/reactNativeDesk.d.ts +54 -0
  34. package/dist/esm/reactNativeDesk.d.ts.map +1 -0
  35. package/dist/esm/reactNativeDesk.js +103 -0
  36. package/dist/esm/reactNativeDesk.js.map +1 -0
  37. package/dist/esm/rest.d.ts +7 -1
  38. package/dist/esm/rest.d.ts.map +1 -1
  39. package/dist/esm/rest.js +6 -0
  40. package/dist/esm/rest.js.map +1 -1
  41. package/package.json +49 -12
package/CHANGELOG.md CHANGED
@@ -7,9 +7,27 @@ release (`0.2.0`) may change the API; a patch release (`0.1.1`) never does.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.2.0]
10
+ ## [0.3.0] - 2026-11-14
11
11
 
12
- Preview, not yet published.
12
+ - `@hainiva/agent-sdk/embed`: `mountDesk(container, { agent, appId, baseUrl,
13
+ theme?, locale?, onEnded?, onDial?, onDialer?, onOpenUrl?, onMediaError? })`
14
+ shows the HAINIVA desk in a sandboxed iframe signed in with your agent
15
+ session and returns `{ destroy(), setTheme(), setLocale(), iframe }`. The
16
+ handshake checks `event.source` and the desk origin, posts to the desk origin
17
+ only, supplies and refreshes the desk token (single flight), and ends on a
18
+ refused session or when the desk does not load (`load_failed`). Exports the
19
+ protocol (`DeskToHost`, `HostToDesk`, `parseDeskMessage`), `DESK_SANDBOX`,
20
+ `DESK_ALLOW` and `deskUrl()`.
21
+ - `@hainiva/agent-sdk/react-native`: `<HainivaDesk agent appId baseUrl onDial …/>`
22
+ in a `react-native-webview` WebView (optional peer, or pass `WebView`):
23
+ bridge injected before the desk's scripts on the desk origin only,
24
+ `originWhitelist` limited to the desk, no `window.open` passthrough; calls
25
+ are handed to your app (`onDial`, `onDialer`).
26
+ - `agent.desk.session()` / `AgentRestClient.deskSession()`:
27
+ `POST /agent/v1/desk-session` → `AgentDeskSessionDto {token, expiresAt}`.
28
+ - Optional peers `react` and `react-native-webview` (only for `<HainivaDesk/>`).
29
+
30
+ ## [0.2.0] - 2026-11-14
13
31
 
14
32
  - `HainivaAgent`: one object for a staff member's app — `connect()`, `me`,
15
33
  `setStatus`, `rings.pending`, `calls.accept/decline/end/hold/mute/transfer`,
@@ -33,7 +51,7 @@ Preview, not yet published.
33
51
 
34
52
  ## [0.1.0]
35
53
 
36
- Preview, not yet published.
54
+ Not published to npm; its contents shipped in 0.2.0.
37
55
 
38
56
  - `SessionManager`: sign-in with a fresh assertion from your backend every
39
57
  time, pluggable `SessionStore` (memory by default), single-flight refresh
@@ -49,6 +67,7 @@ Preview, not yet published.
49
67
  - `/react-native`: `secureStoreSessionStore`, `joinCallRoom` (LiveKit).
50
68
  - `/embed`: placeholder, throws.
51
69
 
52
- [Unreleased]: https://github.com/hainiva/agent-sdk/compare/v0.2.0...HEAD
70
+ [Unreleased]: https://github.com/hainiva/agent-sdk/compare/v0.3.0...HEAD
71
+ [0.3.0]: https://github.com/hainiva/agent-sdk/compare/v0.2.0...v0.3.0
53
72
  [0.2.0]: https://github.com/hainiva/agent-sdk/compare/v0.1.0...v0.2.0
54
73
  [0.1.0]: https://github.com/hainiva/agent-sdk/releases/tag/v0.1.0
package/README.md CHANGED
@@ -11,8 +11,8 @@ browser, in React Native, or in Node.
11
11
  | --- | --- | --- |
12
12
  | `@hainiva/agent-sdk` | `HainivaAgent`, the REST client, the socket, the call state machine, redaction | nothing (no LiveKit, DOM or React Native) |
13
13
  | `@hainiva/agent-sdk/web` | `joinCallRoom` (LiveKit), `localStorageStore` (opt-in) | `livekit-client` |
14
- | `@hainiva/agent-sdk/react-native` | `joinCallRoom`, `secureStoreSessionStore` | `livekit-client`, `@livekit/react-native`, `expo-secure-store` |
15
- | `@hainiva/agent-sdk/embed` | the embeddable desk | not yet available (throws) |
14
+ | `@hainiva/agent-sdk/react-native` | `joinCallRoom`, `secureStoreSessionStore`, `<HainivaDesk/>` | `livekit-client`, `@livekit/react-native`, `expo-secure-store`; `react-native-webview` for `<HainivaDesk/>` |
15
+ | `@hainiva/agent-sdk/embed` | `mountDesk()`: the full HAINIVA desk inside your web page | a browser |
16
16
 
17
17
  Native ringing on phones (CallKit / Android call notification) is
18
18
  [`@hainiva/react-native-call`](https://github.com/hainiva/react-native-call).
@@ -72,7 +72,7 @@ member with **your own** login, it signs a short assertion:
72
72
 
73
73
  | | |
74
74
  | --- | --- |
75
- | header | `alg: "ES256"` (or `RS256`), `kid`: the key id you registered |
75
+ | header | `alg: "ES256"` (or `RS256`), `kid`: the key id you registered. `HS256` works only for a test app on a sandbox organisation. |
76
76
  | `iss` | your agent app id |
77
77
  | `sub` | the member's `externalId` (`PUT /v1/members/by-external-id/:externalId`) |
78
78
  | `aud` | `hainiva-agent` |
@@ -88,8 +88,17 @@ for key generation and a complete signer.
88
88
  expires (one refresh however many requests are waiting; the refresh token
89
89
  rotates). A request answered `401 token_expired` refreshes and is retried once.
90
90
  A refused refresh (expired, revoked, reused) signs in again with a new
91
- assertion. `401 session_revoked` — signed out elsewhere, member suspended, app
92
- paused — is final: the SDK forgets the session and emits `revoked`.
91
+ assertion. `401 session_revoked` (signed out elsewhere, sessions revoked,
92
+ member suspended, role changed, app paused) ends this session: the SDK forgets
93
+ it and emits `revoked`.
94
+
95
+ **Revoking sessions does not cut a member off.** The next `connect()`, or the
96
+ embedded desk asking for a token, signs in again with a fresh assertion, and
97
+ your backend still vouches for the member, so it succeeds. To stop someone
98
+ working, suspend or delete the member (`PATCH /v1/members/:id
99
+ {"status":"suspended"}`, `DELETE /v1/members/:id`) or pause the agent app; then
100
+ sign-in is refused (`401 session_refused`, `403 agent_app_paused`). Stop
101
+ signing assertions for them on your side too.
93
102
 
94
103
  ## Events
95
104
 
@@ -154,15 +163,21 @@ Phones ring through **your** app with **your** Firebase / APNs credentials.
154
163
  before signing out anyway.
155
164
  3. **Show the ring** with `@hainiva/react-native-call` — Android: the FCM data
156
165
  message `{type: "call", tenantId, interactionId, conversationId, path,
157
- caller, line, expiresAt}` (and `call_cancel`); iPhone: a VoIP push to
158
- `<topic>.voip` with the same fields, reported to CallKit natively. See
166
+ caller, line, expiresAt, transferMode?}` (all strings; `expiresAt` in Unix
167
+ milliseconds) and `{type: "call_cancel", tenantId, interactionId, reason}`;
168
+ iPhone: a VoIP push to `<topic>.voip` with the same fields, reported to
169
+ CallKit natively (ring ends come over the socket, not as a push). See
159
170
  [`examples/expo-agent-app`](./examples/expo-agent-app).
160
171
 
161
172
  ## Web
162
173
 
163
174
  Add your page's exact origin to the agent app's `allowedOrigins`. HAINIVA
164
175
  answers the browser's CORS preflight only for origins an agent app lists, and
165
- checks every request against the origins of the app its session belongs to.
176
+ checks every request against the origins of the app its session belongs to
177
+ (`403 origin_not_allowed`). A page on an origin that no app lists is refused at
178
+ the preflight, before any sign-in: `connect()` fails with a
179
+ `HainivaAgentError` of status `0` (`network_error`), which in the browser's
180
+ network panel is a CORS error.
166
181
 
167
182
  The session is kept **in memory** by default: a reload signs in again with a
168
183
  fresh assertion. `localStorageStore()` keeps it across reloads, but **any
@@ -182,6 +197,68 @@ that wakes a locked phone finds it; never migrated to another device) or the
182
197
  Android Keystore. There is no fallback to a plain store. Call
183
198
  `registerGlobals()` from `@livekit/react-native` once at startup.
184
199
 
200
+ ## The embedded desk
201
+
202
+ Show the whole HAINIVA desk inside your product, signed in as your staff
203
+ member with **your** agent session — no HAINIVA login screen, no password.
204
+
205
+ ```ts
206
+ import { mountDesk } from "@hainiva/agent-sdk/embed";
207
+
208
+ await agent.connect();
209
+ const desk = mountDesk(document.getElementById("desk")!, {
210
+ agent, // your connected HainivaAgent
211
+ appId: "YOUR_AGENT_APP_ID",
212
+ baseUrl: "https://app.hainiva.com", // the HAINIVA desk
213
+ theme: "light", locale: "vi",
214
+ onEnded: (reason) => showSignedOut(reason),
215
+ });
216
+ desk.setTheme("dark");
217
+ desk.destroy();
218
+ ```
219
+
220
+ - **Your page's origin must be in the agent app's `allowedOrigins`.** That list
221
+ is also the desk's CSP `frame-ancestors`: any other site gets a blank frame
222
+ (and `onEnded("load_failed")`). Adding an origin lets that site embed the desk
223
+ for this app's staff — list only sites you control.
224
+ - The SDK exchanges your agent session for a 15-minute **desk token**
225
+ (`POST /agent/v1/desk-session`, `agent.desk.session()`) and hands it to the
226
+ desk over `postMessage` — never in the URL, never stored. The desk asks for a
227
+ new one before it expires. When your session ends for good (sign-out,
228
+ member suspended or deleted, app paused) the desk shows "ended" at once and
229
+ `onEnded` fires. Revoking sessions alone does not end the desk: the SDK signs
230
+ in again with a fresh assertion (see Sessions above).
231
+ - The desk token works only for the desk's own work (conversations, calls,
232
+ contacts, tickets…). Settings, team, billing, developer and admin pages are
233
+ refused, and it can never switch organisation.
234
+ - The iframe is created with
235
+ `sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals"`
236
+ (never top navigation: the desk cannot navigate your page) and
237
+ `allow="microphone; camera; autoplay; clipboard-write"`. On the web **the desk
238
+ carries calls itself (WebRTC)**; if your own iframe or Permissions-Policy does
239
+ not delegate the microphone, or the person refuses it, `onMediaError(name)`
240
+ tells you.
241
+ - Messages are read only from that iframe's window **and** the desk origin, and
242
+ only in the documented shapes; everything is sent to the desk origin only.
243
+
244
+ React Native — calls stay native in your app (`@hainiva/react-native-call`),
245
+ the desk hands them to you:
246
+
247
+ ```tsx
248
+ import { WebView } from "react-native-webview";
249
+ import { HainivaDesk } from "@hainiva/agent-sdk/react-native";
250
+
251
+ <HainivaDesk agent={agent} appId={APP_ID} baseUrl="https://app.hainiva.com" WebView={WebView}
252
+ onDial={(number, contactId) => placeCall(number, contactId)}
253
+ onDialer={(prefill) => openDialer(prefill)}
254
+ onOpenUrl={(url) => Linking.openURL(url)}
255
+ onEnded={(reason) => signOut(reason)} />
256
+ ```
257
+
258
+ The WebView stays on the desk origin (`originWhitelist`, every other URL goes
259
+ to `onOpenUrl`), opens no windows, and gets its bridge before the desk's
260
+ scripts, on the desk origin only.
261
+
185
262
  ## Security
186
263
 
187
264
  - **No tokens in URLs or logs.** Tokens travel in the `Authorization` header or
@@ -33,6 +33,10 @@ class HainivaAgent {
33
33
  this.meDto = null;
34
34
  this.media = new Map();
35
35
  this.offs = [];
36
+ this.desk = {
37
+ /** A desk token for the embedded desk (`@hainiva/agent-sdk/embed` mountDesk / `<HainivaDesk/>` call this). */
38
+ session: () => this.guard(this.rest.deskSession()),
39
+ };
36
40
  this.rings = {
37
41
  /** Rings waiting for you now (also fed into the call state). */
38
42
  pending: () => this.syncRings(),
@@ -12,7 +12,7 @@
12
12
  * (`media`, `WebSocket`, `store`), so the core runs in Node too.
13
13
  */
14
14
  import { CallStore, type CallEffect, type CallsState, type TransferTarget } from "./calls.cjs";
15
- import type { AgentAcceptDto, AgentCallEndedData, AgentCallRingingData, AgentConversationDto, AgentDispositionDto, AgentMeDto, AgentMessageDto, AgentPage, AgentPushSubscriptionDto, AgentPushSubscriptionInput, AgentRingDto, AgentSessionDto, SupervisorAgentDto, SupervisorLiveCallDto, SupervisorSuperviseDto } from "./contract.cjs";
15
+ import type { AgentAcceptDto, AgentCallEndedData, AgentDeskSessionDto, AgentCallRingingData, AgentConversationDto, AgentDispositionDto, AgentMeDto, AgentMessageDto, AgentPage, AgentPushSubscriptionDto, AgentPushSubscriptionInput, AgentRingDto, AgentSessionDto, SupervisorAgentDto, SupervisorLiveCallDto, SupervisorSuperviseDto } from "./contract.cjs";
16
16
  import { TypedEmitter } from "./emitter.cjs";
17
17
  import { HainivaAgentError } from "./errors.cjs";
18
18
  import { type AgentEventFrame } from "./events.cjs";
@@ -131,6 +131,10 @@ export declare class HainivaAgent {
131
131
  setStatus(status: AgentStatus, o?: RequestOptions): Promise<{
132
132
  status: AgentStatus;
133
133
  }>;
134
+ readonly desk: {
135
+ /** A desk token for the embedded desk (`@hainiva/agent-sdk/embed` mountDesk / `<HainivaDesk/>` call this). */
136
+ session: () => Promise<AgentDeskSessionDto>;
137
+ };
134
138
  readonly rings: {
135
139
  /** Rings waiting for you now (also fed into the call state). */
136
140
  pending: () => Promise<AgentRingDto[]>;
@@ -142,6 +142,16 @@ export interface AgentSessionDto {
142
142
  /** The event socket on the host the app reached; SDKs derive their own from their base URL. */
143
143
  wsUrl: string;
144
144
  }
145
+ /**
146
+ * POST /agent/v1/desk-session (201) — partner platform S4, the embeddable desk.
147
+ * A 15-minute token for the HAINIVA desk shown inside the partner's product,
148
+ * handed to the desk over postMessage only (never in a URL, never stored).
149
+ * Bound to the agent session it came from: ends when that session ends.
150
+ */
151
+ export interface AgentDeskSessionDto {
152
+ token: string;
153
+ expiresAt: string;
154
+ }
145
155
  /** POST /agent/v1/calls/:id/accept. `media` is null for a call without a room (chat). */
146
156
  export interface AgentAcceptDto {
147
157
  callId: string;
@@ -1,11 +1,253 @@
1
1
  "use strict";
2
- /**
3
- * @hainiva/agent-sdk/embed — the embeddable HAINIVA desk (iframe / WebView with a
4
- * postMessage bridge). Planned for milestone M3; not part of this release.
5
- */
6
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DESK_ALLOW = exports.DESK_SANDBOX = exports.DeskHost = void 0;
4
+ exports.parseDeskMessage = parseDeskMessage;
5
+ exports.deskUrl = deskUrl;
7
6
  exports.mountDesk = mountDesk;
8
- /** Not yet available: throws. */
9
- function mountDesk(_options) {
10
- throw new Error("@hainiva/agent-sdk/embed is not yet available");
7
+ const errors_js_1 = require("./errors.cjs");
8
+ const url_js_1 = require("./url.cjs");
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ const END_REASONS = new Set(["not_embedded", "no_host", "expired", "revoked", "host_ended", "misconfigured"]);
11
+ const PHONE = /^[0-9+*#() .-]{1,40}$/;
12
+ function keysAre(o, required, optional = []) {
13
+ const k = Object.keys(o);
14
+ return required.every((r) => k.includes(r)) && k.every((x) => required.includes(x) || optional.includes(x));
15
+ }
16
+ const optId = (v) => v === undefined || (typeof v === "string" && UUID.test(v));
17
+ /** Exactly one of the desk's messages, or null. */
18
+ function parseDeskMessage(data) {
19
+ let d = data;
20
+ if (typeof d === "string") {
21
+ if (d.length > 8192)
22
+ return null;
23
+ try {
24
+ d = JSON.parse(d);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ if (!d || typeof d !== "object" || Array.isArray(d))
31
+ return null;
32
+ const o = d;
33
+ if (o["v"] !== 1)
34
+ return null;
35
+ switch (o["type"]) {
36
+ case "hainiva:ready":
37
+ return keysAre(o, ["v", "type", "appId"]) && typeof o["appId"] === "string" && UUID.test(o["appId"]) ? { v: 1, type: "hainiva:ready", appId: o["appId"] } : null;
38
+ case "hainiva:token-request":
39
+ return keysAre(o, ["v", "type", "reason"]) && (o["reason"] === "expiring" || o["reason"] === "unauthorized") ? { v: 1, type: "hainiva:token-request", reason: o["reason"] } : null;
40
+ case "hainiva:ended":
41
+ return keysAre(o, ["v", "type", "reason"]) && typeof o["reason"] === "string" && END_REASONS.has(o["reason"])
42
+ ? { v: 1, type: "hainiva:ended", reason: o["reason"] } : null;
43
+ case "hainiva:dial":
44
+ if (!keysAre(o, ["v", "type", "number"], ["contactId"]) || typeof o["number"] !== "string" || !PHONE.test(o["number"]) || !optId(o["contactId"]))
45
+ return null;
46
+ return { v: 1, type: "hainiva:dial", number: o["number"], ...(o["contactId"] ? { contactId: o["contactId"] } : {}) };
47
+ case "hainiva:dialer":
48
+ if (!keysAre(o, ["v", "type"], ["number", "contactId"]) || !optId(o["contactId"]))
49
+ return null;
50
+ if (o["number"] !== undefined && (typeof o["number"] !== "string" || !PHONE.test(o["number"])))
51
+ return null;
52
+ return { v: 1, type: "hainiva:dialer", ...(o["number"] ? { number: o["number"] } : {}), ...(o["contactId"] ? { contactId: o["contactId"] } : {}) };
53
+ case "hainiva:open-url": {
54
+ if (!keysAre(o, ["v", "type", "url"]) || typeof o["url"] !== "string" || o["url"].length > 2048)
55
+ return null;
56
+ let u;
57
+ try {
58
+ u = new URL(o["url"]);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ return ["https:", "http:", "mailto:", "tel:"].includes(u.protocol) ? { v: 1, type: "hainiva:open-url", url: u.href } : null;
64
+ }
65
+ case "hainiva:media-error":
66
+ return keysAre(o, ["v", "type", "name"]) && typeof o["name"] === "string" && o["name"].length <= 60 ? { v: 1, type: "hainiva:media-error", name: o["name"] } : null;
67
+ default:
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * The host side of the desk protocol, transport-agnostic: `mountDesk` (iframe)
73
+ * and `<HainivaDesk/>` (React Native WebView) both drive one.
74
+ * @internal exported for the React Native entry and tests.
75
+ */
76
+ class DeskHost {
77
+ constructor(appId, source, send, cb) {
78
+ this.appId = appId;
79
+ this.source = source;
80
+ this.send = send;
81
+ this.cb = cb;
82
+ this.ended = false;
83
+ this.ready = false;
84
+ this.inflight = null;
85
+ }
86
+ get isReady() { return this.ready; }
87
+ get isEnded() { return this.ended; }
88
+ /** A raw message from the desk (already origin/source-checked by the transport). */
89
+ receive(raw) {
90
+ if (this.ended)
91
+ return;
92
+ const m = parseDeskMessage(raw);
93
+ if (!m)
94
+ return;
95
+ switch (m.type) {
96
+ case "hainiva:ready":
97
+ if (m.appId.toLowerCase() !== this.appId.toLowerCase())
98
+ return;
99
+ this.ready = true;
100
+ void this.supplyToken();
101
+ return;
102
+ case "hainiva:token-request":
103
+ void this.supplyToken();
104
+ return;
105
+ case "hainiva:ended":
106
+ this.finish(m.reason, false);
107
+ return;
108
+ case "hainiva:dial":
109
+ this.cb.onDial?.(m.number, m.contactId);
110
+ return;
111
+ case "hainiva:dialer":
112
+ this.cb.onDialer?.({ ...(m.number ? { number: m.number } : {}), ...(m.contactId ? { contactId: m.contactId } : {}) });
113
+ return;
114
+ case "hainiva:open-url":
115
+ this.cb.onOpenUrl?.(m.url);
116
+ return;
117
+ case "hainiva:media-error":
118
+ this.cb.onMediaError?.(m.name);
119
+ return;
120
+ }
121
+ }
122
+ /** One desk-session request at a time; the token goes to the desk only. */
123
+ supplyToken() {
124
+ if (this.ended)
125
+ return Promise.resolve();
126
+ this.inflight ?? (this.inflight = (async () => {
127
+ try {
128
+ const d = await this.source.desk.session();
129
+ if (!this.ended)
130
+ this.send({ v: 1, type: "hainiva:token", token: d.token, expiresAt: d.expiresAt });
131
+ }
132
+ catch (e) {
133
+ // A final refusal of the agent session: the desk ends with it. A
134
+ // transient failure: the desk asks again (or ends on its own timeout).
135
+ const final = e instanceof errors_js_1.HainivaAgentError && (e.status === 401 || e.status === 403);
136
+ if (final)
137
+ this.finish("session_ended", true);
138
+ }
139
+ finally {
140
+ this.inflight = null;
141
+ }
142
+ })());
143
+ return this.inflight;
144
+ }
145
+ setTheme(mode) { if (mode === "light" || mode === "dark")
146
+ this.send({ v: 1, type: "hainiva:theme", mode }); }
147
+ setLocale(locale) { if (locale === "vi" || locale === "en")
148
+ this.send({ v: 1, type: "hainiva:locale", locale }); }
149
+ finish(reason, tellDesk) {
150
+ if (this.ended)
151
+ return;
152
+ if (tellDesk) {
153
+ try {
154
+ this.send({ v: 1, type: "hainiva:end" });
155
+ }
156
+ catch { /* gone */ }
157
+ }
158
+ this.ended = true;
159
+ this.cb.onEnded?.(reason);
160
+ }
161
+ }
162
+ exports.DeskHost = DeskHost;
163
+ /** The desk page for an app. The URL carries the app id ONLY. */
164
+ function deskUrl(baseUrl, appId, opts = {}) {
165
+ if (!UUID.test(appId))
166
+ throw new errors_js_1.HainivaAgentError(0, "invalid_id", { field: "appId" });
167
+ const base = (0, url_js_1.assertSafeUrl)(baseUrl, { kind: "http", ...(opts.allowInsecureLocalhost ? { allowInsecureLocalhost: true } : {}) });
168
+ const u = new URL("/embed", base.origin);
169
+ u.searchParams.set("app", appId.toLowerCase());
170
+ return u;
171
+ }
172
+ // ── Web: the iframe ──────────────────────────────────────────────────────────
173
+ /**
174
+ * The iframe's sandbox — every flag the desk needs and nothing more:
175
+ * allow-scripts the desk is an application;
176
+ * allow-same-origin the desk keeps ITS OWN origin (app.hainiva.com):
177
+ * its same-origin API calls, its session storage
178
+ * and the gateway's Origin check depend on it. It
179
+ * gives the desk no access to your page — that is
180
+ * a different origin;
181
+ * allow-forms the composer and every dialog submit forms;
182
+ * allow-popups links, recordings and help open in a new tab…
183
+ * allow-popups-to-escape-sandbox …as normal pages, not sandboxed copies;
184
+ * allow-downloads call recordings, attachments, exports;
185
+ * allow-modals the desk confirms destructive actions (confirm()).
186
+ * Deliberately NOT: allow-top-navigation(-by-user-activation) — the desk can
187
+ * never navigate your page; allow-pointer-lock, allow-presentation,
188
+ * allow-orientation-lock, allow-storage-access-by-user-activation.
189
+ */
190
+ exports.DESK_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals";
191
+ /** Calls in the iframe are WebRTC: the microphone (and camera for video) must be delegated. */
192
+ exports.DESK_ALLOW = "microphone; camera; autoplay; clipboard-write";
193
+ /** Show the HAINIVA desk in `container`, signed in as your agent session's staff member. */
194
+ function mountDesk(container, o) {
195
+ if (!container || typeof container.appendChild !== "function")
196
+ throw new errors_js_1.HainivaAgentError(0, "invalid_container", {});
197
+ if (!o?.agent?.desk || typeof o.agent.desk.session !== "function")
198
+ throw new errors_js_1.HainivaAgentError(0, "invalid_agent", {});
199
+ const url = deskUrl(o.baseUrl, o.appId, o.allowInsecureLocalhost ? { allowInsecureLocalhost: true } : {});
200
+ const deskOrigin = url.origin;
201
+ const doc = container.ownerDocument ?? globalThis.document;
202
+ const win = (doc?.defaultView ?? globalThis);
203
+ if (!doc)
204
+ throw new errors_js_1.HainivaAgentError(0, "no_document", {});
205
+ if (win.location?.origin === deskOrigin)
206
+ throw new errors_js_1.HainivaAgentError(0, "same_origin_host", { origin: deskOrigin });
207
+ const iframe = doc.createElement("iframe");
208
+ iframe.setAttribute("sandbox", exports.DESK_SANDBOX);
209
+ iframe.setAttribute("allow", exports.DESK_ALLOW);
210
+ iframe.setAttribute("title", o.title ?? "HAINIVA");
211
+ iframe.setAttribute("referrerpolicy", "origin");
212
+ iframe.style.border = "0";
213
+ iframe.style.width = "100%";
214
+ iframe.style.height = "100%";
215
+ const host = new DeskHost(o.appId, o.agent, (m) => { iframe.contentWindow?.postMessage(m, deskOrigin); }, {
216
+ ...o,
217
+ onEnded: (r) => { clearTimeout(loadTimer); o.onEnded?.(r); },
218
+ });
219
+ const onMessage = (e) => {
220
+ if (e.source !== iframe.contentWindow || e.origin !== deskOrigin)
221
+ return;
222
+ const wasReady = host.isReady;
223
+ host.receive(e.data);
224
+ if (!wasReady && host.isReady) {
225
+ clearTimeout(loadTimer);
226
+ if (o.theme)
227
+ host.setTheme(o.theme);
228
+ if (o.locale)
229
+ host.setLocale(o.locale);
230
+ }
231
+ };
232
+ win.addEventListener("message", onMessage);
233
+ const loadTimer = setTimeout(() => { if (!host.isReady)
234
+ host.finish("load_failed", false); }, o.loadTimeoutMs ?? 20000);
235
+ iframe.setAttribute("src", url.href);
236
+ container.appendChild(iframe);
237
+ return {
238
+ iframe,
239
+ setTheme: (m) => host.setTheme(m),
240
+ setLocale: (l) => host.setLocale(l),
241
+ destroy: () => {
242
+ clearTimeout(loadTimer);
243
+ if (!host.isEnded) {
244
+ try {
245
+ iframe.contentWindow?.postMessage({ v: 1, type: "hainiva:end" }, deskOrigin);
246
+ }
247
+ catch { /* gone */ }
248
+ }
249
+ win.removeEventListener("message", onMessage);
250
+ iframe.remove();
251
+ },
252
+ };
11
253
  }