@hainiva/agent-sdk 0.0.0-stage → 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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +21 -0
- package/README.md +278 -2
- package/SECURITY.md +33 -0
- package/dist/cjs/agent.cjs +320 -0
- package/dist/cjs/agent.d.cts +239 -0
- package/dist/cjs/calls.cjs +277 -0
- package/dist/cjs/calls.d.cts +232 -0
- package/dist/cjs/contract.cjs +14 -0
- package/dist/cjs/contract.d.cts +225 -0
- package/dist/cjs/embed.cjs +253 -0
- package/dist/cjs/embed.d.cts +187 -0
- package/dist/cjs/emitter.cjs +50 -0
- package/dist/cjs/emitter.d.cts +22 -0
- package/dist/cjs/errors.cjs +28 -0
- package/dist/cjs/errors.d.cts +15 -0
- package/dist/cjs/events.cjs +97 -0
- package/dist/cjs/events.d.cts +97 -0
- package/dist/cjs/index.cjs +72 -0
- package/dist/cjs/index.d.cts +28 -0
- package/dist/cjs/livekit.cjs +56 -0
- package/dist/cjs/livekit.d.cts +35 -0
- package/dist/cjs/log.cjs +15 -0
- package/dist/cjs/log.d.cts +5 -0
- package/dist/cjs/react-native.cjs +32 -0
- package/dist/cjs/react-native.d.cts +39 -0
- package/dist/cjs/reactNativeDesk.cjs +107 -0
- package/dist/cjs/reactNativeDesk.d.cts +53 -0
- package/dist/cjs/redact.cjs +71 -0
- package/dist/cjs/redact.d.cts +21 -0
- package/dist/cjs/rest.cjs +286 -0
- package/dist/cjs/rest.d.cts +172 -0
- package/dist/cjs/session.cjs +159 -0
- package/dist/cjs/session.d.cts +101 -0
- package/dist/cjs/singleFlight.cjs +49 -0
- package/dist/cjs/singleFlight.d.cts +32 -0
- package/dist/cjs/url.cjs +39 -0
- package/dist/cjs/url.d.cts +13 -0
- package/dist/cjs/web.cjs +28 -0
- package/dist/cjs/web.d.cts +23 -0
- package/dist/cjs/ws.cjs +246 -0
- package/dist/cjs/ws.d.cts +134 -0
- package/dist/esm/agent.d.ts +240 -0
- package/dist/esm/agent.d.ts.map +1 -0
- package/dist/esm/agent.js +317 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/calls.d.ts +233 -0
- package/dist/esm/calls.d.ts.map +1 -0
- package/dist/esm/calls.js +271 -0
- package/dist/esm/calls.js.map +1 -0
- package/dist/esm/contract.d.ts +226 -0
- package/dist/esm/contract.d.ts.map +1 -0
- package/dist/esm/contract.js +12 -0
- package/dist/esm/contract.js.map +1 -0
- package/dist/esm/embed.d.ts +188 -0
- package/dist/esm/embed.d.ts.map +1 -0
- package/dist/esm/embed.js +247 -0
- package/dist/esm/embed.js.map +1 -0
- package/dist/esm/emitter.d.ts +23 -0
- package/dist/esm/emitter.d.ts.map +1 -0
- package/dist/esm/emitter.js +47 -0
- package/dist/esm/emitter.js.map +1 -0
- package/dist/esm/errors.d.ts +16 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +25 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/events.d.ts +98 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +90 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +29 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/livekit.d.ts +36 -0
- package/dist/esm/livekit.d.ts.map +1 -0
- package/dist/esm/livekit.js +54 -0
- package/dist/esm/livekit.js.map +1 -0
- package/dist/esm/log.d.ts +6 -0
- package/dist/esm/log.d.ts.map +1 -0
- package/dist/esm/log.js +13 -0
- package/dist/esm/log.js.map +1 -0
- package/dist/esm/react-native.d.ts +40 -0
- package/dist/esm/react-native.d.ts.map +1 -0
- package/dist/esm/react-native.js +26 -0
- package/dist/esm/react-native.js.map +1 -0
- 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/redact.d.ts +22 -0
- package/dist/esm/redact.d.ts.map +1 -0
- package/dist/esm/redact.js +66 -0
- package/dist/esm/redact.js.map +1 -0
- package/dist/esm/rest.d.ts +173 -0
- package/dist/esm/rest.d.ts.map +1 -0
- package/dist/esm/rest.js +281 -0
- package/dist/esm/rest.js.map +1 -0
- package/dist/esm/session.d.ts +102 -0
- package/dist/esm/session.d.ts.map +1 -0
- package/dist/esm/session.js +155 -0
- package/dist/esm/session.js.map +1 -0
- package/dist/esm/singleFlight.d.ts +33 -0
- package/dist/esm/singleFlight.d.ts.map +1 -0
- package/dist/esm/singleFlight.js +46 -0
- package/dist/esm/singleFlight.js.map +1 -0
- package/dist/esm/url.d.ts +14 -0
- package/dist/esm/url.d.ts.map +1 -0
- package/dist/esm/url.js +37 -0
- package/dist/esm/url.js.map +1 -0
- package/dist/esm/web.d.ts +24 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +23 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/esm/ws.d.ts +135 -0
- package/dist/esm/ws.d.ts.map +1 -0
- package/dist/esm/ws.js +242 -0
- package/dist/esm/ws.js.map +1 -0
- package/package.json +117 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@hainiva/agent-sdk` are listed here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/). While the version is `0.x`, a minor
|
|
6
|
+
release (`0.2.0`) may change the API; a patch release (`0.1.1`) never does.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.3.0] - 2026-11-14
|
|
11
|
+
|
|
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
|
|
31
|
+
|
|
32
|
+
- `HainivaAgent`: one object for a staff member's app — `connect()`, `me`,
|
|
33
|
+
`setStatus`, `rings.pending`, `calls.accept/decline/end/hold/mute/transfer`,
|
|
34
|
+
`joinMedia` (through the `/web` or `/react-native` LiveKit adapter),
|
|
35
|
+
`conversations.*`, `messages.*`, `wrapUp`, `dispositions`, `supervisor.*`,
|
|
36
|
+
`push.register/unregister`, `on(event)`, `disconnect({ signOut })`.
|
|
37
|
+
- `AgentRestClient`: every `/agent/v1` endpoint, typed with the Agent API
|
|
38
|
+
contract (`AgentMeDto`, `AgentAcceptDto`, …, exported). Errors are
|
|
39
|
+
`HainivaAgentError` from the `{error:{code,message}}` envelope. A 401
|
|
40
|
+
`token_expired` refreshes once (single flight) and retries; a refused refresh
|
|
41
|
+
signs in again with a new assertion; `session_revoked` is final. Every POST
|
|
42
|
+
carries an `Idempotency-Key` (reused by the retry). Timeouts and
|
|
43
|
+
`AbortSignal`. Ids are checked before they go into a path.
|
|
44
|
+
- `/agent/v1/ws` bindings (`agentSocketBindings`): auth first frame with the
|
|
45
|
+
resume position, `token.refresh`, `session.expiring` (refresh at once),
|
|
46
|
+
`resync_required` (rings refetched, `resync` event), close codes 4001 / 4003
|
|
47
|
+
/ 4009. `call.*` events drive the `CallStore`.
|
|
48
|
+
- `SessionManager.invalidate(refusedToken)` ignores a refusal of an older token;
|
|
49
|
+
`SessionManager.clear()` forgets a session the server already ended.
|
|
50
|
+
- `ReconnectingSocket` `closeCodes.fatal`: close codes that stop reconnecting.
|
|
51
|
+
|
|
52
|
+
## [0.1.0]
|
|
53
|
+
|
|
54
|
+
Not published to npm; its contents shipped in 0.2.0.
|
|
55
|
+
|
|
56
|
+
- `SessionManager`: sign-in with a fresh assertion from your backend every
|
|
57
|
+
time, pluggable `SessionStore` (memory by default), single-flight refresh
|
|
58
|
+
before expiry, new sign-in when a refresh is refused.
|
|
59
|
+
- `ReconnectingSocket`: first-frame auth, exponential backoff with jitter,
|
|
60
|
+
heartbeat with dead-connection timeout, `resume {lastSeq}` hook, duplicate
|
|
61
|
+
frame suppression, revoked / token-expired close codes, pluggable frames.
|
|
62
|
+
- `CallStore` / `transition`: the agent call state machine (ringing,
|
|
63
|
+
connecting, active, held, transferring, ended; concurrent rings; ring expiry).
|
|
64
|
+
- `TypedEmitter`, `singleFlight`, `createTokenRefresher`, `assertSafeUrl`,
|
|
65
|
+
`Redactor`, `HainivaAgentError` (redacted).
|
|
66
|
+
- `/web`: `localStorageStore` (opt-in), `joinCallRoom` (LiveKit).
|
|
67
|
+
- `/react-native`: `secureStoreSessionStore`, `joinCallRoom` (LiveKit).
|
|
68
|
+
- `/embed`: placeholder, throws.
|
|
69
|
+
|
|
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
|
|
72
|
+
[0.2.0]: https://github.com/hainiva/agent-sdk/compare/v0.1.0...v0.2.0
|
|
73
|
+
[0.1.0]: https://github.com/hainiva/agent-sdk/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ePacific Telecom (HAINIVA)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,279 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hainiva/agent-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build **your own staff app** on [HAINIVA AICC](https://hainiva.com): your staff
|
|
4
|
+
sign in with an assertion from your backend, receive their rings and
|
|
5
|
+
conversations over one reconnecting socket, and take calls with LiveKit — in a
|
|
6
|
+
browser, in React Native, or in Node.
|
|
7
|
+
|
|
8
|
+
> **Status: 0.x preview.** The API may change in a minor release until 1.0.
|
|
9
|
+
|
|
10
|
+
| Import | What | Needs |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| `@hainiva/agent-sdk` | `HainivaAgent`, the REST client, the socket, the call state machine, redaction | nothing (no LiveKit, DOM or React Native) |
|
|
13
|
+
| `@hainiva/agent-sdk/web` | `joinCallRoom` (LiveKit), `localStorageStore` (opt-in) | `livekit-client` |
|
|
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
|
+
|
|
17
|
+
Native ringing on phones (CallKit / Android call notification) is
|
|
18
|
+
[`@hainiva/react-native-call`](https://github.com/hainiva/react-native-call).
|
|
19
|
+
Samples: [`examples/`](./examples) — a Node assertion signer, an Expo staff
|
|
20
|
+
app and a web softphone.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @hainiva/agent-sdk livekit-client
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { HainivaAgent } from "@hainiva/agent-sdk";
|
|
32
|
+
import { joinCallRoom } from "@hainiva/agent-sdk/web";
|
|
33
|
+
|
|
34
|
+
const agent = new HainivaAgent({
|
|
35
|
+
baseUrl: "https://api.hainiva.com",
|
|
36
|
+
tenantId: "YOUR_ORGANISATION_ID",
|
|
37
|
+
// Ask YOUR backend for a fresh assertion — every time (see below).
|
|
38
|
+
getAssertion: () => fetch("/my-backend/hainiva-assertion", { method: "POST", credentials: "include" })
|
|
39
|
+
.then((r) => r.json()).then((j) => j.assertion),
|
|
40
|
+
media: joinCallRoom,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
agent.on("call.ringing", (r) => showRing(r.callId, r.contactName ?? r.callerNumber));
|
|
44
|
+
agent.on("calls", (state) => render(state)); // the call state machine
|
|
45
|
+
agent.on("wrapup.required", (w) => askForWrapUp(w.callId));
|
|
46
|
+
agent.on("revoked", () => showSignIn()); // HAINIVA ended the session
|
|
47
|
+
|
|
48
|
+
const me = await agent.connect(); // sign in, me, socket, rings waiting
|
|
49
|
+
await agent.setStatus("online");
|
|
50
|
+
|
|
51
|
+
await agent.calls.accept(callId); // answers and joins the room
|
|
52
|
+
await agent.calls.hold(callId, true);
|
|
53
|
+
await agent.calls.transfer(callId, { kind: "queue", queueId, name: "Support" });
|
|
54
|
+
await agent.calls.end(callId);
|
|
55
|
+
await agent.wrapUp(callId, { dispositionCode: "resolved", notes: "…" });
|
|
56
|
+
|
|
57
|
+
await agent.disconnect({ signOut: true });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Everything else is on the same object: `conversations.list/get/assign/release/
|
|
61
|
+
transfer/close`, `messages.list/send/typing`, `dispositions()`,
|
|
62
|
+
`supervisor.liveCalls/agents/supervise/setMode`, `push.register/unregister`,
|
|
63
|
+
`rings.pending()`, `joinMedia(callId)` (re-join with a fresh LiveKit token).
|
|
64
|
+
`agent.rest` is the typed REST client for every `/agent/v1` endpoint, and the
|
|
65
|
+
response types (`AgentMeDto`, `AgentConversationDto`, …) are exported.
|
|
66
|
+
|
|
67
|
+
## Sign-in: your backend vouches for your staff member
|
|
68
|
+
|
|
69
|
+
Your backend holds the agent app's **private key** (HAINIVA stores only the
|
|
70
|
+
public key, or reads your `jwksUrl`). After it has authenticated the staff
|
|
71
|
+
member with **your own** login, it signs a short assertion:
|
|
72
|
+
|
|
73
|
+
| | |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| header | `alg: "ES256"` (or `RS256`), `kid`: the key id you registered. `HS256` works only for a test app on a sandbox organisation. |
|
|
76
|
+
| `iss` | your agent app id |
|
|
77
|
+
| `sub` | the member's `externalId` (`PUT /v1/members/by-external-id/:externalId`) |
|
|
78
|
+
| `aud` | `hainiva-agent` |
|
|
79
|
+
| `jti` | unique: each assertion works once |
|
|
80
|
+
| `iat` / `exp` | now / at most 5 minutes ahead |
|
|
81
|
+
|
|
82
|
+
Roles never come from the assertion — HAINIVA uses the role set on the member.
|
|
83
|
+
The SDK calls `getAssertion` every time it needs a new session and never caches
|
|
84
|
+
or stores the assertion. See [`examples/node-signer`](./examples/node-signer)
|
|
85
|
+
for key generation and a complete signer.
|
|
86
|
+
|
|
87
|
+
**Sessions.** An access token lasts 15 minutes and is refreshed before it
|
|
88
|
+
expires (one refresh however many requests are waiting; the refresh token
|
|
89
|
+
rotates). A request answered `401 token_expired` refreshes and is retried once.
|
|
90
|
+
A refused refresh (expired, revoked, reused) signs in again with a new
|
|
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.
|
|
102
|
+
|
|
103
|
+
## Events
|
|
104
|
+
|
|
105
|
+
`agent.on(name, fn)` — from the event socket (only this member's events,
|
|
106
|
+
filtered by HAINIVA):
|
|
107
|
+
|
|
108
|
+
| Event | Data |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `call.ringing` | `{callId, conversationId, path: direct\|queue\|transfer, channel, line, contactName, callerNumber, transferMode?, expiresAt}` |
|
|
111
|
+
| `call.ended` | `{callId \| null, conversationId, reason: ended\|failed\|answered_elsewhere\|declined\|expired\|transfer_ended}` |
|
|
112
|
+
| `conversation.assigned` | `{conversationId, callId, channel, contactName}` |
|
|
113
|
+
| `message.created` | `{conversationId, interactionId, channel, preview, contentType, senderName}` |
|
|
114
|
+
| `wrapup.required` | `{callId, conversationId, deadlineAt}` |
|
|
115
|
+
| `presence.changed` | `{callId, participantId, role, presence}` |
|
|
116
|
+
| `supervisor.alert` | the alert (supervisors) |
|
|
117
|
+
| `event.unknown` | an event name newer than this SDK |
|
|
118
|
+
|
|
119
|
+
And from the SDK itself: `connection` (socket state), `ready`, `calls` (the
|
|
120
|
+
call state), `callEffect` (stop a native ring, leave media — media is handled
|
|
121
|
+
for you), `resync` (events were missed beyond what HAINIVA keeps: rings are
|
|
122
|
+
refetched, refetch your own lists), `session.expiring`, `revoked`, `error`.
|
|
123
|
+
|
|
124
|
+
The socket authenticates in its **first frame** (the token is never in the
|
|
125
|
+
URL), resumes from the last event after a reconnect, sends a refreshed token
|
|
126
|
+
without reconnecting, and reconnects with backoff — except when HAINIVA revoked
|
|
127
|
+
the session or the member has too many sockets open.
|
|
128
|
+
|
|
129
|
+
## Calls
|
|
130
|
+
|
|
131
|
+
`agent.callsState` / the `calls` event is the HAINIVA Agent app's call state
|
|
132
|
+
machine: one call at a time, concurrent rings, ring expiry, decline, taken
|
|
133
|
+
elsewhere, caller hanging up while you answer, hold, mute, warm and blind
|
|
134
|
+
transfer. `CallStore` and the pure `transition(state, event)` are exported for
|
|
135
|
+
Redux, Zustand or tests.
|
|
136
|
+
|
|
137
|
+
## Push: rings with YOUR app's own credentials
|
|
138
|
+
|
|
139
|
+
Phones ring through **your** app with **your** Firebase / APNs credentials.
|
|
140
|
+
|
|
141
|
+
1. **Upload them** on the agent app — Organization → Developer → Agent apps →
|
|
142
|
+
the bell, or `PUT /v1/agent-apps/:id/push-credentials` (key scope
|
|
143
|
+
`agent_apps:write`):
|
|
144
|
+
```jsonc
|
|
145
|
+
{
|
|
146
|
+
"fcm": { "serviceAccount": { /* the service-account key file */ }, "expiresAt": "2027-11-01T00:00:00Z" },
|
|
147
|
+
"apns": { "keyP8": "<contents of AuthKey_XXXXXXXXXX.p8>", "keyId": "XXXXXXXXXX", "teamId": "YYYYYYYYYY",
|
|
148
|
+
"topic": "com.example.agentapp", "environment": "production" }
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
HAINIVA checks each with Google / Apple before saving (a refusal names the
|
|
152
|
+
problem: `fcm_auth_failed`, `apns_auth_failed`, `apns_topic_not_allowed`, …),
|
|
153
|
+
stores it encrypted and never returns it — only a fingerprint, the status
|
|
154
|
+
and the expiry you declare (your admins are alerted 14 days before, and at
|
|
155
|
+
once if Google or Apple start refusing it). The APNs `topic` must be one of
|
|
156
|
+
the agent app's `allowedBundleIds`. `null` removes a platform.
|
|
157
|
+
2. **Register each phone** after sign-in:
|
|
158
|
+
```ts
|
|
159
|
+
await agent.push.register({ platform: "android", token: fcmToken, kind: "voip", bundleId: "com.example.agentapp", deviceId });
|
|
160
|
+
await agent.push.register({ platform: "ios", token: pushKitToken, kind: "voip", bundleId: "com.example.agentapp", deviceId });
|
|
161
|
+
```
|
|
162
|
+
A phone stops ringing when its sign-in ends; call `push.unregister(token)`
|
|
163
|
+
before signing out anyway.
|
|
164
|
+
3. **Show the ring** with `@hainiva/react-native-call` — Android: the FCM data
|
|
165
|
+
message `{type: "call", tenantId, interactionId, conversationId, path,
|
|
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
|
|
170
|
+
[`examples/expo-agent-app`](./examples/expo-agent-app).
|
|
171
|
+
|
|
172
|
+
## Web
|
|
173
|
+
|
|
174
|
+
Add your page's exact origin to the agent app's `allowedOrigins`. HAINIVA
|
|
175
|
+
answers the browser's CORS preflight only for origins an agent app lists, and
|
|
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.
|
|
181
|
+
|
|
182
|
+
The session is kept **in memory** by default: a reload signs in again with a
|
|
183
|
+
fresh assertion. `localStorageStore()` keeps it across reloads, but **any
|
|
184
|
+
script on your origin can read localStorage, refresh token included** — use it
|
|
185
|
+
only with a strict Content-Security-Policy and no third-party scripts.
|
|
186
|
+
|
|
187
|
+
## React Native
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import * as SecureStore from "expo-secure-store";
|
|
191
|
+
import { joinCallRoom, secureStoreSessionStore } from "@hainiva/agent-sdk/react-native";
|
|
192
|
+
const agent = new HainivaAgent({ …, store: secureStoreSessionStore(SecureStore), media: joinCallRoom });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The session lives in the iOS Keychain (readable after first unlock, so a ring
|
|
196
|
+
that wakes a locked phone finds it; never migrated to another device) or the
|
|
197
|
+
Android Keystore. There is no fallback to a plain store. Call
|
|
198
|
+
`registerGlobals()` from `@livekit/react-native` once at startup.
|
|
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
|
+
## Security
|
|
263
|
+
|
|
264
|
+
- **No tokens in URLs or logs.** Tokens travel in the `Authorization` header or
|
|
265
|
+
the socket's first frame; configured URLs are checked (TLS only, no
|
|
266
|
+
`user:pass@`, no credential-like query parameter). Errors and the `log`
|
|
267
|
+
callback are redacted (JWTs, Bearer values, token parameters, HAINIVA key
|
|
268
|
+
prefixes and the exact tokens the SDK holds). The SDK never writes to the
|
|
269
|
+
console.
|
|
270
|
+
- **Never put the private key in an app.** Only your backend signs assertions.
|
|
271
|
+
Rotate keys with `POST /v1/agent-apps/:id/rotate-credential` (the old key
|
|
272
|
+
keeps working 24 h; `immediate: true` when it leaked) and push credentials by
|
|
273
|
+
uploading new ones.
|
|
274
|
+
- Every POST carries an `Idempotency-Key`, so a retry never acts twice.
|
|
275
|
+
- See [SECURITY.md](./SECURITY.md).
|
|
276
|
+
|
|
277
|
+
## License
|
|
278
|
+
|
|
279
|
+
[MIT](./LICENSE)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please **do not open a public issue**. Email **security@epacific.com.vn** with a
|
|
6
|
+
description, the affected version and steps to reproduce. We acknowledge
|
|
7
|
+
reports within 2 business days and keep you informed until a fix ships.
|
|
8
|
+
|
|
9
|
+
## Supported versions
|
|
10
|
+
|
|
11
|
+
The latest minor release receives security fixes.
|
|
12
|
+
|
|
13
|
+
## Using the SDK safely
|
|
14
|
+
|
|
15
|
+
- **Never put the agent app's private key (or a test secret) in the app.**
|
|
16
|
+
Only your backend signs staff assertions, after it has authenticated the
|
|
17
|
+
staff member itself. Keep assertions short-lived (≤ 5 minutes) with a new
|
|
18
|
+
`jti` each time; live apps use ES256/RS256.
|
|
19
|
+
- `getAssertion` must fetch a new assertion every time — do not cache one.
|
|
20
|
+
- Web: keep the default in-memory session store unless you need reloads to stay
|
|
21
|
+
signed in; `localStorageStore` exposes the refresh token to any script on
|
|
22
|
+
your origin.
|
|
23
|
+
- React Native: use `secureStoreSessionStore`; never AsyncStorage for tokens.
|
|
24
|
+
- Push credentials (Firebase service account, APNs .p8) are uploaded to
|
|
25
|
+
HAINIVA once, by your admin or your backend — never shipped in the app, never
|
|
26
|
+
committed. HAINIVA stores them encrypted and shows only fingerprints; upload
|
|
27
|
+
new ones to rotate, and remove them (`null`) if they leak.
|
|
28
|
+
- Unregister a phone's push token before signing out on it.
|
|
29
|
+
- Do not log raw socket frames or API bodies in production: they carry
|
|
30
|
+
customer data. The SDK's own `log` callback is already redacted.
|
|
31
|
+
- Every release on npm is published from GitHub Actions with
|
|
32
|
+
[provenance](https://docs.npmjs.com/generating-provenance-statements); check it
|
|
33
|
+
with `npm audit signatures`.
|