@invonetwork/web-sdk 0.2.1 → 0.4.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 +48 -1
- package/LICENSE +18 -17
- package/README.md +495 -393
- package/dist/chunk-EEWOAUXO.js +249 -0
- package/dist/index.cjs +188 -63
- package/dist/index.d.cts +9 -9
- package/dist/index.d.ts +9 -9
- package/dist/index.js +54 -31
- package/dist/server.cjs +474 -59
- package/dist/server.d.cts +177 -13
- package/dist/server.d.ts +177 -13
- package/dist/server.js +339 -29
- package/dist/{errors-DV5QsftP.d.cts → types-CBMLNwbe.d.cts} +152 -42
- package/dist/{errors-DV5QsftP.d.ts → types-CBMLNwbe.d.ts} +152 -42
- package/package.json +10 -2
- package/dist/chunk-A44O4KC3.js +0 -147
package/README.md
CHANGED
|
@@ -1,393 +1,495 @@
|
|
|
1
|
-
# @invonetwork/web-sdk
|
|
2
|
-
|
|
3
|
-
First-party TypeScript SDK for integrating **INVO** into partner **web** platforms (storefronts, web games, dashboards). It wraps INVO's web money flows behind a typed, versioned API — the web analog of the Unity/Unreal plugins.
|
|
4
|
-
|
|
5
|
-
> **Status:** `v0.
|
|
6
|
-
> Canonical partner reference: **https://docs.invo.network/docs/currency-purchase** and **https://docs.invo.network/docs/game-developer-integration**.
|
|
7
|
-
|
|
8
|
-
## What it does
|
|
9
|
-
|
|
10
|
-
Four money flows plus passkey (WebAuthn) authentication, split across a trusted server entry and an untrusted browser entry:
|
|
11
|
-
|
|
12
|
-
| Flow | Direction | Real money? | Passkey? | Where |
|
|
13
|
-
|---|---|---|---|---|
|
|
14
|
-
| **Currency purchase** | real money **→** game currency | yes (card/rails) | no | server initiates, browser opens hosted checkout |
|
|
15
|
-
| **Item purchase** | game currency **→** in-game item | no (balance debit) | no | server only |
|
|
16
|
-
| **Send** | currency **→** another player (cross-game) | no | yes (sender approves) | server initiates, browser approves/claims |
|
|
17
|
-
| **Transfer** | currency **→** another player (transfer rail) | no | yes (sender approves) | server initiates, browser approves/claims |
|
|
18
|
-
|
|
19
|
-
The **game secret stays on your server**; the browser only ever holds a short-lived, game-scoped **player token**.
|
|
20
|
-
|
|
21
|
-
## Contents
|
|
22
|
-
|
|
23
|
-
- [Install](#install)
|
|
24
|
-
- [Architecture & the two entry points](#architecture--the-two-entry-points)
|
|
25
|
-
- [Deployment prerequisites](#deployment-prerequisites)
|
|
26
|
-
- [Configuration](#configuration)
|
|
27
|
-
- [Currency purchase (real money in)](#currency-purchase-real-money-in)
|
|
28
|
-
- [Item purchase (spend game currency)](#item-purchase-spend-game-currency)
|
|
29
|
-
- [
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
32
|
-
- [
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
│
|
|
62
|
-
│
|
|
63
|
-
│
|
|
64
|
-
│ •
|
|
65
|
-
│ •
|
|
66
|
-
│ •
|
|
67
|
-
│ •
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
- `
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
|
|
90
|
-
**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
iframe.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
-
|
|
229
|
-
|
|
230
|
-
**
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
-
|
|
323
|
-
-
|
|
324
|
-
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1
|
+
# @invonetwork/web-sdk
|
|
2
|
+
|
|
3
|
+
First-party TypeScript SDK for integrating **INVO** into partner **web** platforms (storefronts, web games, dashboards). It wraps INVO's web money flows behind a typed, versioned API — the web analog of the Unity/Unreal plugins.
|
|
4
|
+
|
|
5
|
+
> **Status:** `v0.4.0`, published on npm. The backend it wraps is **live** on sandbox + production, so you can build and test against sandbox today.
|
|
6
|
+
> Canonical partner reference: **https://docs.invo.network/docs/currency-purchase** and **https://docs.invo.network/docs/game-developer-integration**.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
Four money flows plus passkey (WebAuthn) authentication, split across a trusted server entry and an untrusted browser entry:
|
|
11
|
+
|
|
12
|
+
| Flow | Direction | Real money? | Passkey? | Where |
|
|
13
|
+
|---|---|---|---|---|
|
|
14
|
+
| **Currency purchase** | real money **→** game currency | yes (card/rails) | no | server initiates, browser opens hosted checkout |
|
|
15
|
+
| **Item purchase** | game currency **→** in-game item | no (balance debit) | no | server only |
|
|
16
|
+
| **Send** | currency **→** another player (cross-game) | no | yes (sender approves) | server initiates, browser approves/claims |
|
|
17
|
+
| **Transfer** | currency **→** another player (transfer rail) | no | yes (sender approves) | server initiates, browser approves/claims |
|
|
18
|
+
|
|
19
|
+
The **game secret stays on your server**; the browser only ever holds a short-lived, game-scoped **player token**.
|
|
20
|
+
|
|
21
|
+
## Contents
|
|
22
|
+
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Architecture & the two entry points](#architecture--the-two-entry-points)
|
|
25
|
+
- [Deployment prerequisites](#deployment-prerequisites)
|
|
26
|
+
- [Configuration](#configuration)
|
|
27
|
+
- [Currency purchase (real money in)](#currency-purchase-real-money-in)
|
|
28
|
+
- [Item purchase (spend game currency)](#item-purchase-spend-game-currency)
|
|
29
|
+
- [Player balance](#player-balance)
|
|
30
|
+
- [Sends & transfers (move currency between players)](#sends--transfers-move-currency-between-players)
|
|
31
|
+
- [Passkeys (enroll, approve, link)](#passkeys-enroll-approve-link)
|
|
32
|
+
- [Webhooks](#webhooks)
|
|
33
|
+
- [Resilience & observability](#resilience--observability)
|
|
34
|
+
- [Errors](#errors)
|
|
35
|
+
- [API reference](#api-reference)
|
|
36
|
+
- [Scripts & versioning](#scripts--versioning)
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install @invonetwork/web-sdk
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Node ≥ 18 on the server (uses the global `fetch`). The browser build ships ESM + CJS + types.
|
|
45
|
+
|
|
46
|
+
## Get your account & game secret (INVO console)
|
|
47
|
+
|
|
48
|
+
Sign up, create your game, and copy its credentials (the **game secret**, plus your WebAuthn **RP ID / origins**) in the INVO console. Use the console that matches the environment you're building against:
|
|
49
|
+
|
|
50
|
+
| Environment | Console — sign up, manage games, copy your game secret | API `baseUrl` |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| **Testing / sandbox** | **https://dev.console.invo.network** | `https://sandbox.invo.network/sandbox` |
|
|
53
|
+
| **Production** | **https://console.invo.network** | `https://invo.network` |
|
|
54
|
+
|
|
55
|
+
Build and test against the **dev console + sandbox** first, then switch to the **production console + `https://invo.network`** for launch. **Each environment has its own game secret — never mix them**, and keep the secret server-side only.
|
|
56
|
+
|
|
57
|
+
## Architecture & the two entry points
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
┌──────────────────────────────┐ ┌──────────────────────────────┐
|
|
61
|
+
│ YOUR SERVER (trusted) │ │ THE BROWSER (untrusted) │
|
|
62
|
+
│ @invonetwork/web-sdk/server │ │ @invonetwork/web-sdk │
|
|
63
|
+
│ │ mint │ │
|
|
64
|
+
│ • holds X-Game-Secret-Key │ ──────► │ • holds short-lived token │
|
|
65
|
+
│ • mintPlayerToken() │ token │ (~15 min, game-scoped) │
|
|
66
|
+
│ • initiateSend/Transfer() │ │ • enrollPasskey() │
|
|
67
|
+
│ • createCheckout() │ │ • approveSend/Transfer() │
|
|
68
|
+
│ • purchaseCurrency() │ │ • confirmReceipt*() │
|
|
69
|
+
│ • purchaseItem() │ │ • linkDevice() │
|
|
70
|
+
└───────────────┬───────────────┘ └───────────────┬──────────────┘
|
|
71
|
+
└──────────────► INVO BACKEND ◄────────────┘
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Import | Runs on | Holds | Responsibilities |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| `@invonetwork/web-sdk/server` | your backend (Node ≥18) | the **game secret** | mint player tokens; initiate sends/transfers; currency purchase; item purchase |
|
|
77
|
+
| `@invonetwork/web-sdk` | the browser | a short-lived **player token** | passkey enroll, approve, self-claim, device link |
|
|
78
|
+
|
|
79
|
+
**Never import `/server` into browser code** — it carries the game secret. The two entries are built separately for exactly this reason.
|
|
80
|
+
|
|
81
|
+
## Deployment prerequisites
|
|
82
|
+
|
|
83
|
+
INVO provisions these per tenant before the flows go live (most are super_admin-only, set by INVO — coordinate with your INVO contact):
|
|
84
|
+
|
|
85
|
+
**For sends/transfers + passkeys**
|
|
86
|
+
- `SDK_TRANSFER_VERIFICATION_ENABLED` (master) — on.
|
|
87
|
+
- `SDK_WEBAUTHN_ENABLED` (master) — on.
|
|
88
|
+
- `SDK_TRANSFER_CONFIRM_RECEIPT_ENABLED` — required for **transfer** self-claim.
|
|
89
|
+
- Tenant migrated (`games.sdk_verification_enabled`).
|
|
90
|
+
- **Per-tenant RP ID + origins** (`webauthn_rp_id`, `webauthn_origins`). There's no separate "webauthn on" flag — *the presence of a valid RP ID is the gate*. Until it's set, WebAuthn endpoints return `403 WEBAUTHN_NOT_ENABLED_FOR_TENANT`. **You must serve your integration from an origin listed in `webauthn_origins`,** or passkeys won't validate.
|
|
91
|
+
|
|
92
|
+
**For currency purchase**
|
|
93
|
+
- `platform` (card) rail is always on; `game`/`steam` rails are off by default and each gated by their own flag + per-game config.
|
|
94
|
+
- Honors the platform `purchases` kill switch (`503 flow_paused` when paused).
|
|
95
|
+
|
|
96
|
+
**For item purchase**
|
|
97
|
+
- The game must be in `live` or `testing` state (else `403`). No passkey/payment flags involved — it's a balance debit.
|
|
98
|
+
|
|
99
|
+
**On your side:** store the game secret in server-side config/secrets (never ship it to the browser), and expose a small endpoint that calls `mintPlayerToken` so the browser can fetch/refresh its token.
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { InvoServer } from "@invonetwork/web-sdk/server";
|
|
105
|
+
import { InvoClient } from "@invonetwork/web-sdk";
|
|
106
|
+
|
|
107
|
+
const server = new InvoServer({
|
|
108
|
+
gameSecret: process.env.INVO_GAME_SECRET!, // server-side only
|
|
109
|
+
baseUrl: "https://sandbox.invo.network/sandbox", // prod: "https://invo.network"
|
|
110
|
+
timeoutMs: 30_000, // optional, default 30s
|
|
111
|
+
maxRetries: 2, // optional, default 2 (0 disables)
|
|
112
|
+
// retryBaseDelayMs: 250, // optional backoff base
|
|
113
|
+
// fetch: customFetch, // optional override
|
|
114
|
+
// hooks: { onRequest, onResponse, onError }, // optional observability (see below)
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const client = new InvoClient({
|
|
118
|
+
token, // from your /mint endpoint
|
|
119
|
+
baseUrl: "https://sandbox.invo.network/sandbox",
|
|
120
|
+
refreshToken: () => // optional: auto re-mint + retry on token expiry
|
|
121
|
+
fetch("/invo/token", { method: "POST" }).then((r) => r.json()).then((j) => j.token),
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Base URLs** (manage each environment in its [console](#get-your-account--game-secret-invo-console))
|
|
126
|
+
- Production: `https://invo.network` — console: `https://console.invo.network`
|
|
127
|
+
- Sandbox / testing: `https://sandbox.invo.network/sandbox` — console: `https://dev.console.invo.network` (sandbox prepends the `/sandbox` prefix; the SDK absorbs it via `baseUrl`)
|
|
128
|
+
|
|
129
|
+
`baseUrl` must be `https://` — the game secret and player token travel in request headers, so plaintext is rejected. `http://localhost` is allowed for local dev only.
|
|
130
|
+
|
|
131
|
+
**Player tokens** live ~15 minutes and are game-scoped. Mint one per browser session. If you pass `refreshToken` to `InvoClient`, the SDK transparently re-mints and retries once on `SDK_TOKEN_EXPIRED` (it re-runs the whole passkey ceremony so it never replays a single-use challenge).
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Currency purchase (real money in)
|
|
136
|
+
|
|
137
|
+
Buy game currency with real money. Authenticated by the **payment rail**, not a passkey — there's no WebAuthn step. Two paths:
|
|
138
|
+
|
|
139
|
+
### Hosted checkout (recommended — PCI-light, you never touch card data)
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// SERVER
|
|
143
|
+
const { checkoutUrl, sessionId, expiresAt } = await server.createCheckout({
|
|
144
|
+
playerEmail: "p@example.com",
|
|
145
|
+
usdAmount: "20.00", // USD, 0 < x ≤ 999.99
|
|
146
|
+
rail: "platform", // optional: "platform" (default) | "game" | "steam"
|
|
147
|
+
successUrl: "https://you/buy/ok",
|
|
148
|
+
cancelUrl: "https://you/buy/cancel",
|
|
149
|
+
metadata: { yourOrderId: "ord_42" }, // echoed on the purchase.completed webhook
|
|
150
|
+
});
|
|
151
|
+
// → send the browser to checkoutUrl (single-use, ~15 min)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Open `checkoutUrl` either way:
|
|
155
|
+
|
|
156
|
+
- **Full-page redirect / WebView** — works everywhere; on success the page redirects to your `successUrl`.
|
|
157
|
+
- **Embedded `<iframe>`** — works by default from any https origin (no allow-listing). The page does *not* redirect your top window; listen for the `INVO_CHECKOUT_COMPLETE` postMessage:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// BROWSER
|
|
161
|
+
const iframe = document.createElement("iframe");
|
|
162
|
+
iframe.src = checkoutUrl;
|
|
163
|
+
iframe.style.cssText = "width:440px;height:720px;border:0";
|
|
164
|
+
document.body.appendChild(iframe);
|
|
165
|
+
|
|
166
|
+
window.addEventListener("message", (e) => {
|
|
167
|
+
if (e.origin !== "https://invo.network") return; // sandbox: "https://sandbox.invo.network"
|
|
168
|
+
if (e.data?.type === "INVO_CHECKOUT_COMPLETE") {
|
|
169
|
+
// UX hint ONLY (unsigned). data = { status, new_balance, currency_name, transaction_id }
|
|
170
|
+
refreshBalanceOptimistically(e.data.data.new_balance);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The hosted page handles card entry, saved cards, and 3-D Secure (with a top-level break-out when framed). **Grant currency off the `purchase.completed` webhook**, not the postMessage hint. Currency purchase has **no browser SDK method** — the browser only opens the URL.
|
|
176
|
+
|
|
177
|
+
### Payment rails (neutral names)
|
|
178
|
+
|
|
179
|
+
The `rail` selects the in-page experience — all branded INVO, no visible redirect:
|
|
180
|
+
- `"platform"` (default) — card checkout.
|
|
181
|
+
- `"game"` — regional / game-store methods.
|
|
182
|
+
- `"steam"` — Steam titles hand off to the in-client Steam flow.
|
|
183
|
+
|
|
184
|
+
Provider/processor names are an internal detail and never appear in the API.
|
|
185
|
+
|
|
186
|
+
### Direct rail (advanced — you tokenize the card yourself)
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const purchase = await server.purchaseCurrency({
|
|
190
|
+
playerEmail: "p@example.com",
|
|
191
|
+
usdAmount: "20.00",
|
|
192
|
+
purchaseReference: crypto.randomUUID(), // idempotency key, required
|
|
193
|
+
rail: "platform",
|
|
194
|
+
paymentMethodId: "pm_...", // a tokenized payment method
|
|
195
|
+
});
|
|
196
|
+
// purchase.status:
|
|
197
|
+
// "success" → captured, purchase.newBalance updated
|
|
198
|
+
// "requires_action" → 3-D Secure: run the client action with purchase.clientSecret,
|
|
199
|
+
// then call server.confirmPayment({ paymentIntentId })
|
|
200
|
+
// "pending_payment" → redirect the browser to purchase.paymentUrl (game rail)
|
|
201
|
+
```
|
|
202
|
+
`rail: "steam"` is rejected here (`WRONG_RAIL_ENDPOINT`) — Steam uses its own in-client flow. Reconcile with `server.getOrderDetails({ orderId })`. Most browser integrations should use hosted checkout instead.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Item purchase (spend game currency)
|
|
207
|
+
|
|
208
|
+
Spend the currency a player **already owns** to buy an in-game item. A balance debit — **no real money, no payment rail, no passkey** — server-side only. Amounts are in **game-currency units** (not USD).
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const item = await server.purchaseItem({
|
|
212
|
+
clientRequestId: crypto.randomUUID(), // idempotency key, unique per game
|
|
213
|
+
playerEmail: "p@example.com",
|
|
214
|
+
playerName: "P",
|
|
215
|
+
itemId: "sword_001",
|
|
216
|
+
itemName: "Legendary Sword",
|
|
217
|
+
itemQuantity: 1, // integer 1..1000
|
|
218
|
+
unitPrice: "100.00", // > 0 and ≤ 999999.99
|
|
219
|
+
totalPrice: "100.00", // must equal unitPrice × itemQuantity (±0.01)
|
|
220
|
+
// optional: playerPhone, itemDescription, itemCategory
|
|
221
|
+
});
|
|
222
|
+
// item.status === "success"
|
|
223
|
+
// item.newBalance / item.previousBalance / item.currencyName
|
|
224
|
+
// item.transactionId / item.orderId
|
|
225
|
+
// item.financialBreakdown { total_paid, developer_revenue, platform_fee }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- **Grant the item off the `item.purchased` webhook**, not just this response. INVO debits currency and records the purchase; **your game owns the item catalog and grants the item.** The webhook fires atomically with the spend.
|
|
229
|
+
- **Idempotent** on `clientRequestId` — a duplicate throws `409` (`err.isDuplicateRequest`).
|
|
230
|
+
- **Insufficient balance** throws `400` (`err.isInsufficientBalance`; `required_amount` + `current_balance` on `err.body`).
|
|
231
|
+
- **Throttled** calls throw `429` with `err.retryAfter` (seconds).
|
|
232
|
+
- Client-side validation (missing fields, quantity outside `1..1000`, bad price, total ≠ unit×qty) throws `INVALID_INPUT` **before** any network call.
|
|
233
|
+
- Fee split: **90% developer / 10% INVO** by default (per-partner override). Not guardian-gated.
|
|
234
|
+
|
|
235
|
+
**Companion reads:** `server.getItemPurchaseHistory({ playerEmail, limit?, offset? })` and `server.getItemOrderDetails({ orderId | transactionId | clientRequestId })` (pass **exactly one** id — use `clientRequestId` for recovery: "did this purchase complete?"). To walk the full history, `for await (const row of server.iterateItemPurchaseHistory({ playerEmail }))` pages automatically.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Player balance
|
|
240
|
+
|
|
241
|
+
Read a player's currency balances (server-side, game-secret) by email or player id:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const { balances, summary } = await server.getPlayerBalance({ playerEmail: "p@example.com" });
|
|
245
|
+
// balances: [{ currencyName, availableBalance, reservedBalance, totalBalance, currencySymbol, raw }]
|
|
246
|
+
// or: server.getPlayerBalance({ playerId: 12345 })
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Sends & transfers (move currency between players)
|
|
252
|
+
|
|
253
|
+
Move already-owned game currency from one player to another, authorized by the **sender's passkey** (or an SMS PIN if they aren't enrolled). **Send** and **transfer** are parallel flows:
|
|
254
|
+
|
|
255
|
+
| | Send | Transfer |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| Initiate (server) | `initiateSend` | `initiateTransfer` |
|
|
258
|
+
| Parties | `sender*` / `receiver*` + `receivingGameId` | `source*` / `target*` + `targetGameId` |
|
|
259
|
+
| Approve (browser) | `approveSend` | `approveTransfer` — also returns the sender's **claim code** |
|
|
260
|
+
| Recipient claim (browser) | `confirmReceiptSend` | `confirmReceiptTransfer` |
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
// 1. SERVER — initiate, then branch on how the sender must verify
|
|
264
|
+
const t = await server.initiateTransfer({
|
|
265
|
+
clientRequestId: crypto.randomUUID(),
|
|
266
|
+
sourcePlayerName: "P", sourcePlayerEmail: "p@example.com", sourcePlayerPhone: "+15555550100",
|
|
267
|
+
targetPlayerEmail: "q@example.com", targetPlayerPhone: "+15555550111",
|
|
268
|
+
targetGameId: 123456, amount: "50",
|
|
269
|
+
});
|
|
270
|
+
// (initiateSend uses sender*/receiver* + receivingGameId instead)
|
|
271
|
+
|
|
272
|
+
switch (true) {
|
|
273
|
+
case t.verificationMethod === "in_app": // sender is passkey-enrolled → approve in the browser
|
|
274
|
+
case t.verificationMethod === "sms": // not enrolled, a PIN was sent → show a PIN-entry fallback
|
|
275
|
+
case !!t.guardianApproval: // minor/guardian path (HTTP 202) → do NOT show the PIN UI
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 2. BROWSER — the sender approves with their passkey (give them t.transactionId + the player token)
|
|
279
|
+
const approved = await client.approveTransfer(t.transactionId); // or approveSend(...)
|
|
280
|
+
// approved.claimCode + approved.claimCodeExpiresAt (transfer only) — deliver if the recipient can't self-claim
|
|
281
|
+
|
|
282
|
+
// 3. BROWSER — the recipient claims with their passkey; fall back to the claim code if not enrolled
|
|
283
|
+
try {
|
|
284
|
+
await client.confirmReceiptTransfer(t.transactionId); // or confirmReceiptSend(...)
|
|
285
|
+
} catch (e) {
|
|
286
|
+
if (e instanceof InvoError && e.isReceiverNotEnrolled) {
|
|
287
|
+
// recipient has no passkey here → show claim-code entry using approved.claimCode
|
|
288
|
+
} else throw e;
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- **`verificationMethod`** (from initiate): `"in_app"` → passkey approve; `"sms"` → un-enrolled, PIN sent; `undefined` + `guardianApproval` → minor/guardian (HTTP 202), do **not** show the PIN UI.
|
|
293
|
+
- **Claim codes** are returned only by `approveTransfer`; they're the out-of-band fallback when the recipient isn't enrolled.
|
|
294
|
+
- **`err.isReceiverNotEnrolled`** on `confirmReceipt*` is the explicit signal to switch to claim-code entry.
|
|
295
|
+
- Transfer self-claim additionally requires `SDK_TRANSFER_CONFIRM_RECEIPT_ENABLED`; if it's off, surface the claim-code path.
|
|
296
|
+
|
|
297
|
+
**"You have X to collect"** — to render a collect badge, list a player's live, unclaimed inbound sends/transfers (the source of truth behind the `transfer.claim_pending` webhook):
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
const { inboundPending } = await server.getInboundPending({ playerEmail: "q@example.com" });
|
|
301
|
+
// each row: { transactionId, flow, amount, netAmount, sourceGame, toPhone, toIdentityId, claimCodeExpiresAt }
|
|
302
|
+
// match toPhone to the logged-in player (toIdentityId is null when the phone maps to >1 of your players)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Passkeys (enroll, approve, link)
|
|
308
|
+
|
|
309
|
+
Passkeys (WebAuthn) replace the SMS PIN for approving sends/transfers. The browser SDK wraps `navigator.credentials.create/get`, base64url encoding, challenge round-trips, and error mapping.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// Enroll once per user (no-op to call again — the backend excludes already-enrolled credentials)
|
|
313
|
+
await client.enrollPasskey();
|
|
314
|
+
|
|
315
|
+
// Interchangeable methods (optional): prove an already-enrolled method (e.g. the INVO app
|
|
316
|
+
// device key) to authorize adding THIS passkey, then enroll. Without this, enrolling a second
|
|
317
|
+
// method is blocked with ENROLLMENT_REQUIRES_PROOF.
|
|
318
|
+
await client.linkDevice(linkId); // → { status: "authorized" }
|
|
319
|
+
await client.enrollPasskey();
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
- **User verification is required** on every approve/claim (a missing-UV assertion fails closed).
|
|
323
|
+
- Challenges are single-use and bound to `{flow}:{transactionId}`.
|
|
324
|
+
- The SDK passes the backend's WebAuthn options through unchanged (it does not hard-code `pubKeyCredParams`/`timeout`/`attestation`).
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Webhooks
|
|
329
|
+
|
|
330
|
+
The synchronous responses are for UX; **reconcile and grant value off webhooks.** They're HMAC-signed; **dedupe on the `X-Invo-Idempotency-Key` header** (stable across retries/replays — *not* `X-Invo-Event-Id`, which changes per delivery).
|
|
331
|
+
|
|
332
|
+
### Verify a webhook (server)
|
|
333
|
+
|
|
334
|
+
The SDK ships the signature check so you don't hand-roll HMAC. Pass the **raw** request bytes (never a re-serialized object) and the `X-Invo-Signature` header:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { verifyWebhook, InvoError } from "@invonetwork/web-sdk/server";
|
|
338
|
+
|
|
339
|
+
// e.g. Express: app.post("/invo/webhooks", express.raw({ type: "application/json" }), handler)
|
|
340
|
+
function handler(req, res) {
|
|
341
|
+
let event;
|
|
342
|
+
try {
|
|
343
|
+
event = verifyWebhook(req.body, req.get("X-Invo-Signature"), process.env.INVO_WEBHOOK_SECRET!);
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return res.status(400).send((e as InvoError).code); // bad signature / stale / malformed
|
|
346
|
+
}
|
|
347
|
+
if (alreadyProcessed(req.get("X-Invo-Idempotency-Key"))) return res.status(200).end(); // dedupe
|
|
348
|
+
|
|
349
|
+
switch (event.event_type) {
|
|
350
|
+
case "purchase.completed": grantCurrency(event.data); break; // event.data is typed
|
|
351
|
+
case "item.purchased": grantItem(event.data); break;
|
|
352
|
+
// transfer.* , payout.status_changed , …
|
|
353
|
+
}
|
|
354
|
+
res.status(200).end(); // 2xx fast; offload slow work
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`verifyWebhook` does constant-time HMAC-SHA256 over `${t}.${rawBody}`, enforces a 5-minute replay window, and accepts an **array of secrets** during rotation (`verifyWebhook(body, sig, [oldSecret, newSecret])`). It returns a typed `InvoWebhookEvent` (discriminate on `event_type`) and throws `InvoError` (`WEBHOOK_SIGNATURE_INVALID` / `WEBHOOK_TIMESTAMP_EXPIRED` / `WEBHOOK_MALFORMED` / `WEBHOOK_SECRET_MISSING`) on any failure. **De-dupe yourself on `X-Invo-Idempotency-Key`** — the SDK verifies, it doesn't track delivery.
|
|
359
|
+
|
|
360
|
+
**Edge / serverless** (Cloudflare Workers, Deno, Vercel/Netlify Edge, Bun): `verifyWebhook` uses `node:crypto`, so use **`verifyWebhookAsync`** (Web Crypto) — same args/result, just `await` it — or the ready-made Fetch-API handler:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
import { createWebhookHandler } from "@invonetwork/web-sdk/server";
|
|
364
|
+
|
|
365
|
+
// Next.js App Router — app/invo/webhooks/route.ts
|
|
366
|
+
export const POST = createWebhookHandler({
|
|
367
|
+
secret: process.env.INVO_WEBHOOK_SECRET!,
|
|
368
|
+
onEvent: async (event, { idempotencyKey }) => {
|
|
369
|
+
// de-dupe on idempotencyKey, then grant value. Throw to return 500 (Invo retries).
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`createWebhookHandler` returns `(request: Request) => Promise<Response>` and runs in Next.js, Workers, Deno, Hono, and Bun. Bad signature → `400`; a throwing `onEvent` → `500`.
|
|
375
|
+
|
|
376
|
+
### Event types
|
|
377
|
+
|
|
378
|
+
| Event | Fires for | Use it to |
|
|
379
|
+
|---|---|---|
|
|
380
|
+
| `purchase.completed` | every currency-purchase rail | grant currency (payload: `transaction_id, order_id, player_email, identity_id, usd_amount, currency_amount, currency_name, new_balance, rail`) |
|
|
381
|
+
| `purchase.failed` / `purchase.disputed` | `platform` rail only | handle failures/disputes |
|
|
382
|
+
| `purchase.refunded` | `game` / `steam` rails | handle refunds |
|
|
383
|
+
| `item.purchased` | every item purchase | **grant the in-game item** (payload includes `transaction_id, order_id, player_email, identity_id, item_id, item_name, item_quantity, unit_price, total_price, currency_name, new_balance, fee_breakdown`) |
|
|
384
|
+
|
|
385
|
+
Don't block waiting on `purchase.failed` for the `game`/`steam` rails — they only emit `completed`/`refunded`. Reconcile off `*.completed` + the status endpoints.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## Resilience & observability
|
|
390
|
+
|
|
391
|
+
- **Automatic retries.** Transient failures — network errors/timeouts, `429` (honoring `retry_after`), and `5xx` — are retried with exponential backoff + jitter. Configure with `maxRetries` (default `2`, set `0` to disable) and `retryBaseDelayMs` (default `250`). Mutating calls carry idempotency keys, so retries are safe; set `maxRetries: 0` if you'd rather handle `429` abuse-throttles yourself.
|
|
392
|
+
- **Hooks.** Pass `hooks` to either client for tracing/metrics — all best-effort (a throwing hook never breaks a request):
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
new InvoServer({
|
|
396
|
+
/* … */,
|
|
397
|
+
hooks: {
|
|
398
|
+
onRequest: ({ method, url, attempt }) => {},
|
|
399
|
+
onResponse: ({ status, durationMs, requestId, attempt }) => {},
|
|
400
|
+
onError: ({ error, willRetry, attempt }) => {},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
> Note: hook payloads include the request `url`, which for some calls embeds a player email (e.g. balance-by-email). Tokens and the game secret are sent as headers and are **never** passed to hooks — but redact the `url` if you log hook payloads.
|
|
406
|
+
|
|
407
|
+
- **Request ids.** `InvoError.requestId` carries the backend request id (from `x-invo-request-id` / `x-request-id`) — quote it in support tickets.
|
|
408
|
+
- **Cancellation.** Every method takes an optional `{ signal }` (an `AbortSignal`) as its last argument — `server.getPlayerBalance({ playerEmail }, { signal })`. Aborting throws `InvoError` with `.code === "ABORTED"`, and an aborted call is never retried.
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Errors
|
|
413
|
+
|
|
414
|
+
Every failure throws **`InvoError`** with:
|
|
415
|
+
- `.code` — stable machine code when present (some txn-state errors have none — branch on `.message` for those)
|
|
416
|
+
- `.status` — HTTP status (`0` for client-side validation and network errors)
|
|
417
|
+
- `.message` — human-readable
|
|
418
|
+
- `.body` — the raw parsed response
|
|
419
|
+
|
|
420
|
+
Helpers:
|
|
421
|
+
|
|
422
|
+
| Helper | Meaning |
|
|
423
|
+
|---|---|
|
|
424
|
+
| `.isTokenExpired` | player token expired — re-mint + retry (automatic if `refreshToken` is set) |
|
|
425
|
+
| `.isReceiverNotEnrolled` | recipient has no passkey → switch to claim-code entry |
|
|
426
|
+
| `.isInsufficientBalance` | item purchase failed (400); `required_amount` + `current_balance` on `.body` |
|
|
427
|
+
| `.isDuplicateRequest` | idempotency-keyed request was a duplicate (409) |
|
|
428
|
+
| `.retryAfter` | seconds to back off on a 429 throttle |
|
|
429
|
+
|
|
430
|
+
Client-side guards (bad amount, missing idempotency key, `rail:"steam"` on `purchaseCurrency`, item validation) throw `InvoError` with `.status === 0` **before** any network call. Notable backend codes: `SDK_TOKEN_EXPIRED`, `TENANT_NOT_MIGRATED`, `WEBAUTHN_NOT_ENABLED_FOR_TENANT`, `WEBAUTHN_UV_REQUIRED`, `ENROLLMENT_REQUIRES_PROOF`, `WRONG_RAIL_ENDPOINT`, `flow_paused`.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { InvoError } from "@invonetwork/web-sdk"; // or "@invonetwork/web-sdk/server"
|
|
434
|
+
try {
|
|
435
|
+
await server.purchaseItem(/* … */);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
if (e instanceof InvoError && e.isInsufficientBalance) {
|
|
438
|
+
showTopUp(e.body); // { required_amount, current_balance }
|
|
439
|
+
} else throw e;
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## API reference
|
|
446
|
+
|
|
447
|
+
### `InvoServer` (`@invonetwork/web-sdk/server`)
|
|
448
|
+
|
|
449
|
+
| Method | Returns |
|
|
450
|
+
|---|---|
|
|
451
|
+
| `mintPlayerToken({ playerEmail })` | `{ token, expiresAt, identityId }` |
|
|
452
|
+
| `initiateSend(input)` | `{ transactionId, verificationMethod, guardianApproval?, raw }` |
|
|
453
|
+
| `initiateTransfer(input)` | `{ transactionId, verificationMethod, guardianApproval?, raw }` |
|
|
454
|
+
| `createCheckout(input)` | `{ sessionId, checkoutUrl, expiresAt, raw }` |
|
|
455
|
+
| `purchaseCurrency(input)` | `{ status, clientSecret?, paymentIntentId?, paymentUrl?, transactionId?, orderId?, newBalance?, raw }` |
|
|
456
|
+
| `confirmPayment({ paymentIntentId, orderId? })` | `{ status, transactionId?, newBalance?, raw }` |
|
|
457
|
+
| `getOrderDetails({ orderId? \| transactionId? })` | `{ order, financialSummary, statusTimeline, raw }` |
|
|
458
|
+
| `purchaseItem(input)` | `{ status, transactionId, orderId, newBalance, previousBalance, currencyName, financialBreakdown?, raw }` |
|
|
459
|
+
| `getItemPurchaseHistory({ playerEmail, limit?, offset? })` | `{ history, pagination, raw }` |
|
|
460
|
+
| `getItemOrderDetails({ orderId? \| transactionId? \| clientRequestId? })` | `{ order, financialSummary, statusTimeline, raw }` |
|
|
461
|
+
| `getPlayerBalance({ playerEmail? \| playerId? })` | `{ player, balances, summary, raw }` |
|
|
462
|
+
| `getInboundPending({ playerEmail? \| playerPhone? })` | `{ inboundPending, raw }` — live unclaimed inbound sends/transfers |
|
|
463
|
+
| `iterateItemPurchaseHistory({ playerEmail, pageSize? })` | async iterator over all history rows |
|
|
464
|
+
| `verifyWebhook(rawBody, signatureHeader, secret \| secrets, opts?)` | typed `InvoWebhookEvent` (throws on bad signature) |
|
|
465
|
+
| `verifyWebhookAsync(...)` | same as `verifyWebhook`, Web Crypto (edge/Workers/Deno/Bun) |
|
|
466
|
+
| `createWebhookHandler({ secret, onEvent })` | `(Request) => Promise<Response>` webhook route handler |
|
|
467
|
+
|
|
468
|
+
Every method also accepts an optional final `{ signal }` (`AbortSignal`) for cancellation.
|
|
469
|
+
|
|
470
|
+
### `InvoClient` (`@invonetwork/web-sdk`)
|
|
471
|
+
|
|
472
|
+
| Method | Returns |
|
|
473
|
+
|---|---|
|
|
474
|
+
| `enrollPasskey()` | `{ status, device, raw }` |
|
|
475
|
+
| `approveSend(txnId)` / `approveTransfer(txnId)` | `{ status, next, transactionId, claimCode?, claimCodeExpiresAt?, raw }` |
|
|
476
|
+
| `confirmReceiptSend(txnId)` / `confirmReceiptTransfer(txnId)` | `{ status, raw }` |
|
|
477
|
+
| `linkDevice(linkId)` | `{ status, raw }` |
|
|
478
|
+
|
|
479
|
+
Every method throws `InvoError` on failure. Full inline types ship with the package.
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## Scripts & versioning
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
npm run build # tsup → dist (ESM + CJS + d.ts)
|
|
487
|
+
npm run typecheck # tsc --noEmit
|
|
488
|
+
npm test # vitest
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
The package follows **semver**: patch = fixes, minor = additive surface, major = breaking changes (rare, with a migration note). The server contract is backward-compatible within a major, so an old pinned SDK keeps working. Pin a version and subscribe to release notes for security updates. See [`CHANGELOG.md`](CHANGELOG.md).
|
|
492
|
+
|
|
493
|
+
## License
|
|
494
|
+
|
|
495
|
+
Proprietary — © Invo Tech Inc. See [`LICENSE`](LICENSE).
|