@inferenc/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 +32 -0
- package/LICENSE +21 -0
- package/README.md +189 -2
- package/docs/PUBLISHING.md +97 -0
- package/docs/REEPL.md +152 -0
- package/docs/SERVER.md +81 -0
- package/examples/node/sign-identity.mjs +19 -0
- package/examples/react/SupportChat.tsx +31 -0
- package/package.json +50 -4
- package/src/attributes.js +42 -0
- package/src/browser.d.ts +93 -0
- package/src/browser.js +173 -0
- package/src/events.js +19 -0
- package/src/server.d.ts +39 -0
- package/src/server.js +61 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — first npm release
|
|
4
|
+
|
|
5
|
+
- `update({ name, email, phone, attributes, company, page })` pushes contact data from the browser (debounced 500 ms, coalesced, queued for anonymous visitors until identified).
|
|
6
|
+
- `upsertContact()` and `upsertCompany()` in `@inferenc/sdk/server`.
|
|
7
|
+
- New `POST /v1/public/chat/contacts` route (signed `Widget` token). Unsigned name/email/phone are stored as `unverified_*`; attributes merge into `custom_attributes`; company is stored on the contact as `company_profile`.
|
|
8
|
+
- Inbox contact panel and contact detail show attributes, company, unverified fields and the last seen page.
|
|
9
|
+
- `showNewMessage(prefill?)`, `showMessages()`, `onShow()`, `onHide()` and `onUnreadCountChange()`; `getState()` includes `unreadCount`. The launcher shows an unread badge.
|
|
10
|
+
- The widget opens on a Messenger-style home with the conversation list for identified users.
|
|
11
|
+
- Requires the matching API and hosted widget deployment.
|
|
12
|
+
|
|
13
|
+
## 0.2.0
|
|
14
|
+
|
|
15
|
+
- `hideDefaultLauncher` init option hides the launcher bubble from first paint; open the panel with `show()`.
|
|
16
|
+
- `update({ hideDefaultLauncher })` changes widget behaviour at runtime (for example while a cookie banner is visible).
|
|
17
|
+
- `widgetOrigin` and `apiOrigin` init options for staging and local development (https, or http for localhost only).
|
|
18
|
+
- `normalizeEventName()` exported from `@inferenc/sdk` and `@inferenc/sdk/server` to map legacy free-form event names to valid snake_case names.
|
|
19
|
+
- `getState()` now includes `launcherVisible`.
|
|
20
|
+
- Requires the matching hosted widget; `update()` rejects with a clear error against an older runtime.
|
|
21
|
+
|
|
22
|
+
## 0.1.0 — release candidate (never published)
|
|
23
|
+
|
|
24
|
+
- Browser initialization, automatic widget loading, and identity lifecycle methods.
|
|
25
|
+
- Cancellation protection for logout during initialization and account switching.
|
|
26
|
+
- Node-only signing helper compatible with the deployed Inferenc identity API.
|
|
27
|
+
- TypeScript declarations, React/Node examples, and integration/publishing guides.
|
|
28
|
+
- Zero runtime dependencies; ESM package with separate browser and server exports.
|
|
29
|
+
|
|
30
|
+
- Verified identify handshake automatically creates a contact by workspace and user ID.
|
|
31
|
+
- Browser and Node event tracking with bounded metadata, stable retry IDs, and logout cancellation.
|
|
32
|
+
- Updated Reepl migration examples for frontend and server events.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Inferenc
|
|
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,190 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Inferenc SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Support chat for your website and app. Identify signed-in users automatically, let your team reply, and restore conversations when users return—without asking visitors to fill in their name or email.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @inferenc/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
ES modules; Node.js 20+ for the server helper. Modern browsers and bundlers for the browser entry. TypeScript declarations are included. There are no runtime dependencies and no consumer build/install scripts.
|
|
12
|
+
|
|
13
|
+
## Browser: anonymous visitors
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import inferenc from '@inferenc/sdk';
|
|
17
|
+
|
|
18
|
+
await inferenc.init({ embedHash: 'YOUR_WIDGET_EMBED_HASH' });
|
|
19
|
+
await inferenc.show();
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Get an embed hash from your Inferenc widget configuration and allow your website's domain. Use one widget per page. Remove any existing `widget-loader.js` script before switching to the package; the SDK loads it for you.
|
|
23
|
+
|
|
24
|
+
## Browser: signed-in users
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import inferenc from '@inferenc/sdk';
|
|
28
|
+
|
|
29
|
+
await inferenc.init({
|
|
30
|
+
embedHash: 'YOUR_WIDGET_EMBED_HASH',
|
|
31
|
+
getIdentityToken: async () => {
|
|
32
|
+
const response = await fetch('/api/support/identity', {
|
|
33
|
+
credentials: 'same-origin',
|
|
34
|
+
cache: 'no-store',
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) throw new Error('Unable to authenticate support chat');
|
|
37
|
+
return (await response.json()).token;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Your Help / Contact us action:
|
|
42
|
+
await inferenc.show();
|
|
43
|
+
|
|
44
|
+
// Before clearing the app's authenticated session:
|
|
45
|
+
inferenc.shutdown();
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The callback is invoked again before token expiry. It must fetch a fresh token from your authenticated server; do not repeatedly return a cached token. A one-off `identityToken` is also supported, but you must replace it before expiry. Do not supply both options.
|
|
49
|
+
|
|
50
|
+
On login/account switch, call `await inferenc.identify({ getIdentityToken })`. On normal route changes, keep the widget running. On logout, call `shutdown()` synchronously before clearing auth or rendering another user.
|
|
51
|
+
|
|
52
|
+
## Server: sign identity from your authenticated session
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { signWidgetIdentity } from '@inferenc/sdk/server';
|
|
56
|
+
|
|
57
|
+
// Inside your existing authenticated backend handler:
|
|
58
|
+
const token = signWidgetIdentity({
|
|
59
|
+
secret: process.env.INFERENC_IDENTITY_SECRET,
|
|
60
|
+
embedHash: process.env.INFERENC_EMBED_HASH,
|
|
61
|
+
userId: authenticatedUser.id,
|
|
62
|
+
name: authenticatedUser.name || undefined,
|
|
63
|
+
email: authenticatedUser.email || undefined,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Return { token } with Cache-Control: no-store.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The signing key must be provisioned for this specific widget by your Inferenc administrator. It is different from the public embed hash and is never included in the browser. Name/email are optional; the immutable `userId` links history across sessions and devices. Never take the signed user ID from an untrusted request body/query parameter. See [server integration](./docs/SERVER.md).
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
| Method | Behavior |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `init({ embedHash, ...identity })` | Loads the hosted runtime once and boots the widget. Returns a promise. |
|
|
76
|
+
| `boot(identity?)` | Reboots an initialized widget; anonymous boot preserves an existing anonymous session. |
|
|
77
|
+
| `identify(identity)` | Changes the signed-in user or identity provider. |
|
|
78
|
+
| `trackEvent(name, metadata?, options?)` | Records an event for the verified user; returns an event/contact receipt. |
|
|
79
|
+
| `shutdown()` | Clears the active identity and local session synchronously; returns to anonymous state. |
|
|
80
|
+
| `show()` | Opens the panel; await initialization first. |
|
|
81
|
+
| `hide()` | Closes the panel; does not remove the launcher or stop the runtime. |
|
|
82
|
+
| `update({ hideDefaultLauncher, name, email, phone, attributes, company, page })` | Changes widget behaviour at runtime and pushes contact data; applied once the runtime is ready. See [Contact data](#contact-data-attributes-and-companies). |
|
|
83
|
+
| `showNewMessage(prefill?)` | Opens the panel on a new conversation, optionally prefilling the composer (string, up to 2000 characters). |
|
|
84
|
+
| `showMessages()` | Opens the panel on the conversation list (identified users). |
|
|
85
|
+
| `onShow(cb)` / `onHide(cb)` | Called when the panel opens or closes. Return an unsubscribe function. DOM events `inferenc:show` and `inferenc:hide` are also dispatched. |
|
|
86
|
+
| `onUnreadCountChange(cb)` | Called with the new unread count when it changes (the launcher also shows a badge). Returns an unsubscribe function. |
|
|
87
|
+
| `normalizeEventName(name)` | Maps free-form names to valid snake_case event names (also exported from `/server`). |
|
|
88
|
+
| `getState()` | Returns `{ status, identified, isOpen, launcherVisible, unreadCount }`. Safe during SSR. |
|
|
89
|
+
|
|
90
|
+
`init`, `boot`, `identify`, `show`, and `hide` return promises. Handle errors, including `AbortError` when an operation is superseded by logout or another identity operation. `loadTimeoutMs` on `init` defaults to 20 seconds. After a script load failure, reload the page to retry; the SDK deliberately avoids injecting duplicate runtimes.
|
|
91
|
+
|
|
92
|
+
`init()` completion means the runtime has booted, not that remote history has finished loading. `identified: true` means the backend verified the token and created or resolved the contact, even when the user has never opened chat and has no name/email. Neither is an authorization signal for your application. Inferenc validates signed identity on its backend for every protected conversation request.
|
|
93
|
+
|
|
94
|
+
For failures during later refreshes:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
window.addEventListener('inferenc:identity-error', () => {
|
|
98
|
+
// Show your app's error UI with a sign-in/reload action.
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Do not silently fall back to anonymous chat after a signed-in user's identity fails. Tokens stay in widget memory. Anonymous history is browser-local; verified history is restored from the server. Anonymous sessions are not automatically merged into accounts.
|
|
103
|
+
|
|
104
|
+
## React, Next.js and Reepl
|
|
105
|
+
|
|
106
|
+
- [React lifecycle example](./examples/react/SupportChat.tsx)
|
|
107
|
+
- [Server integration and token contract](./docs/SERVER.md)
|
|
108
|
+
- [Reepl migration from Intercom](./docs/REEPL.md)
|
|
109
|
+
- [Publishing and release handoff](./docs/PUBLISHING.md)
|
|
110
|
+
|
|
111
|
+
Importing the browser entry during SSR is safe; call `init()` only on the client. The `/server` entry is Node-only and blocked for browser-conditioned package resolution. Do not import it into a client component.
|
|
112
|
+
|
|
113
|
+
## Limits
|
|
114
|
+
|
|
115
|
+
This is the JavaScript SDK for Inferenc's existing hosted widget and API. It does not bundle or self-host the messenger UI. The hosted runtime can receive updates independently of the npm version. There is no CommonJS entry, React provider package, native mobile SDK, or Intercom history importer in this release. Current notification emails go to the support team, not visitors. `hide()` does not hide the launcher; use the `hideDefaultLauncher` init option or `update()` for a Help-menu-only setup. `widgetOrigin` and `apiOrigin` init options target staging or local servers.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
npm ci
|
|
121
|
+
npm test
|
|
122
|
+
npm run check
|
|
123
|
+
npm pack
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`npm pack` runs tests and declaration checks, then produces `inferenc-sdk-<version>.tgz`. Only the explicit package allowlist is shipped; tests and development dependencies are excluded.
|
|
127
|
+
|
|
128
|
+
## Contact data, attributes and companies
|
|
129
|
+
|
|
130
|
+
Push contact data from the browser or your server and see it in the inbox contact panel and the contact detail page.
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
// Browser: debounced 500 ms and coalesced. Anonymous visitors queue until identify().
|
|
134
|
+
await inferenc.update({
|
|
135
|
+
phone: '+14155550123',
|
|
136
|
+
attributes: { plan: 'pro', seats: 5, trial: false, legacy_flag: null }, // null deletes a key
|
|
137
|
+
company: { company_id: 'acme', name: 'Acme Inc', attributes: { tier: 'gold' } },
|
|
138
|
+
page: { url: location.href, title: document.title, referrer: document.referrer, utm_source: 'google' },
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
// Server: name and email are covered by your signing secret and stored as verified.
|
|
144
|
+
import { upsertContact, upsertCompany } from '@inferenc/sdk/server';
|
|
145
|
+
await upsertContact({ secret, embedHash, userId, email, name, attributes: { plan: 'pro' } });
|
|
146
|
+
await upsertCompany({ secret, embedHash, userId, company: { company_id: 'acme', name: 'Acme Inc' } });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Rules (enforced identically in the SDK and the API):
|
|
150
|
+
|
|
151
|
+
- `attributes` is a flat map of strings (at most 1000 characters), finite numbers, booleans or `null`; keys match `/^[a-z][a-z0-9_]{0,63}$/`; at most 100 keys per call and 100 per contact. Values merge into the contact; `null` deletes a key.
|
|
152
|
+
- Browser `name`, `email` and `phone` are self-reported and stored as `unverified_*` unless they equal the signed token's claims. Server calls sign `name` and `email`, so those are verified.
|
|
153
|
+
- `company.company_id` is required (at most 128 characters). A new `company_id` replaces the contact's company; the same ID merges attributes. The company is stored on the contact (`company_profile`, with the display name also copied to `company`). A separate companies table is not created yet.
|
|
154
|
+
- `page` records `last_seen_page` and `last_seen_at`; `url` and `referrer` must be http(s).
|
|
155
|
+
- Calls are idempotent. Queued anonymous data is discarded by `shutdown()`.
|
|
156
|
+
- Needs the matching API (`POST /v1/public/chat/contacts`) and hosted widget.
|
|
157
|
+
|
|
158
|
+
## Contact creation and custom events
|
|
159
|
+
|
|
160
|
+
Awaiting signed-in `init()` or `identify()` now waits for `POST /v1/public/chat/identify`. It verifies the token and creates/resolves the user contact immediately; opening chat, sending a message, name and email are unnecessary. Identity failures reject and leave the widget in error state. This requires the matching hosted widget and API deployment.
|
|
161
|
+
|
|
162
|
+
After identification, record browser activity:
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
await inferenc.trackEvent('report.exported', { format: 'pdf', pages: 12 });
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
For business activity in an authenticated backend handler or job:
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
import { trackEvent } from '@inferenc/sdk/server';
|
|
172
|
+
|
|
173
|
+
await trackEvent({
|
|
174
|
+
secret: process.env.INFERENC_IDENTITY_SECRET,
|
|
175
|
+
embedHash: process.env.INFERENC_EMBED_HASH,
|
|
176
|
+
userId: authenticatedUser.id,
|
|
177
|
+
name: 'subscription.activated',
|
|
178
|
+
metadata: { plan: 'pro', seats: 3 },
|
|
179
|
+
eventId: 'subscription_abc_activated', // persist this with the job/outbox
|
|
180
|
+
createdAt: '2026-10-19T10:00:00.000Z', // persist original occurrence time
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The server helper derives a short-lived signed identity and calls the production API; it also creates/resolves the contact when there has been no browser visit. Optional `email` and `userName` are signed profile fields. Derive `userId` from your authenticated session or trusted job record, never a client-selected ID.
|
|
185
|
+
|
|
186
|
+
Both methods return `{ event_id, contact_id, duplicate }`. Event names start with a letter and contain up to 100 letters, digits, underscores, dots or hyphens. Metadata is a flat JSON primitive map (string, finite number, boolean or null), at most 50 keys and 16KB UTF-8. `eventId` allows 1–128 letters, digits, underscores or hyphens. `createdAt` is an optional UTC ISO timestamp, no more than five minutes in the future.
|
|
187
|
+
|
|
188
|
+
Without `eventId`, each call generates a new UUID. Transient network, 429 and 5xx failures retry up to twice with the same payload and ID. For job retries or retries across page reloads, persist and reuse the ID and original payload (including `createdAt` if supplied). The same ID with changed content returns 409; do not retry that conflict as a new ID. Logout/account changes abort in-flight browser requests and prevent retries under another account. An already accepted event cannot be undone by aborting the browser request.
|
|
189
|
+
|
|
190
|
+
Events are contextual customer activity, not authoritative billing proofs: browser and server events currently share the `sdk` source and signed-user authentication. Never send secrets or payment credentials in metadata.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SDK handoff and publishing checklist
|
|
2
|
+
|
|
3
|
+
## Current status
|
|
4
|
+
|
|
5
|
+
`@inferenc/sdk` is published to npm under the `@inferenc` organization, starting with `0.3.0` (`0.1.0` and `0.2.0` were never published). The source lives in the private monorepo at `packages/sdk`; no public GitHub repository exists yet. Later releases follow the npm steps below with the new version number in place of `0.1.0`.
|
|
6
|
+
|
|
7
|
+
The proposed public repository is `inferenc/inferenc-sdk`. The proposed npm scope is `@inferenc`; scope ownership still needs confirmation. GitHub organization access does not grant npm scope access.
|
|
8
|
+
|
|
9
|
+
## Use the package before publication
|
|
10
|
+
|
|
11
|
+
From this directory:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm ci
|
|
15
|
+
npm test
|
|
16
|
+
npm run check
|
|
17
|
+
npm pack
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This produces `inferenc-sdk-0.1.0.tgz`. In a consumer project:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install /absolute/path/to/inferenc-sdk-0.1.0.tgz
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The import paths are already the final ones:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import inferenc from '@inferenc/sdk';
|
|
30
|
+
import { signWidgetIdentity } from '@inferenc/sdk/server'; // server files only
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Only the browser entry belongs in a client bundle. This is a zero-runtime-dependency ESM package, with no preparation/build hook required on consumer install.
|
|
34
|
+
|
|
35
|
+
## Publish source to GitHub, when ready
|
|
36
|
+
|
|
37
|
+
Publish **only this directory**. Do not push the parent product workspace or copy its operational files, environment files, customer IDs, signing-key registry scripts, or credentials into the SDK repository.
|
|
38
|
+
|
|
39
|
+
Review `LICENSE` (MIT), package contents, and examples. Initialize a separate repository and create the public GitHub repo only when authorized to proceed:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
git init -b main
|
|
43
|
+
git add .gitignore .github package.json package-lock.json tsconfig.json src test README.md LICENSE CHANGELOG.md docs examples
|
|
44
|
+
git commit -m "feat: prepare Inferenc browser and server SDK"
|
|
45
|
+
gh repo create inferenc/inferenc-sdk --public --source=. --remote=origin --push
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If that repository already exists, inspect it and connect its remote instead of recreating or overwriting it. There are no automated publish workflows in this package. The included workflow runs tests, type checks and pack checks on Node 20, 22 and 24; it has not run on GitHub yet.
|
|
49
|
+
|
|
50
|
+
After the repo exists, add its metadata:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm pkg set repository.type=git repository.url=git+https://github.com/inferenc/inferenc-sdk.git
|
|
54
|
+
npm pkg set homepage=https://github.com/inferenc/inferenc-sdk#readme
|
|
55
|
+
npm pkg set bugs.url=https://github.com/inferenc/inferenc-sdk/issues
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## First npm publication
|
|
59
|
+
|
|
60
|
+
1. Log in with the intended maintainer account using `npm login`; confirm with `npm whoami`.
|
|
61
|
+
2. Confirm the account can publish under the npm organization `@inferenc`. Create/obtain that scope or agree on another package name before proceeding. A 404 for the package name does not establish ownership of its scope.
|
|
62
|
+
3. Update the release status in README and CHANGELOG when publication is actually occurring. Confirm the license and repository metadata.
|
|
63
|
+
4. Run the checks and inspect the exact tarball:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm ci
|
|
67
|
+
npm test
|
|
68
|
+
npm run check
|
|
69
|
+
npm pack
|
|
70
|
+
# Inspect the allowlisted files; no secrets, private material or node_modules.
|
|
71
|
+
tar -tzf inferenc-sdk-0.1.0.tgz
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
5. Publish the reviewed artifact explicitly as public:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npm publish ./inferenc-sdk-0.1.0.tgz --access public
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Complete npm's login/2FA prompts yourself. Do not put credentials in chat, source files, examples, or shell history. Organization-scoped packages need explicit public access for public distribution: [npm's organization package guide](https://docs.npmjs.com/creating-and-publishing-an-organization-scoped-package/).
|
|
81
|
+
|
|
82
|
+
6. Verify registry availability and installation from a clean project:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
npm view @inferenc/sdk@0.1.0 version dist.integrity
|
|
86
|
+
npm install @inferenc/sdk@0.1.0
|
|
87
|
+
node --input-type=module -e "import sdk from '@inferenc/sdk'; console.log(typeof sdk.init)"
|
|
88
|
+
node --input-type=module -e "import { signWidgetIdentity } from '@inferenc/sdk/server'; console.log(typeof signWidgetIdentity)"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
7. Commit final release metadata, tag `v0.1.0`, and create the GitHub release with the same artifact. Do not claim a registry install works until step 6 succeeds. If publishing fails, keep the documented status as unpublished.
|
|
92
|
+
|
|
93
|
+
npm versions cannot be overwritten. Fix a published package with a new version rather than rebuilding and reusing an existing version. For later automated releases, consider npm trusted publishing with GitHub OIDC after the public repo/package exist; it is not configured here. [Official trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/).
|
|
94
|
+
|
|
95
|
+
## What the SDK does not provision
|
|
96
|
+
|
|
97
|
+
Customers still need an Inferenc widget, an allowed website domain, and a widget-specific server signing key. The package does not create accounts, expose an anonymous identity-signing service, install a shared signing secret, or change notification settings. The main product/backend stays private.
|
package/docs/REEPL.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Reepl: migrate the current Intercom integration
|
|
2
|
+
|
|
3
|
+
This is an implementation guide, not a statement that Reepl has already migrated. Install with `npm install @inferenc/sdk`.
|
|
4
|
+
|
|
5
|
+
## Backend: keep the existing authenticated profile route
|
|
6
|
+
|
|
7
|
+
In `reepl-backend/lambdas/viewOrUpdateProfile/index.mjs`, install the SDK in the Lambda's package and import:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { signWidgetIdentity } from '@inferenc/sdk/server';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The existing handler derives `userID` and `email` from `event.requestContext.authorizer.claims`. Keep that authorization. Inside its successful GET-profile branch, after `filteredResult` is available and before returning the normal profile, add an opt-in identity response:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
if (event.queryStringParameters?.support_identity === '1') {
|
|
17
|
+
if (!process.env.INFERENC_IDENTITY_SECRET || !process.env.INFERENC_EMBED_HASH) {
|
|
18
|
+
const response = generateResponse(503, { message: 'Support chat is unavailable' });
|
|
19
|
+
response.headers = { ...response.headers, 'Cache-Control': 'no-store' };
|
|
20
|
+
return response;
|
|
21
|
+
}
|
|
22
|
+
const token = signWidgetIdentity({
|
|
23
|
+
secret: process.env.INFERENC_IDENTITY_SECRET,
|
|
24
|
+
embedHash: process.env.INFERENC_EMBED_HASH,
|
|
25
|
+
userId: userID,
|
|
26
|
+
name: filteredResult.name || undefined,
|
|
27
|
+
email: email || undefined,
|
|
28
|
+
});
|
|
29
|
+
const response = generateResponse(200, { token, user_id: userID });
|
|
30
|
+
response.headers = {
|
|
31
|
+
...response.headers,
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'Cache-Control': 'no-store, private',
|
|
34
|
+
};
|
|
35
|
+
return response;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use the provisioned widget secret, not the existing Intercom secret. Package the SDK dependency with the Lambda or bundle the helper; declaring the dependency alone is insufficient. Keep normal profile responses unchanged. Disable API Gateway caching for this response. Keep the token out of Reepl's persisted profile query cache by requesting this branch directly.
|
|
40
|
+
|
|
41
|
+
## Frontend: replace the script-loader/provider code with the package
|
|
42
|
+
|
|
43
|
+
The package replaces the custom `loadInferenc()` helper and global `window.Inferenc` types described in earlier integration notes. Do not use both loaders.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import inferenc from '@inferenc/sdk';
|
|
47
|
+
import { apiFetch } from '@/lib/fetcher';
|
|
48
|
+
import useUserStore from '@/store/user-store';
|
|
49
|
+
|
|
50
|
+
export async function connectSupport(expectedUserId: string) {
|
|
51
|
+
await inferenc.init({
|
|
52
|
+
embedHash: process.env.NEXT_PUBLIC_INFERENC_EMBED_HASH!,
|
|
53
|
+
getIdentityToken: async () => {
|
|
54
|
+
if (useUserStore.getState().userId !== expectedUserId) throw new Error('Account changed');
|
|
55
|
+
const result = await apiFetch('/account/profile?support_identity=1', {
|
|
56
|
+
method: 'GET', cache: 'no-store', skipWorkspaceId: true,
|
|
57
|
+
});
|
|
58
|
+
if (useUserStore.getState().userId !== expectedUserId || result.user_id !== expectedUserId) {
|
|
59
|
+
throw new Error('Account changed');
|
|
60
|
+
}
|
|
61
|
+
return result.token;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Mount one provider in the existing `IntercomProvider` position in `src/app/[locale]/layout.tsx`. Connect only when authenticated `userId` is available. Reconnect on account change; call `shutdown()` before clearing or switching the current account. Do not run initialization on every pathname or profile refetch. Keep the provider stable across normal navigation. Handle initial connection errors and the `inferenc:identity-error` event in the existing toast UI. Do not use a stale cached `intercom_jwt` or profile response as the new token.
|
|
68
|
+
|
|
69
|
+
## Help links and logout
|
|
70
|
+
|
|
71
|
+
Replace `window.Intercom('show')` with `void inferenc.show().catch(...)` in:
|
|
72
|
+
|
|
73
|
+
- `components/global/sidebar/nav-help.tsx`
|
|
74
|
+
- `components/global/sidebar/nav-user.tsx`
|
|
75
|
+
- `components/global/page-top-bar.tsx`
|
|
76
|
+
- `components/settings/integrations/integrations.tsx`
|
|
77
|
+
- `components/settings/plans/billing.tsx`
|
|
78
|
+
- `components/settings/plans/cancellation-flow-dialog-global.tsx`
|
|
79
|
+
|
|
80
|
+
In `hooks/auth/use-logout.tsx`, replace Intercom shutdown with `inferenc.shutdown()` **before** clearing auth. Preserve Reepl's existing copilot flush, persisted-cache clearing, desktop logout, and navigation order. Update its tests accordingly.
|
|
81
|
+
|
|
82
|
+
To preserve the current Help-menu-only experience, pass `hideDefaultLauncher: true` to `inferenc.init(...)`. The launcher bubble is hidden from first paint and `inferenc.show()` still opens the panel. `hide()` only closes the panel; it does not hide the launcher or stop network activity.
|
|
83
|
+
|
|
84
|
+
For the cookie consent banner in `components/global/cookie-consent-banner.tsx`, replace the old Intercom launcher calls with `inferenc.update({ hideDefaultLauncher: true })` or `inferenc.update({ hideDefaultLauncher: false })` as the banner appears and is dismissed, without changing consent behavior. `update()` called before the runtime is ready is applied once it loads.
|
|
85
|
+
|
|
86
|
+
## Configuration and scope
|
|
87
|
+
|
|
88
|
+
Set `NEXT_PUBLIC_INFERENC_EMBED_HASH` on the frontend; set the embed hash and signing secret on the backend. Use the existing provisioned Reepl widget rather than creating a duplicate workspace. Get its IDs/secret from the project owner—this public package contains no customer credentials.
|
|
89
|
+
|
|
90
|
+
Add `reepl.app` and `app.reepl.io` to the widget allowlist (the app's configured production hosts). Preserve other intentionally allowed hosts. Add preview domains deliberately. The stable user ID is the Cognito subject, so support history follows the person across Reepl workspace switches; it is not a shared workspace conversation.
|
|
91
|
+
|
|
92
|
+
Migrate frontend and backend Intercom event calls using the event adapter guidance below; replacing the messenger alone does not remove these jobs.
|
|
93
|
+
|
|
94
|
+
## Acceptance
|
|
95
|
+
|
|
96
|
+
Verify no contact form, visitor messages, live team replies, close/reopen, page reload, logout/login, second-browser restoration, isolation between two accounts, token refresh after ten minutes, and late identity-request cancellation during logout. Test Help in billing and cancellation flows, and make sure only one widget loads. Run Reepl's type-check, provider/logout tests, backend auth tests, and required deployment builds.
|
|
97
|
+
|
|
98
|
+
Desktop/webview compatibility needs its own origin and iframe tests. This package does not migrate old Intercom history or add visitor reply emails.
|
|
99
|
+
|
|
100
|
+
## Replace Intercom event tracking
|
|
101
|
+
|
|
102
|
+
Wrap legacy Intercom names with `normalizeEventName()` (exported from `@inferenc/sdk` and `@inferenc/sdk/server`), for example `'Upgrade Button - Click'` becomes `'upgrade_button_click'` and `'credits expired'` becomes `'credits_expired'`; it throws `TypeError` when no usable characters remain. Use `inferenc.trackEvent(name, metadata, { eventId, createdAt })` in `services/tracking/intercom.ts` after the support provider's identified initialization completes. Rename the adapter to Inferenc and update its imports/callers. Keep tracking failures from blocking the underlying user action; report failures to existing diagnostics. Anonymous events are rejected. Keep consent rules intact.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// After awaiting connectSupport(currentUserId):
|
|
106
|
+
await inferenc.trackEvent('report.exported', { report_type: 'summary', format: 'pdf' });
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Migrate backend Intercom event jobs to `trackEvent` from `@inferenc/sdk/server` using the immutable Cognito subject from the job's authenticated ownership record. Keep the signing secret server-only. Use `userName` for an optional profile name, because `name` is the event name. Do not use a workspace ID as the user ID.
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
import { trackEvent } from '@inferenc/sdk/server';
|
|
113
|
+
await trackEvent({
|
|
114
|
+
secret: process.env.INFERENC_IDENTITY_SECRET,
|
|
115
|
+
embedHash: process.env.INFERENC_EMBED_HASH,
|
|
116
|
+
userId: job.cognitoSubject,
|
|
117
|
+
name: 'subscription.activated',
|
|
118
|
+
eventId: job.inferencEventId, // persisted once when enqueuing; same on every retry
|
|
119
|
+
createdAt: job.occurredAt, // immutable UTC ISO timestamp
|
|
120
|
+
metadata: { plan: job.plan, workspace_id: job.workspaceId },
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Persist an ID of 1–128 alphanumeric/underscore/hyphen characters. Names are 1–100 characters, begin with a letter, and use letters/digits/underscore/dot/hyphen. Flatten metadata into at most 50 primitive values and 16KB UTF-8; do not pass entire Intercom payloads. Duplicate identical events return `duplicate: true`; changed content under the same ID returns 409. A fresh automatically generated ID on each job retry would create duplicates. Both browser and server events currently share the `sdk` source and provide customer context, not authoritative proof of payment.
|
|
125
|
+
|
|
126
|
+
Signed-in initialization now creates the contact through the verified identify endpoint before reporting success, without a conversation, name or email. Server event ingestion also creates a contact without any browser visit. Verify contact creation before opening Help; confirm the event appears once on the contact timeline, retry the same job, test invalid signatures, and confirm account switching/logout abort pending event delivery. Deploy matching API/widget changes before relying on these methods.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
### Contact identity and existing records
|
|
130
|
+
|
|
131
|
+
Use the same immutable user ID in browser identity tokens and server event jobs. Contacts are keyed by workspace and user ID, so changing email keeps the contact and two users sharing an email remain separate. Name and email are optional profile fields; do not ask the visitor to re-enter them. The contact persists before identify reports success. Propagate or report initialization failures and retry later rather than pretending connection succeeded.
|
|
132
|
+
|
|
133
|
+
Existing signed conversations are linked to the stable contact when reopened. Old email-based contact records are retained; this release does not merge or delete legacy contacts or import Intercom history. New and restored signed conversations appear under their linked contact.
|
|
134
|
+
|
|
135
|
+
Human inbox replies now reuse one request ID for automatic network retries and the Retry reply action. Reusing an ID with different content is rejected. The saved reply and human handoff are atomic; external notifications remain best effort after persistence. Web visitors receive saved replies through the widget's polling and history.
|
|
136
|
+
|
|
137
|
+
## Map Reepl's Intercom user attributes (0.3.0)
|
|
138
|
+
|
|
139
|
+
Intercom user and company attributes move to `update()` in the browser and `upsertContact()` / `upsertCompany()` on the server. Use the same immutable Cognito subject as `userId`.
|
|
140
|
+
|
|
141
|
+
| Intercom | Inferenc |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `user_id` | The signed token `sub` (`userId`). |
|
|
144
|
+
| `email`, `name` | Token claims (verified) via `signWidgetIdentity` / `upsertContact`; browser values are stored as unverified. |
|
|
145
|
+
| `phone` | `phone` |
|
|
146
|
+
| Custom attributes (`plan`, `credits`, `trial_ends_at`, ...) | `attributes` using snake_case keys (`/^[a-z][a-z0-9_]{0,63}$/`); convert dates to ISO strings or epoch numbers; send `null` to delete. |
|
|
147
|
+
| `company` (`company_id`, `name`, custom attributes) | `company: { company_id, name, attributes }`; Reepl workspace ID is the `company_id`. |
|
|
148
|
+
| `last_request_at`, current page, UTM | `page: { url, title, referrer, utm_* }`. |
|
|
149
|
+
| `Intercom("update", {...})` | `inferenc.update({...})`; call after the provider has identified. Calls made earlier are queued. |
|
|
150
|
+
| Server-side `users.update` | `upsertContact({ secret, embedHash, userId, email, name, attributes, company })`. |
|
|
151
|
+
|
|
152
|
+
Keep the profile Lambda as the authoritative source: send the full attribute set on login and the changed keys afterwards. Attributes are limited to 100 keys per contact and strings to 1000 characters, so drop large blobs and nested objects. Deploy the matching API and hosted widget first; verify in the inbox contact panel that attributes, company and last seen page appear.
|
package/docs/SERVER.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Server integration
|
|
2
|
+
|
|
3
|
+
The package provides the signer; your application provides authentication. Keep your existing Cognito, Auth.js, session-cookie, or other login flow. Create a backend endpoint that authenticates the caller, loads the current user's immutable ID, signs that identity, and returns the token without caching it.
|
|
4
|
+
|
|
5
|
+
## Express-style example
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { signWidgetIdentity } from '@inferenc/sdk/server';
|
|
9
|
+
|
|
10
|
+
// requireSession is YOUR existing authentication middleware, not part of this SDK.
|
|
11
|
+
app.get('/api/support/identity', requireSession, (req, res) => {
|
|
12
|
+
const user = req.user; // set by requireSession after validating the session
|
|
13
|
+
if (!user?.id) return res.status(401).json({ message: 'Sign in required' });
|
|
14
|
+
const token = signWidgetIdentity({
|
|
15
|
+
secret: process.env.INFERENC_IDENTITY_SECRET,
|
|
16
|
+
embedHash: process.env.INFERENC_EMBED_HASH,
|
|
17
|
+
userId: user.id,
|
|
18
|
+
name: user.name || undefined,
|
|
19
|
+
email: user.email || undefined,
|
|
20
|
+
expiresIn: 600,
|
|
21
|
+
});
|
|
22
|
+
res.set('Cache-Control', 'no-store, private').json({ token });
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Express is not a package dependency. The same function works in a Node Lambda or a Next.js route using the Node runtime. The helper does not work in an edge runtime without Node crypto support.
|
|
27
|
+
|
|
28
|
+
## Inputs and signed claims
|
|
29
|
+
|
|
30
|
+
| Input | JWT claim | Constraint |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `secret` | Never included | Server-only widget signing key, at least 32 bytes |
|
|
33
|
+
| `embedHash` | `aud` | Nonempty widget ID, at most 128 characters |
|
|
34
|
+
| `userId` | `sub` | Nonblank immutable app user ID, at most 256 characters |
|
|
35
|
+
| `name`, `email` | Same name | Optional strings, at most 320 characters each |
|
|
36
|
+
| `expiresIn` | Determines `exp` | Integer 60–3600 seconds; default 600 |
|
|
37
|
+
| `now` | `iat` | Integer Unix seconds; omit outside deterministic tests |
|
|
38
|
+
|
|
39
|
+
The header is `{ "alg": "HS256", "typ": "JWT" }`. Inferenc verifies the signature and expiry against the widget's private key registry. That registry also binds the widget to an organization and agent. A token for another user/widget cannot access the conversation.
|
|
40
|
+
|
|
41
|
+
Do not accept a user ID or email from the request and sign it without authenticating the caller. Do not reuse an Intercom signing key. Do not log tokens or place them in URLs, persisted query caches, analytics, client bundles, or browser storage. Use HTTPS in production. Browser identity tokens are short-lived bearer credentials; token expiry is not a substitute for calling the SDK's `shutdown()` when your app logs out.
|
|
42
|
+
|
|
43
|
+
Signed email is profile data your app supplies; Inferenc does not thereby verify mailbox ownership. A name or email is not required to start chat.
|
|
44
|
+
|
|
45
|
+
## Provisioning and rotation
|
|
46
|
+
|
|
47
|
+
Provision the key through your Inferenc administrator for the target widget. Self-service key creation is not exposed by this SDK. Install the same provisioned secret on the application's authenticated server. For rotation/revocation, update the registry and the server secret through the administrator; never distribute a platform-wide secret to customers. Each installation needs its own widget key.
|
|
48
|
+
|
|
49
|
+
## Contact creation and custom events
|
|
50
|
+
|
|
51
|
+
Awaiting signed-in `init()` or `identify()` now waits for `POST /v1/public/chat/identify`. It verifies the token and creates/resolves the user contact immediately; opening chat, sending a message, name and email are unnecessary. Identity failures reject and leave the widget in error state. This requires the matching hosted widget and API deployment.
|
|
52
|
+
|
|
53
|
+
After identification, record browser activity:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
await inferenc.trackEvent('report.exported', { format: 'pdf', pages: 12 });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For business activity in an authenticated backend handler or job:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { trackEvent } from '@inferenc/sdk/server';
|
|
63
|
+
|
|
64
|
+
await trackEvent({
|
|
65
|
+
secret: process.env.INFERENC_IDENTITY_SECRET,
|
|
66
|
+
embedHash: process.env.INFERENC_EMBED_HASH,
|
|
67
|
+
userId: authenticatedUser.id,
|
|
68
|
+
name: 'subscription.activated',
|
|
69
|
+
metadata: { plan: 'pro', seats: 3 },
|
|
70
|
+
eventId: 'subscription_abc_activated', // persist this with the job/outbox
|
|
71
|
+
createdAt: '2026-10-19T10:00:00.000Z', // persist original occurrence time
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The server helper derives a short-lived signed identity and calls the production API; it also creates/resolves the contact when there has been no browser visit. Optional `email` and `userName` are signed profile fields. Derive `userId` from your authenticated session or trusted job record, never a client-selected ID.
|
|
76
|
+
|
|
77
|
+
Both methods return `{ event_id, contact_id, duplicate }`. Event names start with a letter and contain up to 100 letters, digits, underscores, dots or hyphens. Metadata is a flat JSON primitive map (string, finite number, boolean or null), at most 50 keys and 16KB UTF-8. `eventId` allows 1–128 letters, digits, underscores or hyphens. `createdAt` is an optional UTC ISO timestamp, no more than five minutes in the future.
|
|
78
|
+
|
|
79
|
+
Without `eventId`, each call generates a new UUID. Transient network, 429 and 5xx failures retry up to twice with the same payload and ID. For job retries or retries across page reloads, persist and reuse the ID and original payload (including `createdAt` if supplied). The same ID with changed content returns 409; do not retry that conflict as a new ID. Logout/account changes abort in-flight browser requests and prevent retries under another account. An already accepted event cannot be undone by aborting the browser request.
|
|
80
|
+
|
|
81
|
+
Events are contextual customer activity, not authoritative billing proofs: browser and server events currently share the `sdk` source and signed-user authentication. Never send secrets or payment credentials in metadata.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { signWidgetIdentity } from '@inferenc/sdk/server';
|
|
2
|
+
|
|
3
|
+
/** Call only after your server has authenticated the session. */
|
|
4
|
+
export function supportIdentityResponse(authenticatedUser) {
|
|
5
|
+
if (!authenticatedUser?.id) {
|
|
6
|
+
return Response.json({ message: 'Sign in required' }, { status: 401 });
|
|
7
|
+
}
|
|
8
|
+
const token = signWidgetIdentity({
|
|
9
|
+
secret: process.env.INFERENC_IDENTITY_SECRET,
|
|
10
|
+
embedHash: process.env.INFERENC_EMBED_HASH,
|
|
11
|
+
userId: authenticatedUser.id,
|
|
12
|
+
name: authenticatedUser.name || undefined,
|
|
13
|
+
email: authenticatedUser.email || undefined,
|
|
14
|
+
});
|
|
15
|
+
return Response.json(
|
|
16
|
+
{ token, user_id: authenticatedUser.id },
|
|
17
|
+
{ headers: { 'Cache-Control': 'no-store, private' } },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import inferenc from '@inferenc/sdk';
|
|
4
|
+
|
|
5
|
+
// Supply userId from the app's authenticated state, not a text field.
|
|
6
|
+
export function SupportChat({ userId, embedHash }: { userId: string | null; embedHash: string }) {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!userId) { inferenc.shutdown(); return; }
|
|
9
|
+
let active = true;
|
|
10
|
+
void inferenc.init({
|
|
11
|
+
embedHash,
|
|
12
|
+
getIdentityToken: async () => {
|
|
13
|
+
if (!active) throw new Error('Account changed');
|
|
14
|
+
const response = await fetch('/api/support/identity', { credentials: 'same-origin', cache: 'no-store' });
|
|
15
|
+
if (!response.ok) throw new Error('Support identity unavailable');
|
|
16
|
+
const result = await response.json();
|
|
17
|
+
if (!active || result.user_id !== userId) throw new Error('Account changed');
|
|
18
|
+
return result.token;
|
|
19
|
+
},
|
|
20
|
+
}).catch(error => {
|
|
21
|
+
if (active && error.name !== 'AbortError') console.warn('Support chat unavailable');
|
|
22
|
+
});
|
|
23
|
+
// Mount this once in a stable app layout. Explicit logout must call shutdown
|
|
24
|
+
// before clearing auth. Do not tear down the widget on ordinary route changes.
|
|
25
|
+
return () => { active = false; };
|
|
26
|
+
}, [userId, embedHash]);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
// This example expects the server to return { token, user_id }.
|
|
30
|
+
// For Help buttons: await inferenc.show(). For logout: inferenc.shutdown().
|
|
31
|
+
// Also render your app's retry/sign-in UI on inferenc:identity-error.
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inferenc/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Inferenc support chat SDK: browser lifecycle and server-signed user identity.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/browser.d.ts",
|
|
13
|
+
"import": "./src/browser.js",
|
|
14
|
+
"default": "./src/browser.js"
|
|
15
|
+
},
|
|
16
|
+
"./server": {
|
|
17
|
+
"types": "./src/server.d.ts",
|
|
18
|
+
"browser": null,
|
|
19
|
+
"node": "./src/server.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"docs",
|
|
29
|
+
"examples"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test test/*.test.mjs",
|
|
34
|
+
"check": "tsc --noEmit",
|
|
35
|
+
"prepack": "npm test && npm run check"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"inferenc",
|
|
43
|
+
"chat",
|
|
44
|
+
"support",
|
|
45
|
+
"widget",
|
|
46
|
+
"sdk"
|
|
47
|
+
],
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"typescript": "5.9.3",
|
|
50
|
+
"@types/node": "22.19.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Keep validation aligned with the hosted widget (apps/widget/src/script/widget.js mirrors the
|
|
2
|
+
// block between the markers byte for byte, minus "export ") and the public contacts endpoint.
|
|
3
|
+
// BEGIN MIRRORED: attributes
|
|
4
|
+
export function contactPayload(input) {
|
|
5
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value) && (Object.getPrototypeOf(value) === null || Object.getPrototypeOf(Object.getPrototypeOf(value)) === null);
|
|
6
|
+
const attributes = (value, label) => {
|
|
7
|
+
if (!plain(value) || Object.keys(value).length > 100) throw new TypeError(`${label} must be a flat map of at most 100 keys`);
|
|
8
|
+
for (const [key, item] of Object.entries(value)) {
|
|
9
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(key)) throw new TypeError(`Invalid ${label} key: ${key.slice(0, 64)}`);
|
|
10
|
+
if (typeof item === 'string' ? item.length > 1000 : item !== null && typeof item !== 'boolean' && !(typeof item === 'number' && Number.isFinite(item))) throw new TypeError(`${label}.${key} must be a string (max 1000 characters), finite number, boolean or null`);
|
|
11
|
+
}
|
|
12
|
+
return { ...value };
|
|
13
|
+
};
|
|
14
|
+
if (!plain(input)) throw new TypeError('Contact data must be an object');
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const field of ['name', 'email', 'phone']) {
|
|
17
|
+
const value = input[field];
|
|
18
|
+
if (value === undefined) continue;
|
|
19
|
+
const max = field === 'phone' ? 64 : 320;
|
|
20
|
+
if (value !== null && (typeof value !== 'string' || value.length > max || (field === 'email' && value.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())))) throw new TypeError(`Invalid ${field}`);
|
|
21
|
+
out[field] = value;
|
|
22
|
+
}
|
|
23
|
+
if (input.attributes !== undefined) out.attributes = attributes(input.attributes, 'attributes');
|
|
24
|
+
if (input.company !== undefined) {
|
|
25
|
+
const company = input.company;
|
|
26
|
+
if (!plain(company) || typeof company.company_id !== 'string' || !company.company_id.trim() || company.company_id.length > 128) throw new TypeError('company.company_id is required (max 128 characters)');
|
|
27
|
+
if (company.name !== undefined && (typeof company.name !== 'string' || company.name.length > 255)) throw new TypeError('Invalid company.name');
|
|
28
|
+
out.company = { company_id: company.company_id, ...(company.name === undefined ? {} : { name: company.name }), ...(company.attributes === undefined ? {} : { attributes: attributes(company.attributes, 'company.attributes') }) };
|
|
29
|
+
}
|
|
30
|
+
if (input.page !== undefined) {
|
|
31
|
+
if (!plain(input.page)) throw new TypeError('page must be an object');
|
|
32
|
+
const page = {};
|
|
33
|
+
for (const [key, value] of Object.entries(input.page)) {
|
|
34
|
+
if (!['url', 'title', 'referrer'].includes(key) && !/^utm_[a-z]{1,32}$/.test(key)) throw new TypeError(`Unknown page field: ${key.slice(0, 40)}`);
|
|
35
|
+
if (typeof value !== 'string' || value.length > (key === 'title' ? 500 : 2000) || ((key === 'url' || key === 'referrer') && value && !/^https?:\/\//i.test(value))) throw new TypeError(`Invalid page.${key}`);
|
|
36
|
+
page[key] = value;
|
|
37
|
+
}
|
|
38
|
+
out.page = page;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
// END MIRRORED: attributes
|
package/src/browser.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export type IdentityOptions =
|
|
2
|
+
| { identityToken: string; getIdentityToken?: never }
|
|
3
|
+
| { getIdentityToken: () => Promise<string>; identityToken?: never };
|
|
4
|
+
export type BootOptions = IdentityOptions | { identityToken?: never; getIdentityToken?: never };
|
|
5
|
+
export type InitOptions = BootOptions & {
|
|
6
|
+
embedHash: string;
|
|
7
|
+
loadTimeoutMs?: number;
|
|
8
|
+
/** Hide the default launcher bubble from first paint; open the panel with show(). */
|
|
9
|
+
hideDefaultLauncher?: boolean;
|
|
10
|
+
/** Override the widget origin (https, or http for localhost). Defaults to https://embed.inferenc.com. */
|
|
11
|
+
widgetOrigin?: string;
|
|
12
|
+
/** Override the API origin (https, or http for localhost). Defaults to https://api.inferenc.com. */
|
|
13
|
+
apiOrigin?: string;
|
|
14
|
+
};
|
|
15
|
+
/** Flat custom attributes. Keys match /^[a-z][a-z0-9_]{0,63}$/, strings are at most 1000 characters, at most 100 keys; null deletes a key. */
|
|
16
|
+
export type ContactAttributes = Record<string, string | number | boolean | null>;
|
|
17
|
+
export interface CompanyData {
|
|
18
|
+
/** Your stable company ID (max 128 characters). */
|
|
19
|
+
company_id: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
attributes?: ContactAttributes;
|
|
22
|
+
}
|
|
23
|
+
export interface PageData {
|
|
24
|
+
url?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
referrer?: string;
|
|
27
|
+
utm_source?: string;
|
|
28
|
+
utm_medium?: string;
|
|
29
|
+
utm_campaign?: string;
|
|
30
|
+
utm_term?: string;
|
|
31
|
+
utm_content?: string;
|
|
32
|
+
[utm: `utm_${string}`]: string | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Contact data shared by browser `update()` and server `upsertContact()`. */
|
|
35
|
+
export interface ContactData {
|
|
36
|
+
/** Stored as unverified unless it equals the signed identity token claim. */
|
|
37
|
+
name?: string | null;
|
|
38
|
+
/** Stored as unverified unless it equals the signed identity token claim. */
|
|
39
|
+
email?: string | null;
|
|
40
|
+
phone?: string | null;
|
|
41
|
+
attributes?: ContactAttributes;
|
|
42
|
+
company?: CompanyData;
|
|
43
|
+
page?: PageData;
|
|
44
|
+
}
|
|
45
|
+
export interface UpdateOptions extends ContactData { hideDefaultLauncher?: boolean; }
|
|
46
|
+
export interface WidgetState {
|
|
47
|
+
/** identified means the backend verified identity and created/resolved a contact. */
|
|
48
|
+
status: 'uninitialized' | 'loading' | 'anonymous' | 'pending' | 'identified' | 'error';
|
|
49
|
+
identified: boolean;
|
|
50
|
+
isOpen: boolean;
|
|
51
|
+
/** false while the default launcher is hidden; true when uninitialized. */
|
|
52
|
+
launcherVisible: boolean;
|
|
53
|
+
/** Unread human/agent messages since the visitor last had the conversation open; 0 until the widget loads. */
|
|
54
|
+
unreadCount: number;
|
|
55
|
+
}
|
|
56
|
+
export interface InferencSDK {
|
|
57
|
+
init(options: InitOptions): Promise<void>;
|
|
58
|
+
boot(options?: BootOptions): Promise<void>;
|
|
59
|
+
identify(options: IdentityOptions): Promise<void>;
|
|
60
|
+
/** Synchronous: call before clearing your app's logged-in user. */
|
|
61
|
+
shutdown(): void;
|
|
62
|
+
trackEvent(name: string, metadata?: EventMetadata, options?: EventOptions): Promise<EventReceipt>;
|
|
63
|
+
show(): Promise<void>;
|
|
64
|
+
hide(): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Open the panel on a new, empty message. `prefill` fills the composer for the visitor to
|
|
67
|
+
* review and send; it is never sent automatically (max 2000 characters).
|
|
68
|
+
*/
|
|
69
|
+
showNewMessage(prefill?: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Open the panel on the conversation list. Signed-in users see their recent conversations;
|
|
72
|
+
* anonymous visitors have a single conversation, which opens directly.
|
|
73
|
+
*/
|
|
74
|
+
showMessages(): Promise<void>;
|
|
75
|
+
/** Change widget behaviour at runtime, e.g. hide the launcher while a cookie banner is up. */
|
|
76
|
+
update(options: UpdateOptions): Promise<void>;
|
|
77
|
+
/** Called when the panel opens (also emitted as a `inferenc:show` window event). Returns an unsubscribe function. */
|
|
78
|
+
onShow(callback: () => void): () => void;
|
|
79
|
+
/** Called when the panel closes (also emitted as a `inferenc:hide` window event). Returns an unsubscribe function. */
|
|
80
|
+
onHide(callback: () => void): () => void;
|
|
81
|
+
/** Called with the new unread count whenever it changes (also emitted as `inferenc:unread-count`, detail `{ count }`). Returns an unsubscribe function. */
|
|
82
|
+
onUnreadCountChange(callback: (count: number) => void): () => void;
|
|
83
|
+
getState(): WidgetState;
|
|
84
|
+
}
|
|
85
|
+
export declare const inferenc: InferencSDK;
|
|
86
|
+
export default inferenc;
|
|
87
|
+
|
|
88
|
+
export type EventMetadata = Record<string, string | number | boolean | null>;
|
|
89
|
+
export interface EventOptions { eventId?: string; createdAt?: string; }
|
|
90
|
+
export interface EventReceipt { event_id: string; contact_id: string; duplicate: boolean; }
|
|
91
|
+
|
|
92
|
+
/** Maps free-form legacy names to a valid snake_case event name; throws TypeError when nothing usable remains. */
|
|
93
|
+
export declare function normalizeEventName(name: string): string;
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export { normalizeEventName } from './events.js';
|
|
2
|
+
import { contactPayload } from './attributes.js';
|
|
3
|
+
const stateKey = Symbol.for('inferenc.sdk.v1');
|
|
4
|
+
const defaultWidgetOrigin = 'https://embed.inferenc.com';
|
|
5
|
+
const cancelled = () => Object.assign(new Error('Inferenc operation cancelled by a newer identity or logout'), { name: 'AbortError' });
|
|
6
|
+
function state() {
|
|
7
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') throw new Error('Inferenc.init must run in a browser, after client hydration');
|
|
8
|
+
return window[stateKey] ??= { embedHash: null, loading: null, runtime: null, generation: 0, resetOnReady: false, error: false };
|
|
9
|
+
}
|
|
10
|
+
function identityOptions(options = {}, required = false) {
|
|
11
|
+
if (!options || typeof options !== 'object') throw new TypeError('Identity options must be an object');
|
|
12
|
+
const { identityToken, getIdentityToken } = options;
|
|
13
|
+
if (identityToken !== undefined && getIdentityToken !== undefined) throw new TypeError('Supply only one of identityToken or getIdentityToken');
|
|
14
|
+
if (identityToken !== undefined && (typeof identityToken !== 'string' || !identityToken)) throw new TypeError('identityToken must be a non-empty string');
|
|
15
|
+
if (getIdentityToken !== undefined && typeof getIdentityToken !== 'function') throw new TypeError('getIdentityToken must be a function');
|
|
16
|
+
if (required && identityToken === undefined && getIdentityToken === undefined) throw new TypeError('identify requires identityToken or getIdentityToken');
|
|
17
|
+
return getIdentityToken !== undefined ? { getIdentityToken } : identityToken !== undefined ? { identityToken } : {};
|
|
18
|
+
}
|
|
19
|
+
// https only, or http for localhost / 127.0.0.1. Returns the normalised origin.
|
|
20
|
+
function safeOrigin(value, name) {
|
|
21
|
+
if (value === undefined) return undefined;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
25
|
+
if (url.protocol === 'https:' || (url.protocol === 'http:' && local)) return url.origin;
|
|
26
|
+
} catch { /* handled below */ }
|
|
27
|
+
throw new TypeError(`Invalid ${name}`);
|
|
28
|
+
}
|
|
29
|
+
function load(s, embedHash, timeoutMs, settings = {}) {
|
|
30
|
+
if (s.embedHash && s.embedHash !== embedHash) throw new Error('A different widget is already initialized; use one embedHash per page');
|
|
31
|
+
if (s.loading) return s.loading;
|
|
32
|
+
if (window.Inferenc || document.querySelectorAll('script[src*="embed.inferenc.com/widget-loader.js"], script[src*="embed.inferenc.com/api/widget"], script[src$="/widget-loader.js"][data-embed-hash]').length) {
|
|
33
|
+
throw new Error('Remove the existing Inferenc script integration before using the npm SDK');
|
|
34
|
+
}
|
|
35
|
+
s.embedHash = embedHash;
|
|
36
|
+
s.loading = new Promise((resolve, reject) => {
|
|
37
|
+
const script = document.createElement('script');
|
|
38
|
+
let settled = false;
|
|
39
|
+
const finish = (error) => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
window.removeEventListener('inferenc:ready', ready);
|
|
44
|
+
if (error) { s.error = true; reject(error); return; }
|
|
45
|
+
s.runtime = window.Inferenc;
|
|
46
|
+
if (s.resetOnReady) { s.runtime.shutdown(); s.resetOnReady = false; }
|
|
47
|
+
resolve(s.runtime);
|
|
48
|
+
};
|
|
49
|
+
const ready = () => {
|
|
50
|
+
const runtime = window.Inferenc;
|
|
51
|
+
if (!runtime || !['boot','identify','shutdown','show','hide','getState'].every(k => typeof runtime[k] === 'function')) {
|
|
52
|
+
finish(new Error('Inferenc loaded an incompatible widget runtime')); return;
|
|
53
|
+
}
|
|
54
|
+
finish();
|
|
55
|
+
};
|
|
56
|
+
const timer = setTimeout(() => finish(new Error('Inferenc widget load timed out; check the allowed domains and reload to retry')), timeoutMs);
|
|
57
|
+
window.addEventListener('inferenc:ready', ready);
|
|
58
|
+
script.addEventListener('error', () => finish(new Error('Inferenc widget failed to load; reload to retry')), { once: true });
|
|
59
|
+
script.src = `${settings.widgetOrigin ?? defaultWidgetOrigin}/widget-loader.js`;
|
|
60
|
+
script.dataset.embedHash = embedHash;
|
|
61
|
+
if (settings.widgetOrigin) script.dataset.widgetOrigin = settings.widgetOrigin;
|
|
62
|
+
if (settings.apiOrigin) script.dataset.apiOrigin = settings.apiOrigin;
|
|
63
|
+
if (settings.hideDefaultLauncher) script.dataset.hideLauncher = 'true';
|
|
64
|
+
script.async = true;
|
|
65
|
+
document.head.appendChild(script);
|
|
66
|
+
});
|
|
67
|
+
return s.loading;
|
|
68
|
+
}
|
|
69
|
+
// Widget events are plain window CustomEvents, so subscribing works before init resolves
|
|
70
|
+
// and for script-tag users too. Returns an idempotent unsubscribe function.
|
|
71
|
+
function subscribe(name, callback, pick) {
|
|
72
|
+
if (typeof callback !== 'function') throw new TypeError('callback must be a function');
|
|
73
|
+
if (typeof window === 'undefined') return () => {};
|
|
74
|
+
const listener = (event) => {
|
|
75
|
+
const value = pick ? pick(event) : undefined;
|
|
76
|
+
if (value === null) return;
|
|
77
|
+
try { callback(value); } catch (error) { console.error('Inferenc callback failed', error); }
|
|
78
|
+
};
|
|
79
|
+
window.addEventListener(name, listener);
|
|
80
|
+
return () => window.removeEventListener(name, listener);
|
|
81
|
+
}
|
|
82
|
+
const unreadCount = (event) => {
|
|
83
|
+
const count = event?.detail?.count;
|
|
84
|
+
return Number.isInteger(count) && count >= 0 ? count : null;
|
|
85
|
+
};
|
|
86
|
+
async function operate(method, options) {
|
|
87
|
+
const s = state();
|
|
88
|
+
if (!s.loading) throw new Error('Call and await Inferenc.init first');
|
|
89
|
+
const version = ++s.generation;
|
|
90
|
+
const runtime = await s.loading;
|
|
91
|
+
if (version !== s.generation) throw cancelled();
|
|
92
|
+
await runtime[method](options);
|
|
93
|
+
if (version !== s.generation) throw cancelled();
|
|
94
|
+
}
|
|
95
|
+
async function readyRuntime(method) {
|
|
96
|
+
const s = state(); if (!s.loading) throw new Error('Call and await Inferenc.init first');
|
|
97
|
+
const version = s.generation, runtime = await s.loading;
|
|
98
|
+
if (version !== s.generation) throw cancelled();
|
|
99
|
+
if (typeof runtime[method] !== 'function') throw new Error(`Hosted widget does not support ${method}; reload after updating it`);
|
|
100
|
+
return runtime;
|
|
101
|
+
}
|
|
102
|
+
export const inferenc = {
|
|
103
|
+
async init(options) {
|
|
104
|
+
if (!options || typeof options.embedHash !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(options.embedHash)) throw new TypeError('A valid embedHash is required');
|
|
105
|
+
const identity = identityOptions(options);
|
|
106
|
+
const timeoutMs = options.loadTimeoutMs ?? 20000;
|
|
107
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120000) throw new TypeError('loadTimeoutMs must be between 1 and 120000');
|
|
108
|
+
if (options.hideDefaultLauncher !== undefined && typeof options.hideDefaultLauncher !== 'boolean') throw new TypeError('hideDefaultLauncher must be a boolean');
|
|
109
|
+
const widgetOrigin = safeOrigin(options.widgetOrigin, 'widgetOrigin');
|
|
110
|
+
const apiOrigin = safeOrigin(options.apiOrigin, 'apiOrigin');
|
|
111
|
+
const s = state();
|
|
112
|
+
load(s, options.embedHash, timeoutMs, { widgetOrigin, apiOrigin, hideDefaultLauncher: options.hideDefaultLauncher });
|
|
113
|
+
await operate('boot', identity);
|
|
114
|
+
},
|
|
115
|
+
async boot(options = {}) { await operate('boot', identityOptions(options)); },
|
|
116
|
+
async identify(options) { await operate('identify', identityOptions(options, true)); },
|
|
117
|
+
async trackEvent(name, metadata = {}, options = {}) {
|
|
118
|
+
const s = state(); if (!s.loading) throw new Error('Call and await Inferenc.init first');
|
|
119
|
+
const version = s.generation, runtime = await s.loading;
|
|
120
|
+
if (version !== s.generation) throw cancelled();
|
|
121
|
+
if (typeof runtime.trackEvent !== 'function') throw new Error('Hosted widget does not support events; reload after updating it');
|
|
122
|
+
const receipt = await runtime.trackEvent(name, metadata, options);
|
|
123
|
+
if (version !== s.generation) throw cancelled();
|
|
124
|
+
return receipt;
|
|
125
|
+
},
|
|
126
|
+
shutdown() {
|
|
127
|
+
if (typeof window === 'undefined') return;
|
|
128
|
+
const s = window[stateKey];
|
|
129
|
+
if (!s) return;
|
|
130
|
+
++s.generation;
|
|
131
|
+
if (s.runtime) s.runtime.shutdown();
|
|
132
|
+
else if (s.loading) s.resetOnReady = true;
|
|
133
|
+
},
|
|
134
|
+
async show() {
|
|
135
|
+
const s = state(); if (!s.loading) throw new Error('Call and await Inferenc.init first');
|
|
136
|
+
const version = s.generation, runtime = await s.loading;
|
|
137
|
+
if (version !== s.generation) throw cancelled();
|
|
138
|
+
runtime.show();
|
|
139
|
+
},
|
|
140
|
+
async update(options) {
|
|
141
|
+
if (!options || typeof options !== 'object') throw new TypeError('update options must be an object');
|
|
142
|
+
if (options.hideDefaultLauncher !== undefined && typeof options.hideDefaultLauncher !== 'boolean') throw new TypeError('hideDefaultLauncher must be a boolean');
|
|
143
|
+
const contact = contactPayload(Object.fromEntries(['name', 'email', 'phone', 'attributes', 'company', 'page'].filter(key => options[key] !== undefined).map(key => [key, options[key]])));
|
|
144
|
+
const s = state(); if (!s.loading) throw new Error('Call and await Inferenc.init first');
|
|
145
|
+
const version = s.generation, runtime = await s.loading;
|
|
146
|
+
if (version !== s.generation) throw cancelled();
|
|
147
|
+
if (typeof runtime.update !== 'function') throw new Error('Hosted widget does not support update; reload after updating it');
|
|
148
|
+
// Contact data resolves once sent (identified visitors) or queued (anonymous visitors).
|
|
149
|
+
await runtime.update({ ...(options.hideDefaultLauncher === undefined ? {} : { hideDefaultLauncher: options.hideDefaultLauncher }), ...contact });
|
|
150
|
+
},
|
|
151
|
+
async hide() {
|
|
152
|
+
const s = state(); if (!s.loading) throw new Error('Call and await Inferenc.init first');
|
|
153
|
+
const version = s.generation, runtime = await s.loading;
|
|
154
|
+
if (version !== s.generation) throw cancelled();
|
|
155
|
+
runtime.hide();
|
|
156
|
+
},
|
|
157
|
+
async showNewMessage(prefill) {
|
|
158
|
+
if (prefill !== undefined && (typeof prefill !== 'string' || prefill.length > 2000)) throw new TypeError('prefill must be a string of at most 2000 characters');
|
|
159
|
+
(await readyRuntime('showNewMessage')).showNewMessage(prefill ?? '');
|
|
160
|
+
},
|
|
161
|
+
async showMessages() { (await readyRuntime('showMessages')).showMessages(); },
|
|
162
|
+
/** Called when the panel opens. Returns an unsubscribe function. */
|
|
163
|
+
onShow(callback) { return subscribe('inferenc:show', callback); },
|
|
164
|
+
/** Called when the panel closes. Returns an unsubscribe function. */
|
|
165
|
+
onHide(callback) { return subscribe('inferenc:hide', callback); },
|
|
166
|
+
/** Called with the new unread count whenever it changes. Returns an unsubscribe function. */
|
|
167
|
+
onUnreadCountChange(callback) { return subscribe('inferenc:unread-count', callback, unreadCount); },
|
|
168
|
+
getState() {
|
|
169
|
+
const s = typeof window !== 'undefined' ? window[stateKey] : null;
|
|
170
|
+
return s?.runtime ? s.runtime.getState() : { status: s?.error ? 'error' : s?.loading ? 'loading' : 'uninitialized', identified: false, isOpen: false, launcherVisible: true, unreadCount: 0 };
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
export default inferenc;
|
package/src/events.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Keep validation aligned with the hosted widget and public events endpoint.
|
|
2
|
+
export function eventPayload(name, metadata = {}, options = {}) {
|
|
3
|
+
if (typeof name !== 'string' || !/^[A-Za-z][A-Za-z0-9_.-]{0,99}$/.test(name)) throw new TypeError('Invalid event name');
|
|
4
|
+
const eventId = options.eventId ?? crypto.randomUUID();
|
|
5
|
+
if (typeof eventId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(eventId)) throw new TypeError('Invalid eventId');
|
|
6
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata) || (Object.getPrototypeOf(metadata) !== null && Object.getPrototypeOf(Object.getPrototypeOf(metadata)) !== null) || Object.keys(metadata).length > 50) throw new TypeError('metadata must be a flat map of at most 50 keys');
|
|
7
|
+
for (const value of Object.values(metadata)) if (value !== null && !['string', 'number', 'boolean'].includes(typeof value) || typeof value === 'number' && !Number.isFinite(value)) throw new TypeError('metadata values must be finite JSON primitives');
|
|
8
|
+
if (new TextEncoder().encode(JSON.stringify(metadata)).length > 16384) throw new TypeError('metadata exceeds 16KB');
|
|
9
|
+
if (options.createdAt !== undefined && (typeof options.createdAt !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(options.createdAt) || !Number.isFinite(Date.parse(options.createdAt)) || Date.parse(options.createdAt) > Date.now() + 300000)) throw new TypeError('Invalid createdAt UTC timestamp');
|
|
10
|
+
return { event_id: eventId, name, metadata, ...(options.createdAt === undefined ? {} : { created_at: options.createdAt }) };
|
|
11
|
+
}
|
|
12
|
+
// Maps free-form legacy names (e.g. "Upgrade Button - Click") to a valid snake_case event name.
|
|
13
|
+
export function normalizeEventName(name) {
|
|
14
|
+
if (typeof name !== 'string') throw new TypeError('Event name must be a string');
|
|
15
|
+
let normalized = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
16
|
+
if (!normalized) throw new TypeError('Event name has no usable characters');
|
|
17
|
+
if (/^[0-9]/.test(normalized)) normalized = `e_${normalized}`;
|
|
18
|
+
return normalized.slice(0, 100).replace(/_+$/, '');
|
|
19
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface SignWidgetIdentityOptions {
|
|
2
|
+
/** Server-only signing secret provisioned for this widget. Never expose it to clients. */
|
|
3
|
+
secret: string;
|
|
4
|
+
embedHash: string;
|
|
5
|
+
/** Immutable user ID read from your authenticated server session. */
|
|
6
|
+
userId: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
/** Lifetime in seconds: 60–3600; default 600. */
|
|
10
|
+
expiresIn?: number;
|
|
11
|
+
/** Unix timestamp in seconds. Omit in production. */
|
|
12
|
+
now?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function signWidgetIdentity(options: SignWidgetIdentityOptions): string;
|
|
15
|
+
|
|
16
|
+
import type { EventMetadata, EventOptions, EventReceipt } from './browser.js';
|
|
17
|
+
export interface TrackEventOptions extends EventOptions {
|
|
18
|
+
secret: string;
|
|
19
|
+
embedHash: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
metadata?: EventMetadata;
|
|
23
|
+
email?: string;
|
|
24
|
+
userName?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function trackEvent(options: TrackEventOptions): Promise<EventReceipt>;
|
|
27
|
+
|
|
28
|
+
import type { CompanyData, ContactData } from './browser.js';
|
|
29
|
+
export interface UpsertContactOptions extends ContactData {
|
|
30
|
+
secret: string;
|
|
31
|
+
embedHash: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ContactReceipt { contact_id: string; user_id: string; contact?: Record<string, unknown>; }
|
|
35
|
+
/** Create or update a contact. name and email are signed by your secret and stored as verified. */
|
|
36
|
+
export declare function upsertContact(options: UpsertContactOptions): Promise<ContactReceipt>;
|
|
37
|
+
export declare function upsertCompany(options: { secret: string; embedHash: string; userId: string; company: CompanyData }): Promise<ContactReceipt>;
|
|
38
|
+
|
|
39
|
+
export { normalizeEventName } from './browser.js';
|
package/src/server.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Server-only helper. Never ship the signing secret to a browser. */
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
export function signWidgetIdentity({ secret, embedHash, userId, name, email, expiresIn = 600, now = Math.floor(Date.now() / 1000) }) {
|
|
4
|
+
if (typeof secret !== 'string' || Buffer.byteLength(secret) < 32) throw new Error('Signing secret must contain at least 32 bytes');
|
|
5
|
+
if (typeof userId !== 'string' || !userId.trim() || userId.length > 256) throw new Error('Invalid userId');
|
|
6
|
+
if (typeof embedHash !== 'string' || !embedHash || embedHash.length > 128) throw new Error('Invalid embedHash');
|
|
7
|
+
if (!Number.isInteger(expiresIn) || expiresIn < 60 || expiresIn > 3600 || !Number.isInteger(now)) throw new Error('Token lifetime must be 60–3600 seconds');
|
|
8
|
+
const claims = { sub: userId, aud: embedHash, iat: now, exp: now + expiresIn };
|
|
9
|
+
for (const [field, value] of Object.entries({ name, email })) {
|
|
10
|
+
if (value !== undefined) {
|
|
11
|
+
if (typeof value !== 'string' || value.length > 320) throw new Error(`Invalid ${field}`);
|
|
12
|
+
claims[field] = value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
16
|
+
const payload = `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}`;
|
|
17
|
+
return `${payload}.${createHmac('sha256', secret).update(payload).digest('base64url')}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
import { eventPayload } from './events.js';
|
|
21
|
+
export { normalizeEventName } from './events.js';
|
|
22
|
+
import { contactPayload } from './attributes.js';
|
|
23
|
+
// Signed POST to a public SDK route, retrying network errors, 429 and 5xx with the same body.
|
|
24
|
+
async function post(route, label, token, body, valid) {
|
|
25
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(`https://api.inferenc.com/v1/public/chat/${route}`, {
|
|
28
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Widget ${token}` },
|
|
29
|
+
body, signal: AbortSignal.timeout(15000), redirect: 'error',
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) throw Object.assign(new Error(`Inferenc ${label} failed (${response.status})`), { status: response.status });
|
|
32
|
+
const result = await response.json();
|
|
33
|
+
if (!result.success || !valid(result.data)) throw Object.assign(new Error(`Invalid Inferenc ${label} response`), { status: 502 });
|
|
34
|
+
return result.data;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (attempt === 2 || error.status && error.status !== 429 && error.status < 500) throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Deliver contextual events. Persist eventId in your job/outbox for safe retries. */
|
|
41
|
+
export async function trackEvent({ secret, embedHash, userId, name, metadata = {}, eventId, createdAt, email, userName }) {
|
|
42
|
+
const body = JSON.stringify(eventPayload(name, metadata, { eventId, createdAt }));
|
|
43
|
+
const token = signWidgetIdentity({ secret, embedHash, userId, name: userName, email });
|
|
44
|
+
return post('events', 'event', token, body, data => data?.event_id && data?.contact_id);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Create or update a contact. The signing secret vouches for name and email, so they are stored as
|
|
48
|
+
* verified; phone, attributes (null deletes a key) and company are merged into the contact.
|
|
49
|
+
*/
|
|
50
|
+
export async function upsertContact({ secret, embedHash, userId, email, name, phone, attributes, company, page }) {
|
|
51
|
+
const body = JSON.stringify(contactPayload({ name, email, phone, attributes, company, page }));
|
|
52
|
+
const token = signWidgetIdentity({ secret, embedHash, userId, name, email });
|
|
53
|
+
return post('contacts', 'contact', token, body, data => data?.contact_id);
|
|
54
|
+
}
|
|
55
|
+
/** Attach or update the company of an existing user (creates the contact if needed). */
|
|
56
|
+
export async function upsertCompany({ secret, embedHash, userId, company }) {
|
|
57
|
+
if (company === undefined) throw new TypeError('company is required');
|
|
58
|
+
const body = JSON.stringify(contactPayload({ company }));
|
|
59
|
+
const token = signWidgetIdentity({ secret, embedHash, userId });
|
|
60
|
+
return post('contacts', 'company', token, body, data => data?.contact_id);
|
|
61
|
+
}
|