@hainiva/agent-sdk 0.2.0 → 0.3.1
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/CHANGELOG.md +29 -4
- package/README.md +100 -9
- package/dist/cjs/agent.cjs +4 -0
- package/dist/cjs/agent.d.cts +5 -1
- package/dist/cjs/contract.d.cts +10 -0
- package/dist/cjs/embed.cjs +249 -7
- package/dist/cjs/embed.d.cts +181 -7
- package/dist/cjs/index.cjs +4 -4
- package/dist/cjs/index.d.cts +4 -4
- package/dist/cjs/react-native.cjs +6 -1
- package/dist/cjs/react-native.d.cts +1 -0
- package/dist/cjs/reactNativeDesk.cjs +107 -0
- package/dist/cjs/reactNativeDesk.d.cts +53 -0
- package/dist/cjs/rest.cjs +6 -0
- package/dist/cjs/rest.d.cts +7 -1
- package/dist/esm/agent.d.ts +5 -1
- package/dist/esm/agent.d.ts.map +1 -1
- package/dist/esm/agent.js +4 -0
- package/dist/esm/agent.js.map +1 -1
- package/dist/esm/contract.d.ts +10 -0
- package/dist/esm/contract.d.ts.map +1 -1
- package/dist/esm/contract.js.map +1 -1
- package/dist/esm/embed.d.ts +181 -7
- package/dist/esm/embed.d.ts.map +1 -1
- package/dist/esm/embed.js +243 -5
- package/dist/esm/embed.js.map +1 -1
- package/dist/esm/index.d.ts +4 -4
- package/dist/esm/index.js +4 -4
- package/dist/esm/react-native.d.ts +1 -0
- package/dist/esm/react-native.d.ts.map +1 -1
- package/dist/esm/react-native.js +3 -0
- package/dist/esm/react-native.js.map +1 -1
- package/dist/esm/reactNativeDesk.d.ts +54 -0
- package/dist/esm/reactNativeDesk.d.ts.map +1 -0
- package/dist/esm/reactNativeDesk.js +103 -0
- package/dist/esm/reactNativeDesk.js.map +1 -0
- package/dist/esm/rest.d.ts +7 -1
- package/dist/esm/rest.d.ts.map +1 -1
- package/dist/esm/rest.js +6 -0
- package/dist/esm/rest.js.map +1 -1
- package/package.json +50 -13
package/CHANGELOG.md
CHANGED
|
@@ -7,9 +7,32 @@ 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.
|
|
10
|
+
## [0.3.1] - 2026-11-15
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
- README: the product is named HAINIVA; new Documentation section linking the
|
|
13
|
+
developer docs, the API reference and the sibling SDKs. No code changes.
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-11-14
|
|
16
|
+
|
|
17
|
+
- `@hainiva/agent-sdk/embed`: `mountDesk(container, { agent, appId, baseUrl,
|
|
18
|
+
theme?, locale?, onEnded?, onDial?, onDialer?, onOpenUrl?, onMediaError? })`
|
|
19
|
+
shows the HAINIVA desk in a sandboxed iframe signed in with your agent
|
|
20
|
+
session and returns `{ destroy(), setTheme(), setLocale(), iframe }`. The
|
|
21
|
+
handshake checks `event.source` and the desk origin, posts to the desk origin
|
|
22
|
+
only, supplies and refreshes the desk token (single flight), and ends on a
|
|
23
|
+
refused session or when the desk does not load (`load_failed`). Exports the
|
|
24
|
+
protocol (`DeskToHost`, `HostToDesk`, `parseDeskMessage`), `DESK_SANDBOX`,
|
|
25
|
+
`DESK_ALLOW` and `deskUrl()`.
|
|
26
|
+
- `@hainiva/agent-sdk/react-native`: `<HainivaDesk agent appId baseUrl onDial …/>`
|
|
27
|
+
in a `react-native-webview` WebView (optional peer, or pass `WebView`):
|
|
28
|
+
bridge injected before the desk's scripts on the desk origin only,
|
|
29
|
+
`originWhitelist` limited to the desk, no `window.open` passthrough; calls
|
|
30
|
+
are handed to your app (`onDial`, `onDialer`).
|
|
31
|
+
- `agent.desk.session()` / `AgentRestClient.deskSession()`:
|
|
32
|
+
`POST /agent/v1/desk-session` → `AgentDeskSessionDto {token, expiresAt}`.
|
|
33
|
+
- Optional peers `react` and `react-native-webview` (only for `<HainivaDesk/>`).
|
|
34
|
+
|
|
35
|
+
## [0.2.0] - 2026-11-14
|
|
13
36
|
|
|
14
37
|
- `HainivaAgent`: one object for a staff member's app — `connect()`, `me`,
|
|
15
38
|
`setStatus`, `rings.pending`, `calls.accept/decline/end/hold/mute/transfer`,
|
|
@@ -33,7 +56,7 @@ Preview, not yet published.
|
|
|
33
56
|
|
|
34
57
|
## [0.1.0]
|
|
35
58
|
|
|
36
|
-
|
|
59
|
+
Not published to npm; its contents shipped in 0.2.0.
|
|
37
60
|
|
|
38
61
|
- `SessionManager`: sign-in with a fresh assertion from your backend every
|
|
39
62
|
time, pluggable `SessionStore` (memory by default), single-flight refresh
|
|
@@ -49,6 +72,8 @@ Preview, not yet published.
|
|
|
49
72
|
- `/react-native`: `secureStoreSessionStore`, `joinCallRoom` (LiveKit).
|
|
50
73
|
- `/embed`: placeholder, throws.
|
|
51
74
|
|
|
52
|
-
[Unreleased]: https://github.com/hainiva/agent-sdk/compare/v0.
|
|
75
|
+
[Unreleased]: https://github.com/hainiva/agent-sdk/compare/v0.3.1...HEAD
|
|
76
|
+
[0.3.1]: https://github.com/hainiva/agent-sdk/compare/v0.3.0...v0.3.1
|
|
77
|
+
[0.3.0]: https://github.com/hainiva/agent-sdk/compare/v0.2.0...v0.3.0
|
|
53
78
|
[0.2.0]: https://github.com/hainiva/agent-sdk/compare/v0.1.0...v0.2.0
|
|
54
79
|
[0.1.0]: https://github.com/hainiva/agent-sdk/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @hainiva/agent-sdk
|
|
2
2
|
|
|
3
|
-
Build **your own staff app** on [HAINIVA
|
|
3
|
+
Build **your own staff app** on [HAINIVA](https://hainiva.com): your staff
|
|
4
4
|
sign in with an assertion from your backend, receive their rings and
|
|
5
5
|
conversations over one reconnecting socket, and take calls with LiveKit — in a
|
|
6
6
|
browser, in React Native, or in Node.
|
|
@@ -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
|
|
15
|
-
| `@hainiva/agent-sdk/embed` | the
|
|
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`
|
|
92
|
-
|
|
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}` (
|
|
158
|
-
|
|
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,82 @@ 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
|
+
|
|
262
|
+
## Documentation
|
|
263
|
+
|
|
264
|
+
- [HAINIVA developer docs](https://hainiva.com/en/developers/docs) — start with
|
|
265
|
+
[Quickstart: your own staff app](https://hainiva.com/en/developers/docs/quickstart-staff-app), then
|
|
266
|
+
[Staff sign-in and agent apps](https://hainiva.com/en/developers/docs/staff-sign-in),
|
|
267
|
+
[Agent API](https://hainiva.com/en/developers/docs/agent-api), [Events](https://hainiva.com/en/developers/docs/events),
|
|
268
|
+
[Push setup](https://hainiva.com/en/developers/docs/push-setup) and [Embedded desk](https://hainiva.com/en/developers/docs/embedded-desk).
|
|
269
|
+
- [API reference](https://api.hainiva.com/docs/) — every endpoint, request and
|
|
270
|
+
error code.
|
|
271
|
+
- [Troubleshooting](https://hainiva.com/en/developers/docs/troubleshooting).
|
|
272
|
+
- Sibling SDKs: [`@hainiva/react-native-call`](https://github.com/hainiva/react-native-call)
|
|
273
|
+
(native ringing) and [`@hainiva/app-sdk`](https://github.com/hainiva/app-sdk)
|
|
274
|
+
(your customers' app, the App channel).
|
|
275
|
+
|
|
185
276
|
## Security
|
|
186
277
|
|
|
187
278
|
- **No tokens in URLs or logs.** Tokens travel in the `Authorization` header or
|
package/dist/cjs/agent.cjs
CHANGED
|
@@ -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(),
|
package/dist/cjs/agent.d.cts
CHANGED
|
@@ -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[]>;
|
package/dist/cjs/contract.d.cts
CHANGED
|
@@ -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;
|
package/dist/cjs/embed.cjs
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
}
|