@invonetwork/web-sdk 0.1.0 → 0.2.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 CHANGED
@@ -1,39 +1,58 @@
1
- # Changelog
2
-
3
- All notable changes to `@invonetwork/web-sdk` are documented here. This project follows
4
- [Semantic Versioning](https://semver.org/).
5
-
6
- ## [0.1.0] — 2026-06-30
7
-
8
- Initial scaffold.
9
-
10
- - `InvoServer` (`/server`): `mintPlayerToken`, `initiateSend`, `initiateTransfer`,
11
- `createCheckout`, `purchaseCurrency`, `confirmPayment`, `getOrderDetails`.
12
- - Client-side purchase guards (§4.7): USD amount `0 < x 999.99`, required
13
- `purchaseReference`, and `rail:"steam"` rejected before the network call
14
- (`INVALID_INPUT` / `MISSING_PURCHASE_REFERENCE` / `WRONG_RAIL_ENDPOINT`).
15
- - `InvoClient` (browser): `enrollPasskey`, `approveSend`/`approveTransfer`,
16
- `confirmReceiptSend`/`confirmReceiptTransfer`, and `linkDevice` for the
17
- interchangeable-methods flow (§4.6).
18
- - Optional `refreshToken` hook: transparently re-mints and retries once on
19
- `SDK_TOKEN_EXPIRED` (§11.2).
20
- - Shared: typed `InvoError`, isomorphic HTTP client, WebAuthn JSON⇄binary helpers.
21
- - Tests: HTTP layer, server request mapping + purchase guards, browser client
22
- flows (enroll/approve/link/token-refresh), and WebAuthn serialization.
23
- - Contracts extracted + auditor-verified against the INVO backend.
24
-
25
- ### Hardening (independent red-team pass)
26
- - **Guardian/minor `202` path no longer mismapped to `verificationMethod:"sms"`** —
27
- `initiateSend`/`initiateTransfer` now return `verificationMethod: undefined` and a
28
- `guardianApproval` block on the guardian path, so callers don't route into the
29
- PIN UI by mistake (§4.3).
30
- - `usdAmount` validation tightened: rejects non-plain-decimal strings
31
- (`"0x10"`, `"1e2"`, whitespace) and >2 decimal places before any network call.
32
- - Load-bearing response fields (`token`, `checkout_url`) now throw `INVALID_RESPONSE`
33
- instead of silently surfacing as empty strings.
34
- - `getOrderDetails` requires at least one of `orderId`/`transactionId`.
35
- - Token refresh now re-runs the **whole** passkey ceremony on `SDK_TOKEN_EXPIRED`
36
- (never replays a single-use assertion) and single-flights concurrent refreshes.
37
- - `baseUrl` must be `https://` (localhost exempt) so the token/secret can't travel
38
- in cleartext.
39
- - Published tarball excludes sourcemaps (no proprietary source shipped).
1
+ # Changelog
2
+
3
+ All notable changes to `@invonetwork/web-sdk` are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.2.0] — 2026-06-30
7
+
8
+ Adds **item purchase** (spend existing game currency on an in-game item, §4.8) — an
9
+ additive, server-only surface.
10
+
11
+ - `InvoServer` (`/server`): `purchaseItem`, `getItemPurchaseHistory`, `getItemOrderDetails`.
12
+ - Server-side, game-secret auth no passkey, no real money, no payment rail (it's a
13
+ balance debit). Grant the item off the `item.purchased` webhook.
14
+ - Client-side guards: required fields (trim-checked), `itemQuantity` integer `1..1000`,
15
+ prices `> 0` and `<= 999999.99` (magnitude-safe 2-decimal check), and
16
+ `totalPrice == unitPrice × itemQuantity (±0.01)` compared in integer cents.
17
+ - Load-bearing response fields (`transaction_id`, `order_id`) throw `INVALID_RESPONSE`
18
+ if missing on a 200.
19
+ - `InvoError` helpers: `isInsufficientBalance` (gated to 400; not the `429` throttle),
20
+ `isDuplicateRequest` (409), `retryAfter` (numeric or string `retry_after`); all
21
+ null-safe against non-JSON error bodies.
22
+ - Independently audited (2 agents) against handoff doc §4.8/§6/§8; contract verified,
23
+ error-classification edge cases fixed.
24
+
25
+ ## [0.1.0] 2026-06-30
26
+
27
+ Initial scaffold.
28
+
29
+ - `InvoServer` (`/server`): `mintPlayerToken`, `initiateSend`, `initiateTransfer`,
30
+ `createCheckout`, `purchaseCurrency`, `confirmPayment`, `getOrderDetails`.
31
+ - Client-side purchase guards (§4.7): USD amount `0 < x 999.99`, required
32
+ `purchaseReference`, and `rail:"steam"` rejected before the network call
33
+ (`INVALID_INPUT` / `MISSING_PURCHASE_REFERENCE` / `WRONG_RAIL_ENDPOINT`).
34
+ - `InvoClient` (browser): `enrollPasskey`, `approveSend`/`approveTransfer`,
35
+ `confirmReceiptSend`/`confirmReceiptTransfer`, and `linkDevice` for the
36
+ interchangeable-methods flow (§4.6).
37
+ - Optional `refreshToken` hook: transparently re-mints and retries once on
38
+ `SDK_TOKEN_EXPIRED` (§11.2).
39
+ - Shared: typed `InvoError`, isomorphic HTTP client, WebAuthn JSON⇄binary helpers.
40
+ - Tests: HTTP layer, server request mapping + purchase guards, browser client
41
+ flows (enroll/approve/link/token-refresh), and WebAuthn serialization.
42
+ - Contracts extracted + auditor-verified against the INVO backend.
43
+
44
+ ### Hardening (independent red-team pass)
45
+ - **Guardian/minor `202` path no longer mismapped to `verificationMethod:"sms"`** —
46
+ `initiateSend`/`initiateTransfer` now return `verificationMethod: undefined` and a
47
+ `guardianApproval` block on the guardian path, so callers don't route into the
48
+ PIN UI by mistake (§4.3).
49
+ - `usdAmount` validation tightened: rejects non-plain-decimal strings
50
+ (`"0x10"`, `"1e2"`, whitespace) and >2 decimal places before any network call.
51
+ - Load-bearing response fields (`token`, `checkout_url`) now throw `INVALID_RESPONSE`
52
+ instead of silently surfacing as empty strings.
53
+ - `getOrderDetails` requires at least one of `orderId`/`transactionId`.
54
+ - Token refresh now re-runs the **whole** passkey ceremony on `SDK_TOKEN_EXPIRED`
55
+ (never replays a single-use assertion) and single-flights concurrent refreshes.
56
+ - `baseUrl` must be `https://` (localhost exempt) so the token/secret can't travel
57
+ in cleartext.
58
+ - Published tarball excludes sourcemaps (no proprietary source shipped).
package/README.md CHANGED
@@ -1,142 +1,219 @@
1
- # @invonetwork/web-sdk
2
-
3
- INVO Web SDK for partner **web** platforms — **currency purchase** + **passkey (WebAuthn)** verification for cross-game **sends/transfers**. The web analog of the Unity/UE plugins.
4
-
5
- > **Status:** v0.1.0. The backend this wraps is **live** — the INVO-hosted checkout page, sessions, rails, and webhooks are deployed on sandbox + production, so you can build and test against sandbox today. Full, current API reference + flow docs: **https://docs.invo.network/docs/currency-purchase** and **https://docs.invo.network/docs/game-developer-integration**.
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm install @invonetwork/web-sdk
11
- ```
12
-
13
- > Published under the INVO-owned `@invonetwork` npm org. (Until the first `npm publish`, develop with `npm link` or a local install.)
14
-
15
- ## Two entry points (the game secret never reaches the browser)
16
-
17
- | Import | Runs | Holds | Does |
18
- |---|---|---|---|
19
- | `@invonetwork/web-sdk/server` | your server (Node ≥18) | the **game secret** | mint player token, initiate send/transfer, currency purchase |
20
- | `@invonetwork/web-sdk` | the browser | a short-lived **player token** | enroll passkey, approve, self-claim |
21
-
22
- ## Base URLs
23
-
24
- - Production: `https://invo.network`
25
- - Sandbox: `https://sandbox.invo.network/sandbox`
26
-
27
- `baseUrl` must be `https://` (the game secret and player token travel in request headers); `http://localhost` is allowed for local development only.
28
-
29
- ## Server (Node)
30
-
31
- ```ts
32
- import { InvoServer } from "@invonetwork/web-sdk/server";
33
-
34
- const invo = new InvoServer({
35
- gameSecret: process.env.INVO_GAME_SECRET!, // server-side only
36
- baseUrl: "https://sandbox.invo.network/sandbox",
37
- });
38
-
39
- // 1. mint a short-lived token for the browser
40
- const { token } = await invo.mintPlayerToken({ playerEmail: "p@example.com" });
41
-
42
- // 2a. buy currency — hosted checkout (recommended; INVO's page handles the processor)
43
- const { checkoutUrl } = await invo.createCheckout({
44
- playerEmail: "p@example.com",
45
- usdAmount: "20.00",
46
- rail: "platform", // optional: "platform" (card, default) | "game" | "steam"
47
- metadata: { yourOrderId: "ord_42" }, // echoed back on the purchase.completed webhook
48
- });
49
- // → open checkoutUrl in a WebView/redirect or an iframe (see "Consuming the checkout")
50
-
51
- // 2b. or initiate a send; inspect verificationMethod
52
- const send = await invo.initiateSend({
53
- clientRequestId: crypto.randomUUID(),
54
- senderPlayerName: "P", senderPlayerEmail: "p@example.com", senderPlayerPhone: "+15555550100",
55
- receiverPlayerEmail: "q@example.com", receiverPlayerPhone: "+15555550111",
56
- receivingGameId: 123456, amount: "50",
57
- });
58
- if (send.verificationMethod === "in_app") {
59
- // hand send.transactionId + the player token to the browser to approve via passkey
60
- } else if (send.verificationMethod === "sms") {
61
- // sender not enrolled — fall back to the SMS-PIN UI
62
- } else if (send.guardianApproval) {
63
- // minor/guardian path (HTTP 202): pending guardian approval — do NOT show the PIN UI
64
- }
65
- ```
66
-
67
- ## Browser
68
-
69
- ```ts
70
- import { InvoClient } from "@invonetwork/web-sdk";
71
-
72
- const invo = new InvoClient({
73
- token,
74
- baseUrl: "https://sandbox.invo.network/sandbox",
75
- // Optional: player tokens live ~15 min. If a call fails with SDK_TOKEN_EXPIRED,
76
- // the SDK calls this once for a fresh token (re-minted by your backend) and
77
- // retries the request transparently.
78
- refreshToken: () => fetch("/invo/token").then((r) => r.json()).then((j) => j.token),
79
- });
80
-
81
- await invo.enrollPasskey(); // once per user
82
- await invo.approveSend(transactionId); // or approveTransfer(...)
83
- await invo.confirmReceiptSend(transactionId); // recipient self-claim
84
-
85
- // Interchangeable methods (optional): prove an already-enrolled method (e.g. the
86
- // INVO app device key) to authorize adding this passkey, then enroll it.
87
- await invo.linkDevice(linkId); // → { status: "authorized" }
88
- await invo.enrollPasskey();
89
- ```
90
-
91
- Currency purchase has **no browser SDK method** — the server mints `checkoutUrl`; the browser just opens it.
92
-
93
- ## Consuming the checkout (browser)
94
-
95
- `createCheckout` returns a `checkoutUrl` (15-min, single-use). Open it either way:
96
-
97
- - **WebView / full-page redirect** (works everywhere, no setup): on success the page redirects to your `successUrl`.
98
- - **Embedded `<iframe>`** — **works by default** (any https origin may frame it; no allow-listing): the page does *not* redirect your top window — listen for the `INVO_CHECKOUT_COMPLETE` postMessage.
99
-
100
- ```ts
101
- const iframe = document.createElement("iframe");
102
- iframe.src = checkoutUrl;
103
- iframe.style.cssText = "width:440px;height:720px;border:0";
104
- document.body.appendChild(iframe);
105
-
106
- window.addEventListener("message", (e) => {
107
- if (e.origin !== "https://invo.network") return; // sandbox: "https://sandbox.invo.network"
108
- if (e.data?.type === "INVO_CHECKOUT_COMPLETE") {
109
- // UX hint only (unsigned). data = { status:'success', new_balance, currency_name, transaction_id }
110
- refreshBalanceOptimistically(e.data.data.new_balance);
111
- }
112
- });
113
- ```
114
-
115
- The hosted page handles **card entry, saved cards, and 3-D Secure** (including a top-level break-out when embedded). You build no payment UI and never touch card data.
116
-
117
- **Completion is authoritative via the `purchase.completed` webhook** (HMAC-signed). **Dedupe on `X-Invo-Idempotency-Key`** (stable across retries/replays — *not* `X-Invo-Event-Id`, which changes per delivery). Treat `INVO_CHECKOUT_COMPLETE` as a UX hint; grant currency off the webhook (or re-read the balance).
118
-
119
- ## Payment rails (neutral names)
120
-
121
- The `rail` passed to `createCheckout` selects the in-page experience — all branded as INVO, no visible redirect:
122
- - `"platform"` (default) — card checkout on the hosted page.
123
- - `"game"` — regional / game-store methods, embedded in-page.
124
- - `"steam"` — Steam titles use the dedicated Steam flow; a `steam` session only shows an in-client hand-off, it doesn't drive the purchase.
125
-
126
- Provider/processor names are an internal backend detail and never appear in the API. (`purchaseCurrency()` on the server is an advanced direct path for partners who tokenize cards themselves — most integrations should use `createCheckout`.)
127
-
128
- ## Errors
129
-
130
- Every failure throws `InvoError` with `.code` (when present), `.status`, `.message`, `.body`. Branch on `.code`; for the handful of state errors with no `code` (e.g. `receiver_not_enrolled_use_claim_code`), use `.message` / the `.isReceiverNotEnrolled` helper. `.isTokenExpired` flags an expired token — if you pass `refreshToken` to `InvoClient` the SDK handles this for you (one re-mint + retry); otherwise re-mint server-side and retry. Client-side input guards (e.g. a USD amount outside `0 < x ≤ 999.99`, a missing `purchaseReference`, or `rail:"steam"` on `purchaseCurrency`) also throw `InvoError` with `.status === 0` before any network call.
131
-
132
- ## Scripts
133
-
134
- ```bash
135
- npm run build # tsup dist (ESM + CJS + d.ts)
136
- npm run typecheck # tsc --noEmit
137
- npm test # vitest
138
- ```
139
-
140
- ## License
141
-
142
- Proprietary © Invo Tech Inc. See `LICENSE` (the partner-distribution license is a business decision; swap to MIT/Apache-2.0 if preferred).
1
+ # @invonetwork/web-sdk
2
+
3
+ INVO Web SDK for partner **web** platforms — **currency purchase** + **passkey (WebAuthn)** verification for cross-game **sends/transfers**. The web analog of the Unity/UE plugins.
4
+
5
+ > **Status:** v0.1.0. The backend this wraps is **live** — the INVO-hosted checkout page, sessions, rails, and webhooks are deployed on sandbox + production, so you can build and test against sandbox today. Full, current API reference + flow docs: **https://docs.invo.network/docs/currency-purchase** and **https://docs.invo.network/docs/game-developer-integration**.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @invonetwork/web-sdk
11
+ ```
12
+
13
+ > Published under the INVO-owned `@invonetwork` npm org. (Until the first `npm publish`, develop with `npm link` or a local install.)
14
+
15
+ ## Two entry points (the game secret never reaches the browser)
16
+
17
+ | Import | Runs | Holds | Does |
18
+ |---|---|---|---|
19
+ | `@invonetwork/web-sdk/server` | your server (Node ≥18) | the **game secret** | mint player token, initiate send/transfer, currency purchase, item purchase |
20
+ | `@invonetwork/web-sdk` | the browser | a short-lived **player token** | enroll passkey, approve, self-claim |
21
+
22
+ ## Base URLs
23
+
24
+ - Production: `https://invo.network`
25
+ - Sandbox: `https://sandbox.invo.network/sandbox`
26
+
27
+ `baseUrl` must be `https://` (the game secret and player token travel in request headers); `http://localhost` is allowed for local development only.
28
+
29
+ ## Server (Node)
30
+
31
+ ```ts
32
+ import { InvoServer } from "@invonetwork/web-sdk/server";
33
+
34
+ const invo = new InvoServer({
35
+ gameSecret: process.env.INVO_GAME_SECRET!, // server-side only
36
+ baseUrl: "https://sandbox.invo.network/sandbox",
37
+ });
38
+
39
+ // 1. mint a short-lived token for the browser
40
+ const { token } = await invo.mintPlayerToken({ playerEmail: "p@example.com" });
41
+
42
+ // 2a. buy currency — hosted checkout (recommended; INVO's page handles the processor)
43
+ const { checkoutUrl } = await invo.createCheckout({
44
+ playerEmail: "p@example.com",
45
+ usdAmount: "20.00",
46
+ rail: "platform", // optional: "platform" (card, default) | "game" | "steam"
47
+ metadata: { yourOrderId: "ord_42" }, // echoed back on the purchase.completed webhook
48
+ });
49
+ // → open checkoutUrl in a WebView/redirect or an iframe (see "Consuming the checkout")
50
+
51
+ // 2b. or initiate a send; inspect verificationMethod
52
+ const send = await invo.initiateSend({
53
+ clientRequestId: crypto.randomUUID(),
54
+ senderPlayerName: "P", senderPlayerEmail: "p@example.com", senderPlayerPhone: "+15555550100",
55
+ receiverPlayerEmail: "q@example.com", receiverPlayerPhone: "+15555550111",
56
+ receivingGameId: 123456, amount: "50",
57
+ });
58
+ if (send.verificationMethod === "in_app") {
59
+ // hand send.transactionId + the player token to the browser to approve via passkey
60
+ } else if (send.verificationMethod === "sms") {
61
+ // sender not enrolled — fall back to the SMS-PIN UI
62
+ } else if (send.guardianApproval) {
63
+ // minor/guardian path (HTTP 202): pending guardian approval — do NOT show the PIN UI
64
+ }
65
+ ```
66
+
67
+ ## Browser
68
+
69
+ ```ts
70
+ import { InvoClient } from "@invonetwork/web-sdk";
71
+
72
+ const invo = new InvoClient({
73
+ token,
74
+ baseUrl: "https://sandbox.invo.network/sandbox",
75
+ // Optional: player tokens live ~15 min. If a call fails with SDK_TOKEN_EXPIRED,
76
+ // the SDK calls this once for a fresh token (re-minted by your backend) and
77
+ // retries the request transparently.
78
+ refreshToken: () => fetch("/invo/token").then((r) => r.json()).then((j) => j.token),
79
+ });
80
+
81
+ await invo.enrollPasskey(); // once per user
82
+ await invo.approveSend(transactionId); // or approveTransfer(...)
83
+ await invo.confirmReceiptSend(transactionId); // recipient self-claim
84
+
85
+ // Interchangeable methods (optional): prove an already-enrolled method (e.g. the
86
+ // INVO app device key) to authorize adding this passkey, then enroll it.
87
+ await invo.linkDevice(linkId); // → { status: "authorized" }
88
+ await invo.enrollPasskey();
89
+ ```
90
+
91
+ Currency purchase has **no browser SDK method** — the server mints `checkoutUrl`; the browser just opens it.
92
+
93
+ ## Consuming the checkout (browser)
94
+
95
+ `createCheckout` returns a `checkoutUrl` (15-min, single-use). Open it either way:
96
+
97
+ - **WebView / full-page redirect** (works everywhere, no setup): on success the page redirects to your `successUrl`.
98
+ - **Embedded `<iframe>`** — **works by default** (any https origin may frame it; no allow-listing): the page does *not* redirect your top window — listen for the `INVO_CHECKOUT_COMPLETE` postMessage.
99
+
100
+ ```ts
101
+ const iframe = document.createElement("iframe");
102
+ iframe.src = checkoutUrl;
103
+ iframe.style.cssText = "width:440px;height:720px;border:0";
104
+ document.body.appendChild(iframe);
105
+
106
+ window.addEventListener("message", (e) => {
107
+ if (e.origin !== "https://invo.network") return; // sandbox: "https://sandbox.invo.network"
108
+ if (e.data?.type === "INVO_CHECKOUT_COMPLETE") {
109
+ // UX hint only (unsigned). data = { status:'success', new_balance, currency_name, transaction_id }
110
+ refreshBalanceOptimistically(e.data.data.new_balance);
111
+ }
112
+ });
113
+ ```
114
+
115
+ The hosted page handles **card entry, saved cards, and 3-D Secure** (including a top-level break-out when embedded). You build no payment UI and never touch card data.
116
+
117
+ **Completion is authoritative via the `purchase.completed` webhook** (HMAC-signed). **Dedupe on `X-Invo-Idempotency-Key`** (stable across retries/replays — *not* `X-Invo-Event-Id`, which changes per delivery). Treat `INVO_CHECKOUT_COMPLETE` as a UX hint; grant currency off the webhook (or re-read the balance).
118
+
119
+ ## Payment rails (neutral names)
120
+
121
+ The `rail` passed to `createCheckout` selects the in-page experience — all branded as INVO, no visible redirect:
122
+ - `"platform"` (default) — card checkout on the hosted page.
123
+ - `"game"` — regional / game-store methods, embedded in-page.
124
+ - `"steam"` — Steam titles use the dedicated Steam flow; a `steam` session only shows an in-client hand-off, it doesn't drive the purchase.
125
+
126
+ Provider/processor names are an internal backend detail and never appear in the API. (`purchaseCurrency()` on the server is an advanced direct path for partners who tokenize cards themselves — most integrations should use `createCheckout`.)
127
+
128
+ ## Sends & transfers (move existing game currency, no real money)
129
+
130
+ Distinct from *purchase* (which brings real money in), **sends** and **transfers** move
131
+ already-owned game currency from one player to another and are authorized by the
132
+ sender's **passkey** (or an SMS PIN if they aren't enrolled). The two are parallel
133
+ flows with the same shape:
134
+
135
+ | | Send | Transfer |
136
+ |---|---|---|
137
+ | Initiate (server) | `initiateSend` → `/api/currency-sends/initiate-send` | `initiateTransfer` → `/api/transfers/initiate-transfer` |
138
+ | Parties | `sender*` / `receiver*`, `receivingGameId` | `source*` / `target*`, `targetGameId` |
139
+ | Approve (browser) | `approveSend` | `approveTransfer` (also returns the sender's **claim code**) |
140
+ | Recipient claim (browser) | `confirmReceiptSend` | `confirmReceiptTransfer` |
141
+
142
+ ### End-to-end (transfer shown; send is identical with `*Send` methods)
143
+
144
+ ```ts
145
+ // 1. SERVER — initiate, then branch on how the sender must verify
146
+ const t = await invo.initiateTransfer({
147
+ clientRequestId: crypto.randomUUID(),
148
+ sourcePlayerName: "P", sourcePlayerEmail: "p@example.com", sourcePlayerPhone: "+15555550100",
149
+ targetPlayerEmail: "q@example.com", targetPlayerPhone: "+15555550111",
150
+ targetGameId: 123456, amount: "50",
151
+ });
152
+ // t.verificationMethod: "in_app" (passkey) | "sms" (PIN fallback) | undefined (+ t.guardianApproval on the minor/guardian path)
153
+
154
+ // 2. BROWSER — the sender approves with their passkey (hand them t.transactionId + the player token)
155
+ const approved = await invo.approveTransfer(t.transactionId);
156
+ // approved.claimCode + approved.claimCodeExpiresAt — deliver to the recipient if they can't self-claim
157
+
158
+ // 3. BROWSER — the recipient claims. If they have a passkey, self-claim; else fall back to the claim code.
159
+ try {
160
+ await invo.confirmReceiptTransfer(t.transactionId);
161
+ } catch (e) {
162
+ if (e instanceof InvoError && e.isReceiverNotEnrolled) {
163
+ // recipient has no passkey here → show a claim-code entry UI using approved.claimCode
164
+ } else throw e;
165
+ }
166
+ ```
167
+
168
+ - **`verificationMethod`** comes from initiate: `"in_app"` → passkey approve; `"sms"` → the sender wasn't enrolled and a PIN was sent (build a PIN-entry fallback); `undefined` with `guardianApproval` → minor/guardian path (do **not** show the PIN UI).
169
+ - **Claim codes** are only returned by `approveTransfer` (transfer flow). They're the out-of-band fallback when the recipient isn't passkey-enrolled.
170
+ - **`isReceiverNotEnrolled`** on the `confirmReceipt*` error is the explicit signal to switch to claim-code entry.
171
+ - Transfer self-claim additionally requires the tenant's `SDK_TRANSFER_CONFIRM_RECEIPT_ENABLED` flag; if it's off, surface the claim-code path instead.
172
+
173
+ ## Item purchase (spend game currency on an in-game item)
174
+
175
+ Distinct from *currency purchase* (real money **in**): an **item purchase spends the
176
+ currency a player already owns** to buy an in-game item. It's a balance debit — **no
177
+ real money, no payment rail, no passkey** — authenticated server-side by the game
178
+ secret, so it lives on the **`/server`** entry. Amounts are in **game-currency units**
179
+ (not USD).
180
+
181
+ ```ts
182
+ const item = await invo.purchaseItem({
183
+ clientRequestId: crypto.randomUUID(), // idempotency key, unique per game
184
+ playerEmail: "p@example.com",
185
+ playerName: "P",
186
+ itemId: "sword_001",
187
+ itemName: "Legendary Sword",
188
+ itemQuantity: 1,
189
+ unitPrice: "100.00",
190
+ totalPrice: "100.00", // must equal unitPrice × itemQuantity (±0.01)
191
+ // optional: playerPhone, itemDescription, itemCategory
192
+ });
193
+ // item.status === "success", item.newBalance (post-spend), item.previousBalance,
194
+ // item.transactionId, item.orderId, item.financialBreakdown { total_paid, developer_revenue, platform_fee }
195
+ ```
196
+
197
+ - **Grant the item off the `item.purchased` webhook**, not just this response — the webhook fires atomically with the spend (dedupe on `X-Invo-Idempotency-Key`). INVO debits currency; your game owns the catalog and grants the item.
198
+ - **Idempotent** on `clientRequestId` — a duplicate throws a `409` `InvoError` (`err.isDuplicateRequest`).
199
+ - **Insufficient balance** throws a `400` (`err.isInsufficientBalance`; `required_amount` + `current_balance` on `err.body`).
200
+ - **Throttles** return `429` with `err.retryAfter` (seconds). Validation (missing fields, quantity outside `1..1000`, price `≤0`/`>999999.99`/non-decimal, total ≠ unit×qty) throws `INVALID_INPUT` **before** any network call.
201
+ - Fee split is **90% developer / 10% INVO** by default (per-partner override). Not guardian-gated (the currency was already guardian-approved when it entered the wallet).
202
+
203
+ Companion reads (server): `invo.getItemPurchaseHistory({ playerEmail, limit?, offset? })` and `invo.getItemOrderDetails({ orderId | transactionId | clientRequestId })` — pass **exactly one** id; use `clientRequestId` for recovery ("did this purchase complete?").
204
+
205
+ ## Errors
206
+
207
+ Every failure throws `InvoError` with `.code` (when present), `.status`, `.message`, `.body`. Branch on `.code`; for the handful of state errors with no `code` (e.g. `receiver_not_enrolled_use_claim_code`), use `.message` / the `.isReceiverNotEnrolled` helper. `.isTokenExpired` flags an expired token — if you pass `refreshToken` to `InvoClient` the SDK handles this for you (one re-mint + retry); otherwise re-mint server-side and retry. Client-side input guards (e.g. a USD amount outside `0 < x ≤ 999.99`, a missing `purchaseReference`, or `rail:"steam"` on `purchaseCurrency`) also throw `InvoError` with `.status === 0` before any network call.
208
+
209
+ ## Scripts
210
+
211
+ ```bash
212
+ npm run build # tsup → dist (ESM + CJS + d.ts)
213
+ npm run typecheck # tsc --noEmit
214
+ npm test # vitest
215
+ ```
216
+
217
+ ## License
218
+
219
+ Proprietary — © Invo Tech Inc. See `LICENSE` (the partner-distribution license is a business decision; swap to MIT/Apache-2.0 if preferred).
@@ -16,6 +16,32 @@ var InvoError = class _InvoError extends Error {
16
16
  get isTokenExpired() {
17
17
  return this.code === "SDK_TOKEN_EXPIRED";
18
18
  }
19
+ /**
20
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
21
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
22
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
23
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
24
+ */
25
+ get isInsufficientBalance() {
26
+ if (this.status !== 400) return false;
27
+ const b = this.bodyObject();
28
+ return this.code === "INSUFFICIENT_BALANCE" || "required_amount" in b || /insufficient[_ ]balance/i.test(this.message);
29
+ }
30
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
31
+ get isDuplicateRequest() {
32
+ return this.code === "DUPLICATE_REQUEST" || this.status === 409 && /duplicate/i.test(this.message);
33
+ }
34
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
35
+ get retryAfter() {
36
+ const v = this.bodyObject()["retry_after"];
37
+ const n = typeof v === "string" ? Number(v) : v;
38
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : void 0;
39
+ }
40
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
41
+ * The `in` operator throws on primitives, so callers must go through this. */
42
+ bodyObject() {
43
+ return this.body && typeof this.body === "object" ? this.body : {};
44
+ }
19
45
  };
20
46
  function errorFromResponse(status, body) {
21
47
  let message = `INVO request failed (HTTP ${status})`;
@@ -117,5 +143,5 @@ var Http = class {
117
143
  };
118
144
 
119
145
  export { Http, InvoError, assertSecureBaseUrl };
120
- //# sourceMappingURL=chunk-KUQVVH2P.js.map
121
- //# sourceMappingURL=chunk-KUQVVH2P.js.map
146
+ //# sourceMappingURL=chunk-A44O4KC3.js.map
147
+ //# sourceMappingURL=chunk-A44O4KC3.js.map
@@ -107,6 +107,47 @@ interface PurchaseResult {
107
107
  newBalance?: string | number | null;
108
108
  raw: Record<string, unknown>;
109
109
  }
110
+ interface PurchaseItemInput {
111
+ /** Idempotency key, unique per game. Required. A duplicate returns 409. */
112
+ clientRequestId: string;
113
+ playerEmail: string;
114
+ playerName: string;
115
+ itemId: string;
116
+ itemName: string;
117
+ /** Integer quantity, 1..1000. */
118
+ itemQuantity: number;
119
+ /** Price PER UNIT, in game-currency units (not USD). > 0 and <= 999999.99. */
120
+ unitPrice: string | number;
121
+ /** Must equal unitPrice × itemQuantity (±0.01). > 0 and <= 999999.99. */
122
+ totalPrice: string | number;
123
+ playerPhone?: string;
124
+ itemDescription?: string;
125
+ itemCategory?: string;
126
+ }
127
+ interface PurchaseItemResult {
128
+ /** "success" on a completed spend. */
129
+ status: string;
130
+ transactionId: string;
131
+ orderId: string;
132
+ /** Canonical balance AFTER the spend (game-currency units). */
133
+ newBalance: string | number | null;
134
+ previousBalance: string | number | null;
135
+ currencyName: string;
136
+ /** { total_paid, developer_revenue, platform_fee } when present. */
137
+ financialBreakdown?: Record<string, unknown>;
138
+ raw: Record<string, unknown>;
139
+ }
140
+ interface ItemHistoryQuery {
141
+ playerEmail: string;
142
+ limit?: number;
143
+ offset?: number;
144
+ }
145
+ /** Look up an item order by EXACTLY ONE of these. */
146
+ interface ItemOrderQuery {
147
+ orderId?: string;
148
+ transactionId?: string;
149
+ clientRequestId?: string;
150
+ }
110
151
  interface ApproveResult {
111
152
  status: string;
112
153
  next: string;
@@ -151,6 +192,20 @@ declare class InvoError extends Error {
151
192
  get isReceiverNotEnrolled(): boolean;
152
193
  /** True if the session/SDK token has expired and the caller should re-mint + retry. */
153
194
  get isTokenExpired(): boolean;
195
+ /**
196
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
197
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
198
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
199
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
200
+ */
201
+ get isInsufficientBalance(): boolean;
202
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
203
+ get isDuplicateRequest(): boolean;
204
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
205
+ get retryAfter(): number | undefined;
206
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
207
+ * The `in` operator throws on primitives, so callers must go through this. */
208
+ private bodyObject;
154
209
  }
155
210
 
156
- export { type ApproveResult as A, type ClientConfig as C, InvoError as I, type LinkDeviceResult as L, type PlayerToken as P, type Rail as R, type ServerConfig as S, type VerificationMethod as V, type ConfirmReceiptResult as a, type InitiateSendInput as b, type InitiateResult as c, type InitiateTransferInput as d, type CreateCheckoutInput as e, type CreateCheckoutResult as f, type PurchaseInput as g, type PurchaseResult as h };
211
+ export { type ApproveResult as A, type ClientConfig as C, InvoError as I, type LinkDeviceResult as L, type PlayerToken as P, type Rail as R, type ServerConfig as S, type VerificationMethod as V, type ConfirmReceiptResult as a, type InitiateSendInput as b, type InitiateResult as c, type InitiateTransferInput as d, type CreateCheckoutInput as e, type CreateCheckoutResult as f, type PurchaseInput as g, type PurchaseResult as h, type PurchaseItemInput as i, type PurchaseItemResult as j, type ItemHistoryQuery as k, type ItemOrderQuery as l };
@@ -107,6 +107,47 @@ interface PurchaseResult {
107
107
  newBalance?: string | number | null;
108
108
  raw: Record<string, unknown>;
109
109
  }
110
+ interface PurchaseItemInput {
111
+ /** Idempotency key, unique per game. Required. A duplicate returns 409. */
112
+ clientRequestId: string;
113
+ playerEmail: string;
114
+ playerName: string;
115
+ itemId: string;
116
+ itemName: string;
117
+ /** Integer quantity, 1..1000. */
118
+ itemQuantity: number;
119
+ /** Price PER UNIT, in game-currency units (not USD). > 0 and <= 999999.99. */
120
+ unitPrice: string | number;
121
+ /** Must equal unitPrice × itemQuantity (±0.01). > 0 and <= 999999.99. */
122
+ totalPrice: string | number;
123
+ playerPhone?: string;
124
+ itemDescription?: string;
125
+ itemCategory?: string;
126
+ }
127
+ interface PurchaseItemResult {
128
+ /** "success" on a completed spend. */
129
+ status: string;
130
+ transactionId: string;
131
+ orderId: string;
132
+ /** Canonical balance AFTER the spend (game-currency units). */
133
+ newBalance: string | number | null;
134
+ previousBalance: string | number | null;
135
+ currencyName: string;
136
+ /** { total_paid, developer_revenue, platform_fee } when present. */
137
+ financialBreakdown?: Record<string, unknown>;
138
+ raw: Record<string, unknown>;
139
+ }
140
+ interface ItemHistoryQuery {
141
+ playerEmail: string;
142
+ limit?: number;
143
+ offset?: number;
144
+ }
145
+ /** Look up an item order by EXACTLY ONE of these. */
146
+ interface ItemOrderQuery {
147
+ orderId?: string;
148
+ transactionId?: string;
149
+ clientRequestId?: string;
150
+ }
110
151
  interface ApproveResult {
111
152
  status: string;
112
153
  next: string;
@@ -151,6 +192,20 @@ declare class InvoError extends Error {
151
192
  get isReceiverNotEnrolled(): boolean;
152
193
  /** True if the session/SDK token has expired and the caller should re-mint + retry. */
153
194
  get isTokenExpired(): boolean;
195
+ /**
196
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
197
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
198
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
199
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
200
+ */
201
+ get isInsufficientBalance(): boolean;
202
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
203
+ get isDuplicateRequest(): boolean;
204
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
205
+ get retryAfter(): number | undefined;
206
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
207
+ * The `in` operator throws on primitives, so callers must go through this. */
208
+ private bodyObject;
154
209
  }
155
210
 
156
- export { type ApproveResult as A, type ClientConfig as C, InvoError as I, type LinkDeviceResult as L, type PlayerToken as P, type Rail as R, type ServerConfig as S, type VerificationMethod as V, type ConfirmReceiptResult as a, type InitiateSendInput as b, type InitiateResult as c, type InitiateTransferInput as d, type CreateCheckoutInput as e, type CreateCheckoutResult as f, type PurchaseInput as g, type PurchaseResult as h };
211
+ export { type ApproveResult as A, type ClientConfig as C, InvoError as I, type LinkDeviceResult as L, type PlayerToken as P, type Rail as R, type ServerConfig as S, type VerificationMethod as V, type ConfirmReceiptResult as a, type InitiateSendInput as b, type InitiateResult as c, type InitiateTransferInput as d, type CreateCheckoutInput as e, type CreateCheckoutResult as f, type PurchaseInput as g, type PurchaseResult as h, type PurchaseItemInput as i, type PurchaseItemResult as j, type ItemHistoryQuery as k, type ItemOrderQuery as l };
package/dist/index.cjs CHANGED
@@ -18,6 +18,32 @@ var InvoError = class _InvoError extends Error {
18
18
  get isTokenExpired() {
19
19
  return this.code === "SDK_TOKEN_EXPIRED";
20
20
  }
21
+ /**
22
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
23
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
24
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
25
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
26
+ */
27
+ get isInsufficientBalance() {
28
+ if (this.status !== 400) return false;
29
+ const b = this.bodyObject();
30
+ return this.code === "INSUFFICIENT_BALANCE" || "required_amount" in b || /insufficient[_ ]balance/i.test(this.message);
31
+ }
32
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
33
+ get isDuplicateRequest() {
34
+ return this.code === "DUPLICATE_REQUEST" || this.status === 409 && /duplicate/i.test(this.message);
35
+ }
36
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
37
+ get retryAfter() {
38
+ const v = this.bodyObject()["retry_after"];
39
+ const n = typeof v === "string" ? Number(v) : v;
40
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : void 0;
41
+ }
42
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
43
+ * The `in` operator throws on primitives, so callers must go through this. */
44
+ bodyObject() {
45
+ return this.body && typeof this.body === "object" ? this.body : {};
46
+ }
21
47
  };
22
48
  function errorFromResponse(status, body) {
23
49
  let message = `INVO request failed (HTTP ${status})`;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-B7rVID2r.cjs';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.cjs';
1
+ import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-DV5QsftP.cjs';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.cjs';
3
3
 
4
4
  declare class InvoClient {
5
5
  private readonly http;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-B7rVID2r.js';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.js';
1
+ import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-DV5QsftP.js';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.js';
3
3
 
4
4
  declare class InvoClient {
5
5
  private readonly http;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { assertSecureBaseUrl, Http, InvoError } from './chunk-KUQVVH2P.js';
2
- export { InvoError } from './chunk-KUQVVH2P.js';
1
+ import { assertSecureBaseUrl, Http, InvoError } from './chunk-A44O4KC3.js';
2
+ export { InvoError } from './chunk-A44O4KC3.js';
3
3
 
4
4
  // src/shared/webauthn.ts
5
5
  function b64urlToBuffer(value) {
package/dist/server.cjs CHANGED
@@ -18,6 +18,32 @@ var InvoError = class _InvoError extends Error {
18
18
  get isTokenExpired() {
19
19
  return this.code === "SDK_TOKEN_EXPIRED";
20
20
  }
21
+ /**
22
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
23
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
24
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
25
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
26
+ */
27
+ get isInsufficientBalance() {
28
+ if (this.status !== 400) return false;
29
+ const b = this.bodyObject();
30
+ return this.code === "INSUFFICIENT_BALANCE" || "required_amount" in b || /insufficient[_ ]balance/i.test(this.message);
31
+ }
32
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
33
+ get isDuplicateRequest() {
34
+ return this.code === "DUPLICATE_REQUEST" || this.status === 409 && /duplicate/i.test(this.message);
35
+ }
36
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
37
+ get retryAfter() {
38
+ const v = this.bodyObject()["retry_after"];
39
+ const n = typeof v === "string" ? Number(v) : v;
40
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : void 0;
41
+ }
42
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
43
+ * The `in` operator throws on primitives, so callers must go through this. */
44
+ bodyObject() {
45
+ return this.body && typeof this.body === "object" ? this.body : {};
46
+ }
21
47
  };
22
48
  function errorFromResponse(status, body) {
23
49
  let message = `INVO request failed (HTTP ${status})`;
@@ -121,29 +147,35 @@ var Http = class {
121
147
  // src/server.ts
122
148
  var DEFAULT_UA = "invonetwork-web-sdk/0.1.0 (+https://invo.network)";
123
149
  var MAX_USD_AMOUNT = 999.99;
124
- function invalidAmount(usdAmount, why) {
150
+ var MAX_ITEM_PRICE = 999999.99;
151
+ function invalidInput(label, value, why) {
125
152
  return new InvoError({
126
- message: `usdAmount ${why} (got ${JSON.stringify(usdAmount)}).`,
153
+ message: `${label} ${why} (got ${JSON.stringify(value)}).`,
127
154
  code: "INVALID_INPUT",
128
155
  status: 0
129
156
  });
130
157
  }
131
- function assertUsdAmount(usdAmount) {
158
+ var PLAIN_DECIMAL = /^\d+(\.\d{1,2})?$/;
159
+ function parseMoney(value, max, label) {
132
160
  let n;
133
- if (typeof usdAmount === "number") {
134
- n = usdAmount;
135
- if (Number.isFinite(n) && Math.abs(n * 100 - Math.round(n * 100)) > 1e-9) {
136
- throw invalidAmount(usdAmount, "must have at most 2 decimal places");
161
+ if (typeof value === "number") {
162
+ if (!Number.isFinite(value) || !PLAIN_DECIMAL.test(String(value))) {
163
+ throw invalidInput(label, value, "must be a positive decimal with at most 2 places");
137
164
  }
165
+ n = value;
138
166
  } else {
139
- if (!/^\d+(\.\d{1,2})?$/.test(usdAmount)) {
140
- throw invalidAmount(usdAmount, "must be a plain decimal USD value with at most 2 places");
167
+ if (!PLAIN_DECIMAL.test(value)) {
168
+ throw invalidInput(label, value, "must be a plain decimal value with at most 2 places");
141
169
  }
142
- n = Number(usdAmount);
170
+ n = Number(value);
143
171
  }
144
- if (!Number.isFinite(n) || n <= 0 || n > MAX_USD_AMOUNT) {
145
- throw invalidAmount(usdAmount, `must be > 0 and <= ${MAX_USD_AMOUNT}`);
172
+ if (!Number.isFinite(n) || n <= 0 || n > max) {
173
+ throw invalidInput(label, value, `must be > 0 and <= ${max}`);
146
174
  }
175
+ return n;
176
+ }
177
+ function assertUsdAmount(usdAmount) {
178
+ parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
147
179
  }
148
180
  function requireField(value, field, raw) {
149
181
  const s = value == null ? "" : String(value);
@@ -322,6 +354,104 @@ var InvoServer = class {
322
354
  this.auth
323
355
  );
324
356
  }
357
+ /**
358
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
359
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
360
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
361
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
362
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
363
+ * item to your inventory off the `item.purchased` webhook, not just this response.
364
+ */
365
+ async purchaseItem(input) {
366
+ const required = [
367
+ ["clientRequestId", input.clientRequestId],
368
+ ["playerEmail", input.playerEmail],
369
+ ["playerName", input.playerName],
370
+ ["itemId", input.itemId],
371
+ ["itemName", input.itemName]
372
+ ];
373
+ for (const [k, v] of required) {
374
+ if (typeof v !== "string" || !v.trim()) throw invalidInput(k, v, "is required");
375
+ }
376
+ if (!Number.isInteger(input.itemQuantity) || input.itemQuantity < 1 || input.itemQuantity > 1e3) {
377
+ throw invalidInput("itemQuantity", input.itemQuantity, "must be an integer 1..1000");
378
+ }
379
+ const unit = parseMoney(input.unitPrice, MAX_ITEM_PRICE, "unitPrice");
380
+ const total = parseMoney(input.totalPrice, MAX_ITEM_PRICE, "totalPrice");
381
+ const expectedCents = Math.round(unit * input.itemQuantity * 100);
382
+ const totalCents = Math.round(total * 100);
383
+ if (Math.abs(totalCents - expectedCents) > 1) {
384
+ throw invalidInput(
385
+ "totalPrice",
386
+ input.totalPrice,
387
+ `must equal unitPrice \xD7 itemQuantity (\xB10.01): ${unit} \xD7 ${input.itemQuantity}`
388
+ );
389
+ }
390
+ const body = {
391
+ client_request_id: input.clientRequestId,
392
+ player_email: input.playerEmail,
393
+ player_name: input.playerName,
394
+ item_id: input.itemId,
395
+ item_name: input.itemName,
396
+ item_quantity: input.itemQuantity,
397
+ unit_price: input.unitPrice,
398
+ total_price: input.totalPrice
399
+ };
400
+ if (input.playerPhone) body["player_phone"] = input.playerPhone;
401
+ if (input.itemDescription) body["item_description"] = input.itemDescription;
402
+ if (input.itemCategory) body["item_category"] = input.itemCategory;
403
+ const raw = await this.http.post(
404
+ "/api/item-purchases/purchase-item",
405
+ body,
406
+ this.auth
407
+ );
408
+ return {
409
+ status: String(raw["status"] ?? ""),
410
+ transactionId: requireField(raw["transaction_id"], "transaction_id", raw),
411
+ orderId: requireField(raw["order_id"], "order_id", raw),
412
+ newBalance: raw["new_balance"] ?? null,
413
+ previousBalance: raw["previous_balance"] ?? null,
414
+ currencyName: String(raw["currency_name"] ?? ""),
415
+ financialBreakdown: raw["financial_breakdown"],
416
+ raw
417
+ };
418
+ }
419
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
420
+ async getItemPurchaseHistory(query) {
421
+ if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
422
+ throw invalidInput("playerEmail", query.playerEmail, "is required");
423
+ }
424
+ const q = new URLSearchParams();
425
+ q.set("player_email", query.playerEmail);
426
+ if (query.limit != null) q.set("limit", String(query.limit));
427
+ if (query.offset != null) q.set("offset", String(query.offset));
428
+ return this.http.get(
429
+ `/api/item-purchases/player-purchase-history?${q.toString()}`,
430
+ this.auth
431
+ );
432
+ }
433
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
434
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
435
+ async getItemOrderDetails(query) {
436
+ const provided = [query.orderId, query.transactionId, query.clientRequestId].filter(
437
+ (v) => typeof v === "string" && v.trim()
438
+ );
439
+ if (provided.length !== 1) {
440
+ throw new InvoError({
441
+ message: "getItemOrderDetails requires EXACTLY ONE of orderId, transactionId, or clientRequestId.",
442
+ code: "INVALID_INPUT",
443
+ status: 0
444
+ });
445
+ }
446
+ const q = new URLSearchParams();
447
+ if (query.orderId) q.set("order_id", query.orderId);
448
+ if (query.transactionId) q.set("transaction_id", query.transactionId);
449
+ if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
450
+ return this.http.get(
451
+ `/api/item-purchases/order-details?${q.toString()}`,
452
+ this.auth
453
+ );
454
+ }
325
455
  toInitiateResult(raw) {
326
456
  const vm = raw["verification_method"];
327
457
  const guardian = raw["guardian_approval"];
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult } from './errors-B7rVID2r.cjs';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.cjs';
1
+ import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult, i as PurchaseItemInput, j as PurchaseItemResult, k as ItemHistoryQuery, l as ItemOrderQuery } from './errors-DV5QsftP.cjs';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.cjs';
3
3
 
4
4
  declare class InvoServer {
5
5
  private readonly http;
@@ -36,7 +36,21 @@ declare class InvoServer {
36
36
  orderId?: string;
37
37
  transactionId?: string;
38
38
  }): Promise<Record<string, unknown>>;
39
+ /**
40
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
41
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
42
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
43
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
44
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
45
+ * item to your inventory off the `item.purchased` webhook, not just this response.
46
+ */
47
+ purchaseItem(input: PurchaseItemInput): Promise<PurchaseItemResult>;
48
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
49
+ getItemPurchaseHistory(query: ItemHistoryQuery): Promise<Record<string, unknown>>;
50
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
51
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
52
+ getItemOrderDetails(query: ItemOrderQuery): Promise<Record<string, unknown>>;
39
53
  private toInitiateResult;
40
54
  }
41
55
 
42
- export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, PlayerToken, PurchaseInput, PurchaseResult, ServerConfig };
56
+ export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, ItemHistoryQuery, ItemOrderQuery, PlayerToken, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult } from './errors-B7rVID2r.js';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.js';
1
+ import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult, i as PurchaseItemInput, j as PurchaseItemResult, k as ItemHistoryQuery, l as ItemOrderQuery } from './errors-DV5QsftP.js';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.js';
3
3
 
4
4
  declare class InvoServer {
5
5
  private readonly http;
@@ -36,7 +36,21 @@ declare class InvoServer {
36
36
  orderId?: string;
37
37
  transactionId?: string;
38
38
  }): Promise<Record<string, unknown>>;
39
+ /**
40
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
41
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
42
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
43
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
44
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
45
+ * item to your inventory off the `item.purchased` webhook, not just this response.
46
+ */
47
+ purchaseItem(input: PurchaseItemInput): Promise<PurchaseItemResult>;
48
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
49
+ getItemPurchaseHistory(query: ItemHistoryQuery): Promise<Record<string, unknown>>;
50
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
51
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
52
+ getItemOrderDetails(query: ItemOrderQuery): Promise<Record<string, unknown>>;
39
53
  private toInitiateResult;
40
54
  }
41
55
 
42
- export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, PlayerToken, PurchaseInput, PurchaseResult, ServerConfig };
56
+ export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, ItemHistoryQuery, ItemOrderQuery, PlayerToken, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig };
package/dist/server.js CHANGED
@@ -1,32 +1,38 @@
1
- import { assertSecureBaseUrl, Http, InvoError } from './chunk-KUQVVH2P.js';
2
- export { InvoError } from './chunk-KUQVVH2P.js';
1
+ import { assertSecureBaseUrl, Http, InvoError } from './chunk-A44O4KC3.js';
2
+ export { InvoError } from './chunk-A44O4KC3.js';
3
3
 
4
4
  // src/server.ts
5
5
  var DEFAULT_UA = "invonetwork-web-sdk/0.1.0 (+https://invo.network)";
6
6
  var MAX_USD_AMOUNT = 999.99;
7
- function invalidAmount(usdAmount, why) {
7
+ var MAX_ITEM_PRICE = 999999.99;
8
+ function invalidInput(label, value, why) {
8
9
  return new InvoError({
9
- message: `usdAmount ${why} (got ${JSON.stringify(usdAmount)}).`,
10
+ message: `${label} ${why} (got ${JSON.stringify(value)}).`,
10
11
  code: "INVALID_INPUT",
11
12
  status: 0
12
13
  });
13
14
  }
14
- function assertUsdAmount(usdAmount) {
15
+ var PLAIN_DECIMAL = /^\d+(\.\d{1,2})?$/;
16
+ function parseMoney(value, max, label) {
15
17
  let n;
16
- if (typeof usdAmount === "number") {
17
- n = usdAmount;
18
- if (Number.isFinite(n) && Math.abs(n * 100 - Math.round(n * 100)) > 1e-9) {
19
- throw invalidAmount(usdAmount, "must have at most 2 decimal places");
18
+ if (typeof value === "number") {
19
+ if (!Number.isFinite(value) || !PLAIN_DECIMAL.test(String(value))) {
20
+ throw invalidInput(label, value, "must be a positive decimal with at most 2 places");
20
21
  }
22
+ n = value;
21
23
  } else {
22
- if (!/^\d+(\.\d{1,2})?$/.test(usdAmount)) {
23
- throw invalidAmount(usdAmount, "must be a plain decimal USD value with at most 2 places");
24
+ if (!PLAIN_DECIMAL.test(value)) {
25
+ throw invalidInput(label, value, "must be a plain decimal value with at most 2 places");
24
26
  }
25
- n = Number(usdAmount);
27
+ n = Number(value);
26
28
  }
27
- if (!Number.isFinite(n) || n <= 0 || n > MAX_USD_AMOUNT) {
28
- throw invalidAmount(usdAmount, `must be > 0 and <= ${MAX_USD_AMOUNT}`);
29
+ if (!Number.isFinite(n) || n <= 0 || n > max) {
30
+ throw invalidInput(label, value, `must be > 0 and <= ${max}`);
29
31
  }
32
+ return n;
33
+ }
34
+ function assertUsdAmount(usdAmount) {
35
+ parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
30
36
  }
31
37
  function requireField(value, field, raw) {
32
38
  const s = value == null ? "" : String(value);
@@ -205,6 +211,104 @@ var InvoServer = class {
205
211
  this.auth
206
212
  );
207
213
  }
214
+ /**
215
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
216
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
217
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
218
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
219
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
220
+ * item to your inventory off the `item.purchased` webhook, not just this response.
221
+ */
222
+ async purchaseItem(input) {
223
+ const required = [
224
+ ["clientRequestId", input.clientRequestId],
225
+ ["playerEmail", input.playerEmail],
226
+ ["playerName", input.playerName],
227
+ ["itemId", input.itemId],
228
+ ["itemName", input.itemName]
229
+ ];
230
+ for (const [k, v] of required) {
231
+ if (typeof v !== "string" || !v.trim()) throw invalidInput(k, v, "is required");
232
+ }
233
+ if (!Number.isInteger(input.itemQuantity) || input.itemQuantity < 1 || input.itemQuantity > 1e3) {
234
+ throw invalidInput("itemQuantity", input.itemQuantity, "must be an integer 1..1000");
235
+ }
236
+ const unit = parseMoney(input.unitPrice, MAX_ITEM_PRICE, "unitPrice");
237
+ const total = parseMoney(input.totalPrice, MAX_ITEM_PRICE, "totalPrice");
238
+ const expectedCents = Math.round(unit * input.itemQuantity * 100);
239
+ const totalCents = Math.round(total * 100);
240
+ if (Math.abs(totalCents - expectedCents) > 1) {
241
+ throw invalidInput(
242
+ "totalPrice",
243
+ input.totalPrice,
244
+ `must equal unitPrice \xD7 itemQuantity (\xB10.01): ${unit} \xD7 ${input.itemQuantity}`
245
+ );
246
+ }
247
+ const body = {
248
+ client_request_id: input.clientRequestId,
249
+ player_email: input.playerEmail,
250
+ player_name: input.playerName,
251
+ item_id: input.itemId,
252
+ item_name: input.itemName,
253
+ item_quantity: input.itemQuantity,
254
+ unit_price: input.unitPrice,
255
+ total_price: input.totalPrice
256
+ };
257
+ if (input.playerPhone) body["player_phone"] = input.playerPhone;
258
+ if (input.itemDescription) body["item_description"] = input.itemDescription;
259
+ if (input.itemCategory) body["item_category"] = input.itemCategory;
260
+ const raw = await this.http.post(
261
+ "/api/item-purchases/purchase-item",
262
+ body,
263
+ this.auth
264
+ );
265
+ return {
266
+ status: String(raw["status"] ?? ""),
267
+ transactionId: requireField(raw["transaction_id"], "transaction_id", raw),
268
+ orderId: requireField(raw["order_id"], "order_id", raw),
269
+ newBalance: raw["new_balance"] ?? null,
270
+ previousBalance: raw["previous_balance"] ?? null,
271
+ currencyName: String(raw["currency_name"] ?? ""),
272
+ financialBreakdown: raw["financial_breakdown"],
273
+ raw
274
+ };
275
+ }
276
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
277
+ async getItemPurchaseHistory(query) {
278
+ if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
279
+ throw invalidInput("playerEmail", query.playerEmail, "is required");
280
+ }
281
+ const q = new URLSearchParams();
282
+ q.set("player_email", query.playerEmail);
283
+ if (query.limit != null) q.set("limit", String(query.limit));
284
+ if (query.offset != null) q.set("offset", String(query.offset));
285
+ return this.http.get(
286
+ `/api/item-purchases/player-purchase-history?${q.toString()}`,
287
+ this.auth
288
+ );
289
+ }
290
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
291
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
292
+ async getItemOrderDetails(query) {
293
+ const provided = [query.orderId, query.transactionId, query.clientRequestId].filter(
294
+ (v) => typeof v === "string" && v.trim()
295
+ );
296
+ if (provided.length !== 1) {
297
+ throw new InvoError({
298
+ message: "getItemOrderDetails requires EXACTLY ONE of orderId, transactionId, or clientRequestId.",
299
+ code: "INVALID_INPUT",
300
+ status: 0
301
+ });
302
+ }
303
+ const q = new URLSearchParams();
304
+ if (query.orderId) q.set("order_id", query.orderId);
305
+ if (query.transactionId) q.set("transaction_id", query.transactionId);
306
+ if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
307
+ return this.http.get(
308
+ `/api/item-purchases/order-details?${q.toString()}`,
309
+ this.auth
310
+ );
311
+ }
208
312
  toInitiateResult(raw) {
209
313
  const vm = raw["verification_method"];
210
314
  const guardian = raw["guardian_approval"];
package/package.json CHANGED
@@ -1,68 +1,68 @@
1
- {
2
- "name": "@invonetwork/web-sdk",
3
- "version": "0.1.0",
4
- "description": "INVO Web SDK — currency purchase + passkey (WebAuthn) verification for partner web platforms.",
5
- "license": "UNLICENSED",
6
- "private": false,
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/Invo-Technologies/invo-web-sdk.git"
10
- },
11
- "homepage": "https://docs.invo.network",
12
- "bugs": {
13
- "url": "https://github.com/Invo-Technologies/invo-web-sdk/issues"
14
- },
15
- "type": "module",
16
- "engines": {
17
- "node": ">=18"
18
- },
19
- "sideEffects": false,
20
- "files": [
21
- "dist",
22
- "!dist/**/*.map",
23
- "README.md",
24
- "LICENSE",
25
- "CHANGELOG.md"
26
- ],
27
- "exports": {
28
- ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js",
31
- "require": "./dist/index.cjs"
32
- },
33
- "./server": {
34
- "types": "./dist/server.d.ts",
35
- "import": "./dist/server.js",
36
- "require": "./dist/server.cjs"
37
- }
38
- },
39
- "main": "./dist/index.cjs",
40
- "module": "./dist/index.js",
41
- "types": "./dist/index.d.ts",
42
- "scripts": {
43
- "build": "tsup",
44
- "dev": "tsup --watch",
45
- "typecheck": "tsc --noEmit",
46
- "test": "vitest run",
47
- "test:watch": "vitest",
48
- "clean": "rimraf dist",
49
- "prepublishOnly": "npm run clean && npm run build"
50
- },
51
- "keywords": [
52
- "invo",
53
- "webauthn",
54
- "passkey",
55
- "payments",
56
- "game-currency",
57
- "sdk"
58
- ],
59
- "publishConfig": {
60
- "access": "public"
61
- },
62
- "devDependencies": {
63
- "rimraf": "^5.0.5",
64
- "tsup": "^8.0.1",
65
- "typescript": "^5.4.5",
66
- "vitest": "^1.5.0"
67
- }
68
- }
1
+ {
2
+ "name": "@invonetwork/web-sdk",
3
+ "version": "0.2.0",
4
+ "description": "INVO Web SDK — currency purchase + passkey (WebAuthn) verification for partner web platforms.",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Invo-Technologies/invo-web-sdk.git"
10
+ },
11
+ "homepage": "https://docs.invo.network",
12
+ "bugs": {
13
+ "url": "https://github.com/Invo-Technologies/invo-web-sdk/issues"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "sideEffects": false,
20
+ "files": [
21
+ "dist",
22
+ "!dist/**/*.map",
23
+ "README.md",
24
+ "LICENSE",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "require": "./dist/index.cjs"
32
+ },
33
+ "./server": {
34
+ "types": "./dist/server.d.ts",
35
+ "import": "./dist/server.js",
36
+ "require": "./dist/server.cjs"
37
+ }
38
+ },
39
+ "main": "./dist/index.cjs",
40
+ "module": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "dev": "tsup --watch",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
48
+ "clean": "rimraf dist",
49
+ "prepublishOnly": "npm run clean && npm run build"
50
+ },
51
+ "keywords": [
52
+ "invo",
53
+ "webauthn",
54
+ "passkey",
55
+ "payments",
56
+ "game-currency",
57
+ "sdk"
58
+ ],
59
+ "publishConfig": {
60
+ "access": "public"
61
+ },
62
+ "devDependencies": {
63
+ "rimraf": "^5.0.5",
64
+ "tsup": "^8.0.1",
65
+ "typescript": "^5.4.5",
66
+ "vitest": "^1.5.0"
67
+ }
68
+ }