invonetwork 2.1.2__tar.gz → 2.2.0__tar.gz
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.
- {invonetwork-2.1.2 → invonetwork-2.2.0}/CHANGELOG.md +29 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/PKG-INFO +30 -34
- {invonetwork-2.1.2 → invonetwork-2.2.0}/README.md +29 -33
- {invonetwork-2.1.2 → invonetwork-2.2.0}/pyproject.toml +1 -1
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/errors.py +9 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/server.py +71 -56
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_balance_identities.py +6 -4
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_reads.py +12 -2
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_server.py +16 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/.gitignore +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/LICENSE +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/__init__.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/hooks.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/http.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/py.typed +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/types.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/src/invonetwork/webhooks.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/helpers.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_audit_fixes.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_claim.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_errors.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_guardian_approval.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_hooks.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_inbound_pending.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_item_purchase.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_phone_share.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_recovery.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_retry.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_server_destinations.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_sms_verify.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_transaction_status.py +0 -0
- {invonetwork-2.1.2 → invonetwork-2.2.0}/tests/test_webhooks.py +0 -0
|
@@ -3,6 +3,35 @@
|
|
|
3
3
|
All notable changes to this project are documented here. This project adheres to
|
|
4
4
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [2.2.0] - 2026-07-07
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
**Backend reconciliation** — an audit of the SDK against the live backend surfaced three places
|
|
11
|
+
the SDK didn't match reality. All changes are backward-compatible (relaxing required inputs /
|
|
12
|
+
adding surface); no working call breaks.
|
|
13
|
+
|
|
14
|
+
- **`mint_player_token` no longer requires `player_phone`.** `/api/sdk/player-token` is a
|
|
15
|
+
session-token mint for an **existing** player (looked up by email), not a registration call —
|
|
16
|
+
it reads only `player_email` and ignores `player_phone`. The 2.0.0 hard E.164 requirement was
|
|
17
|
+
a bug: it could **block a valid token mint for an existing email-only player**. `player_phone`
|
|
18
|
+
is now optional (keyword) and validated only if provided. Phone stays enforced on the money
|
|
19
|
+
calls and at player creation.
|
|
20
|
+
- **Send by phone alone.** `receiver_player_email` (send) and `target_player_email` (transfer)
|
|
21
|
+
are now **optional** keyword args — send to a recipient's phone; they supply their email at
|
|
22
|
+
claim (claim still requires it).
|
|
23
|
+
- **`get_player_balance` is by email only.** There is no by-id balance route; passing
|
|
24
|
+
`player_id` now raises a clear `INVALID_INPUT` pointing to the browser `getBalance()` or
|
|
25
|
+
`player_email`, instead of hitting a 404.
|
|
26
|
+
- **New `InvoError.is_user_verification_required`** for the `WEBAUTHN_UV_REQUIRED` (401) approve
|
|
27
|
+
failure.
|
|
28
|
+
- `iterate_item_purchase_history` reads the backend's `total_count` pagination key (was `total`;
|
|
29
|
+
behavior unchanged — the short-page guard already terminated the loop).
|
|
30
|
+
|
|
31
|
+
**Migration:** none required. If you passed `player_phone` to `mint_player_token` it still works;
|
|
32
|
+
you can now omit it. Replace any `get_player_balance(player_id=…)` with an email lookup or the
|
|
33
|
+
browser `getBalance()`.
|
|
34
|
+
|
|
6
35
|
## [2.1.2] - 2026-07-07
|
|
7
36
|
|
|
8
37
|
### Added / Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: invonetwork
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: INVO Python server SDK -- currency purchase, item purchase, sends/transfers, and webhook verification for partner platforms.
|
|
5
5
|
Project-URL: Homepage, https://docs.invo.network
|
|
6
6
|
Project-URL: Documentation, https://docs.invo.network
|
|
@@ -54,8 +54,10 @@ same live backend interchangeably.
|
|
|
54
54
|
> wraps is **live** on sandbox + production, so you can build and test against sandbox today.
|
|
55
55
|
> **Since 2.0.0 (breaking):** `mint_player_token` requires `player_phone` (E.164) — see the
|
|
56
56
|
> [CHANGELOG](CHANGELOG.md) migration note. **2.1.0 adds** the passkey-recovery relay
|
|
57
|
-
> (`recovery_begin`/`recovery_complete`); **2.1.1**
|
|
58
|
-
> **2.
|
|
57
|
+
> (`recovery_begin`/`recovery_complete`); **2.1.1** security hardening; **2.1.2** `expires_in_seconds`
|
|
58
|
+
> on `create_checkout`. **2.2.0** reconciles with the backend: `mint_player_token` no longer
|
|
59
|
+
> requires `player_phone` (it's a session mint, not registration), send-by-phone-alone, and
|
|
60
|
+
> `get_player_balance` is email-only.
|
|
59
61
|
> Canonical partner reference: **https://docs.invo.network**.
|
|
60
62
|
|
|
61
63
|
## Highlights
|
|
@@ -166,37 +168,29 @@ INVO integrations split across two trust boundaries. This package is the **serve
|
|
|
166
168
|
The **game secret authenticates every call here and must never reach a browser.** Mint a
|
|
167
169
|
short-lived player token server-side with `mint_player_token` and hand *that* to the browser SDK.
|
|
168
170
|
|
|
169
|
-
### Player
|
|
171
|
+
### Player token (session mint for an existing player)
|
|
170
172
|
|
|
171
|
-
`mint_player_token`
|
|
172
|
-
|
|
173
|
+
`mint_player_token` mints a short-lived, game-scoped **session token** for a player who
|
|
174
|
+
**already exists** on your game — it is *not* a registration call. The backend looks the player
|
|
175
|
+
up by `player_email` and returns a token for their existing identity (or `404` if unknown). It
|
|
176
|
+
only needs `player_email`:
|
|
173
177
|
|
|
174
178
|
```python
|
|
175
|
-
token = server.mint_player_token(
|
|
176
|
-
player_email="player@example.com",
|
|
177
|
-
player_phone="+15555550100", # REQUIRED -- E.164: leading "+", country code, digits only
|
|
178
|
-
)
|
|
179
|
+
token = server.mint_player_token(player_email="player@example.com")
|
|
179
180
|
```
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
**Same person, new email, same phone is expected and supported.** When the phone already
|
|
194
|
-
belongs to another INVO account, the mint raises an `InvoError` with
|
|
195
|
-
`is_phone_share_approval_required` (409) — this is **not a failure**: INVO sent the phone
|
|
196
|
-
holder a consent SMS. Show the player a "pending approval" state, then re-issue the same mint
|
|
197
|
-
once approved. Poll `phone_share_status(phone=..., email=...)`, or use
|
|
198
|
-
`phone_share_initiate` / `phone_share_approve` (the error's `phone_share_approval_id` carries
|
|
199
|
-
the id) for the in-app OTP fallback.
|
|
182
|
+
`player_phone` is **optional** here (validated as E.164 only if you pass it, and ignored by this
|
|
183
|
+
endpoint) — an existing email-only player still mints a token.
|
|
184
|
+
|
|
185
|
+
**Where phone actually matters.** A player's INVO identity encodes their phone, and cross-game
|
|
186
|
+
money routing keys off it — but that's enforced on the money calls, not the token mint:
|
|
187
|
+
|
|
188
|
+
- **`initiate_send` / `initiate_transfer`** require the **sender's** phone (E.164), and take the
|
|
189
|
+
**recipient's** phone. You can send by the recipient's **phone alone** — they supply their
|
|
190
|
+
email when they claim.
|
|
191
|
+
- An account with no phone can't receive cross-game money or take part in the account-linking
|
|
192
|
+
consent SMS, so make sure players have a phone at **creation / enrollment** (in your own
|
|
193
|
+
player system), before they transact.
|
|
200
194
|
|
|
201
195
|
### Not in this SDK (by design): the browser WebAuthn ceremonies
|
|
202
196
|
|
|
@@ -246,8 +240,9 @@ INVO enables each flow for your tenant in the [console](#get-your-account--game-
|
|
|
246
240
|
|
|
247
241
|
- **Store the game secret server-side** (env var / secret manager) and expose a small endpoint
|
|
248
242
|
that calls `mint_player_token` so your front-end can fetch/refresh a player token.
|
|
249
|
-
- **
|
|
250
|
-
|
|
243
|
+
- **Make sure players have a phone (E.164) at creation/enrollment** — it's required on the money
|
|
244
|
+
calls (`initiate_send`/`initiate_transfer`) and for cross-game receive, though not on the token
|
|
245
|
+
mint itself (see [Player token](#player-token-session-mint-for-an-existing-player)).
|
|
251
246
|
- **Set your webhook signing secret** and verify every delivery with `verify_webhook` — grant
|
|
252
247
|
currency/items off webhooks, not synchronous responses.
|
|
253
248
|
- **For currency purchase:** hosted checkout works out of the box; ask INVO to enable the
|
|
@@ -394,7 +389,8 @@ for row in server.iterate_item_purchase_history(player_email="p@example.com"):
|
|
|
394
389
|
|
|
395
390
|
```python
|
|
396
391
|
result = server.get_player_balance(player_email="p@example.com")
|
|
397
|
-
#
|
|
392
|
+
# Lookup is by EMAIL only — there is no by-id balance route (player_id is a per-game internal
|
|
393
|
+
# id). For a client-side read, use the browser InvoClient.getBalance() (identity from the token).
|
|
398
394
|
for b in result.balances:
|
|
399
395
|
print(b.currency_name, b.available_balance, b.total_balance)
|
|
400
396
|
```
|
|
@@ -624,7 +620,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
|
|
|
624
620
|
|
|
625
621
|
| Method | Returns |
|
|
626
622
|
|---|---|
|
|
627
|
-
| `mint_player_token(player_email, player_phone)` | `PlayerToken(token, expires_at, identity_id, raw)` — **
|
|
623
|
+
| `mint_player_token(player_email, player_phone?)` | `PlayerToken(token, expires_at, identity_id, raw)` — session mint for an **existing** player (404 if unknown); `player_phone` optional/validated-if-present (see [Player token](#player-token-session-mint-for-an-existing-player)) |
|
|
628
624
|
| `initiate_send(...)` | `InitiateResult(transaction_id, verification_method, guardian_approval, raw)` |
|
|
629
625
|
| `initiate_transfer(...)` | `InitiateResult` |
|
|
630
626
|
| `create_checkout(player_email, usd_amount, rail?, success_url?, cancel_url?, metadata?)` | `CreateCheckoutResult(session_id, checkout_url, expires_at, expires_in_seconds, raw)` |
|
|
@@ -635,7 +631,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
|
|
|
635
631
|
| `get_item_purchase_history(player_email, limit?, offset?)` | `ItemHistoryResult(history, pagination, raw)` |
|
|
636
632
|
| `get_item_order_details(order_id? \| transaction_id? \| client_request_id?)` | `OrderDetailsResult` |
|
|
637
633
|
| `iterate_item_purchase_history(player_email, page_size?)` | generator of history rows (`dict`) |
|
|
638
|
-
| `get_player_balance(player_email
|
|
634
|
+
| `get_player_balance(player_email)` | `PlayerBalanceResult(player, balances, summary, raw)` — **by email only** (no by-id route; use browser `InvoClient.getBalance()` client-side) |
|
|
639
635
|
| `get_inbound_pending(player_email? \| player_phone?)` | `InboundPendingResult(inbound_pending, raw)` |
|
|
640
636
|
| `get_linked_identities(player_email? \| player_phone?)` | `LinkedIdentitiesResult(wallet_user_id, primary_email, primary_phone, is_minor, emails, not_found, raw)` — **server-only (PII)** |
|
|
641
637
|
| `verify_sms_transfer(transaction_id, sms_pin)` / `verify_sms_send(...)` | `SmsVerifyResult` — complete the SMS-PIN path when `verification_method == "sms"` |
|
|
@@ -9,8 +9,10 @@ same live backend interchangeably.
|
|
|
9
9
|
> wraps is **live** on sandbox + production, so you can build and test against sandbox today.
|
|
10
10
|
> **Since 2.0.0 (breaking):** `mint_player_token` requires `player_phone` (E.164) — see the
|
|
11
11
|
> [CHANGELOG](CHANGELOG.md) migration note. **2.1.0 adds** the passkey-recovery relay
|
|
12
|
-
> (`recovery_begin`/`recovery_complete`); **2.1.1**
|
|
13
|
-
> **2.
|
|
12
|
+
> (`recovery_begin`/`recovery_complete`); **2.1.1** security hardening; **2.1.2** `expires_in_seconds`
|
|
13
|
+
> on `create_checkout`. **2.2.0** reconciles with the backend: `mint_player_token` no longer
|
|
14
|
+
> requires `player_phone` (it's a session mint, not registration), send-by-phone-alone, and
|
|
15
|
+
> `get_player_balance` is email-only.
|
|
14
16
|
> Canonical partner reference: **https://docs.invo.network**.
|
|
15
17
|
|
|
16
18
|
## Highlights
|
|
@@ -121,37 +123,29 @@ INVO integrations split across two trust boundaries. This package is the **serve
|
|
|
121
123
|
The **game secret authenticates every call here and must never reach a browser.** Mint a
|
|
122
124
|
short-lived player token server-side with `mint_player_token` and hand *that* to the browser SDK.
|
|
123
125
|
|
|
124
|
-
### Player
|
|
126
|
+
### Player token (session mint for an existing player)
|
|
125
127
|
|
|
126
|
-
`mint_player_token`
|
|
127
|
-
|
|
128
|
+
`mint_player_token` mints a short-lived, game-scoped **session token** for a player who
|
|
129
|
+
**already exists** on your game — it is *not* a registration call. The backend looks the player
|
|
130
|
+
up by `player_email` and returns a token for their existing identity (or `404` if unknown). It
|
|
131
|
+
only needs `player_email`:
|
|
128
132
|
|
|
129
133
|
```python
|
|
130
|
-
token = server.mint_player_token(
|
|
131
|
-
player_email="player@example.com",
|
|
132
|
-
player_phone="+15555550100", # REQUIRED -- E.164: leading "+", country code, digits only
|
|
133
|
-
)
|
|
134
|
+
token = server.mint_player_token(player_email="player@example.com")
|
|
134
135
|
```
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
**Same person, new email, same phone is expected and supported.** When the phone already
|
|
149
|
-
belongs to another INVO account, the mint raises an `InvoError` with
|
|
150
|
-
`is_phone_share_approval_required` (409) — this is **not a failure**: INVO sent the phone
|
|
151
|
-
holder a consent SMS. Show the player a "pending approval" state, then re-issue the same mint
|
|
152
|
-
once approved. Poll `phone_share_status(phone=..., email=...)`, or use
|
|
153
|
-
`phone_share_initiate` / `phone_share_approve` (the error's `phone_share_approval_id` carries
|
|
154
|
-
the id) for the in-app OTP fallback.
|
|
137
|
+
`player_phone` is **optional** here (validated as E.164 only if you pass it, and ignored by this
|
|
138
|
+
endpoint) — an existing email-only player still mints a token.
|
|
139
|
+
|
|
140
|
+
**Where phone actually matters.** A player's INVO identity encodes their phone, and cross-game
|
|
141
|
+
money routing keys off it — but that's enforced on the money calls, not the token mint:
|
|
142
|
+
|
|
143
|
+
- **`initiate_send` / `initiate_transfer`** require the **sender's** phone (E.164), and take the
|
|
144
|
+
**recipient's** phone. You can send by the recipient's **phone alone** — they supply their
|
|
145
|
+
email when they claim.
|
|
146
|
+
- An account with no phone can't receive cross-game money or take part in the account-linking
|
|
147
|
+
consent SMS, so make sure players have a phone at **creation / enrollment** (in your own
|
|
148
|
+
player system), before they transact.
|
|
155
149
|
|
|
156
150
|
### Not in this SDK (by design): the browser WebAuthn ceremonies
|
|
157
151
|
|
|
@@ -201,8 +195,9 @@ INVO enables each flow for your tenant in the [console](#get-your-account--game-
|
|
|
201
195
|
|
|
202
196
|
- **Store the game secret server-side** (env var / secret manager) and expose a small endpoint
|
|
203
197
|
that calls `mint_player_token` so your front-end can fetch/refresh a player token.
|
|
204
|
-
- **
|
|
205
|
-
|
|
198
|
+
- **Make sure players have a phone (E.164) at creation/enrollment** — it's required on the money
|
|
199
|
+
calls (`initiate_send`/`initiate_transfer`) and for cross-game receive, though not on the token
|
|
200
|
+
mint itself (see [Player token](#player-token-session-mint-for-an-existing-player)).
|
|
206
201
|
- **Set your webhook signing secret** and verify every delivery with `verify_webhook` — grant
|
|
207
202
|
currency/items off webhooks, not synchronous responses.
|
|
208
203
|
- **For currency purchase:** hosted checkout works out of the box; ask INVO to enable the
|
|
@@ -349,7 +344,8 @@ for row in server.iterate_item_purchase_history(player_email="p@example.com"):
|
|
|
349
344
|
|
|
350
345
|
```python
|
|
351
346
|
result = server.get_player_balance(player_email="p@example.com")
|
|
352
|
-
#
|
|
347
|
+
# Lookup is by EMAIL only — there is no by-id balance route (player_id is a per-game internal
|
|
348
|
+
# id). For a client-side read, use the browser InvoClient.getBalance() (identity from the token).
|
|
353
349
|
for b in result.balances:
|
|
354
350
|
print(b.currency_name, b.available_balance, b.total_balance)
|
|
355
351
|
```
|
|
@@ -579,7 +575,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
|
|
|
579
575
|
|
|
580
576
|
| Method | Returns |
|
|
581
577
|
|---|---|
|
|
582
|
-
| `mint_player_token(player_email, player_phone)` | `PlayerToken(token, expires_at, identity_id, raw)` — **
|
|
578
|
+
| `mint_player_token(player_email, player_phone?)` | `PlayerToken(token, expires_at, identity_id, raw)` — session mint for an **existing** player (404 if unknown); `player_phone` optional/validated-if-present (see [Player token](#player-token-session-mint-for-an-existing-player)) |
|
|
583
579
|
| `initiate_send(...)` | `InitiateResult(transaction_id, verification_method, guardian_approval, raw)` |
|
|
584
580
|
| `initiate_transfer(...)` | `InitiateResult` |
|
|
585
581
|
| `create_checkout(player_email, usd_amount, rail?, success_url?, cancel_url?, metadata?)` | `CreateCheckoutResult(session_id, checkout_url, expires_at, expires_in_seconds, raw)` |
|
|
@@ -590,7 +586,7 @@ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retr
|
|
|
590
586
|
| `get_item_purchase_history(player_email, limit?, offset?)` | `ItemHistoryResult(history, pagination, raw)` |
|
|
591
587
|
| `get_item_order_details(order_id? \| transaction_id? \| client_request_id?)` | `OrderDetailsResult` |
|
|
592
588
|
| `iterate_item_purchase_history(player_email, page_size?)` | generator of history rows (`dict`) |
|
|
593
|
-
| `get_player_balance(player_email
|
|
589
|
+
| `get_player_balance(player_email)` | `PlayerBalanceResult(player, balances, summary, raw)` — **by email only** (no by-id route; use browser `InvoClient.getBalance()` client-side) |
|
|
594
590
|
| `get_inbound_pending(player_email? \| player_phone?)` | `InboundPendingResult(inbound_pending, raw)` |
|
|
595
591
|
| `get_linked_identities(player_email? \| player_phone?)` | `LinkedIdentitiesResult(wallet_user_id, primary_email, primary_phone, is_minor, emails, not_found, raw)` — **server-only (PII)** |
|
|
596
592
|
| `verify_sms_transfer(transaction_id, sms_pin)` / `verify_sms_send(...)` | `SmsVerifyResult` — complete the SMS-PIN path when `verification_method == "sms"` |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "invonetwork"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "INVO Python server SDK -- currency purchase, item purchase, sends/transfers, and webhook verification for partner platforms."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -85,6 +85,15 @@ class InvoError(Exception):
|
|
|
85
85
|
"""True if enrollment needs the OTP-grant flow (``enrollmentBegin``/``enrollmentVerify``)."""
|
|
86
86
|
return self.code == "ENROLLMENT_REQUIRES_AUTHORIZATION"
|
|
87
87
|
|
|
88
|
+
@property
|
|
89
|
+
def is_user_verification_required(self) -> bool:
|
|
90
|
+
"""True if a passkey approve/confirm was rejected for lacking user verification.
|
|
91
|
+
|
|
92
|
+
``WEBAUTHN_UV_REQUIRED`` (401) -- re-run the ceremony requiring UV (biometric/PIN); the
|
|
93
|
+
backend treats a UV-satisfied assertion as the signed step-up.
|
|
94
|
+
"""
|
|
95
|
+
return self.code == "WEBAUTHN_UV_REQUIRED"
|
|
96
|
+
|
|
88
97
|
@property
|
|
89
98
|
def is_enrollment_proof_required(self) -> bool:
|
|
90
99
|
"""True if enrolling is blocked because another method exists -- use device link."""
|
|
@@ -50,7 +50,7 @@ from .types import (
|
|
|
50
50
|
|
|
51
51
|
__all__ = ["InvoServer"]
|
|
52
52
|
|
|
53
|
-
DEFAULT_USER_AGENT = "invonetwork-python-sdk/2.
|
|
53
|
+
DEFAULT_USER_AGENT = "invonetwork-python-sdk/2.2.0 (+https://invo.network)"
|
|
54
54
|
"""Explicit, non-blocked UA. The backend 403-blocks UAs matching nikto|sqlmap|
|
|
55
55
|
python-urllib|scanner|bot|^$, so we never use urllib's default UA."""
|
|
56
56
|
|
|
@@ -409,32 +409,33 @@ class InvoServer:
|
|
|
409
409
|
|
|
410
410
|
# ---- Token ----
|
|
411
411
|
|
|
412
|
-
def mint_player_token(
|
|
412
|
+
def mint_player_token(
|
|
413
|
+
self, *, player_email: str, player_phone: Optional[str] = None
|
|
414
|
+
) -> PlayerToken:
|
|
413
415
|
"""Mint a short-lived, game-scoped player token to hand to the browser client.
|
|
414
416
|
|
|
415
|
-
This is
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
NOT a failure: the phone holder was sent a consent SMS. Surface "pending
|
|
426
|
-
approval" to the user, then re-issue the same call once approved (poll
|
|
427
|
-
``phone_share_status``, or use ``phone_share_initiate`` / ``phone_share_approve``
|
|
428
|
-
for the in-app OTP fallback).
|
|
417
|
+
This is a SESSION-TOKEN mint for a player who ALREADY EXISTS on the game -- not a
|
|
418
|
+
registration call. The backend looks the player up by ``player_email`` and returns a
|
|
419
|
+
token for their existing identity; if no such player exists it responds 404. The
|
|
420
|
+
network identity (which encodes the player's phone) is established at player creation
|
|
421
|
+
and on the money-routing calls, so phone is NOT required or read here.
|
|
422
|
+
|
|
423
|
+
``player_phone`` is optional and only validated (E.164) if you pass it -- the backend
|
|
424
|
+
ignores it on this endpoint, so an existing email-only player can still mint a token.
|
|
425
|
+
Phone is enforced where it matters: ``initiate_send`` / ``initiate_transfer`` (the
|
|
426
|
+
sender's phone, and the recipient's when known) and at player creation.
|
|
429
427
|
|
|
430
428
|
Idempotent: safe to retry (re-minting just issues a fresh token).
|
|
431
429
|
"""
|
|
432
430
|
if not _is_present(player_email):
|
|
433
431
|
raise _invalid_input("playerEmail", player_email, "is required")
|
|
434
|
-
|
|
432
|
+
body: Dict[str, Any] = {"player_email": player_email}
|
|
433
|
+
if player_phone is not None:
|
|
434
|
+
_assert_e164(player_phone, "playerPhone") # validate only if provided
|
|
435
|
+
body["player_phone"] = player_phone
|
|
435
436
|
raw = self._http.post(
|
|
436
437
|
"/api/sdk/player-token",
|
|
437
|
-
|
|
438
|
+
body,
|
|
438
439
|
self._auth,
|
|
439
440
|
idempotent=True,
|
|
440
441
|
)
|
|
@@ -454,29 +455,30 @@ class InvoServer:
|
|
|
454
455
|
sender_player_name: str,
|
|
455
456
|
sender_player_email: str,
|
|
456
457
|
sender_player_phone: str,
|
|
457
|
-
receiver_player_email: str,
|
|
458
458
|
receiver_player_phone: str,
|
|
459
459
|
receiving_game_id: Any,
|
|
460
460
|
amount: Money,
|
|
461
|
+
receiver_player_email: Optional[str] = None,
|
|
461
462
|
) -> InitiateResult:
|
|
462
463
|
"""Initiate a cross-game currency SEND. Inspect ``result.verification_method``.
|
|
463
464
|
|
|
464
|
-
|
|
465
|
+
You can send by the recipient's PHONE alone -- ``receiver_player_email`` is optional
|
|
466
|
+
(they supply their own email when they claim). Idempotent on ``client_request_id``
|
|
467
|
+
(the backend dedupes), so it is safe to retry.
|
|
465
468
|
"""
|
|
469
|
+
body: Dict[str, Any] = {
|
|
470
|
+
"client_request_id": client_request_id,
|
|
471
|
+
"sender_player_name": sender_player_name,
|
|
472
|
+
"sender_player_email": sender_player_email,
|
|
473
|
+
"sender_player_phone": sender_player_phone,
|
|
474
|
+
"receiver_player_phone": receiver_player_phone,
|
|
475
|
+
"receiving_game_id": receiving_game_id,
|
|
476
|
+
"amount": amount,
|
|
477
|
+
}
|
|
478
|
+
if receiver_player_email is not None:
|
|
479
|
+
body["receiver_player_email"] = receiver_player_email
|
|
466
480
|
raw = self._http.post(
|
|
467
|
-
"/api/currency-sends/initiate-send",
|
|
468
|
-
{
|
|
469
|
-
"client_request_id": client_request_id,
|
|
470
|
-
"sender_player_name": sender_player_name,
|
|
471
|
-
"sender_player_email": sender_player_email,
|
|
472
|
-
"sender_player_phone": sender_player_phone,
|
|
473
|
-
"receiver_player_email": receiver_player_email,
|
|
474
|
-
"receiver_player_phone": receiver_player_phone,
|
|
475
|
-
"receiving_game_id": receiving_game_id,
|
|
476
|
-
"amount": amount,
|
|
477
|
-
},
|
|
478
|
-
self._auth,
|
|
479
|
-
idempotent=True,
|
|
481
|
+
"/api/currency-sends/initiate-send", body, self._auth, idempotent=True
|
|
480
482
|
)
|
|
481
483
|
return self._to_initiate_result(raw)
|
|
482
484
|
|
|
@@ -487,29 +489,30 @@ class InvoServer:
|
|
|
487
489
|
source_player_name: str,
|
|
488
490
|
source_player_email: str,
|
|
489
491
|
source_player_phone: str,
|
|
490
|
-
target_player_email: str,
|
|
491
492
|
target_player_phone: str,
|
|
492
493
|
target_game_id: Any,
|
|
493
494
|
amount: Money,
|
|
495
|
+
target_player_email: Optional[str] = None,
|
|
494
496
|
) -> InitiateResult:
|
|
495
497
|
"""Initiate a cross-game TRANSFER. Inspect ``result.verification_method``.
|
|
496
498
|
|
|
497
|
-
|
|
499
|
+
You can transfer by the recipient's PHONE alone -- ``target_player_email`` is optional
|
|
500
|
+
(they supply their own email when they claim). Idempotent on ``client_request_id``
|
|
501
|
+
(the backend dedupes), so it is safe to retry.
|
|
498
502
|
"""
|
|
503
|
+
body: Dict[str, Any] = {
|
|
504
|
+
"client_request_id": client_request_id,
|
|
505
|
+
"source_player_name": source_player_name,
|
|
506
|
+
"source_player_email": source_player_email,
|
|
507
|
+
"source_player_phone": source_player_phone,
|
|
508
|
+
"target_player_phone": target_player_phone,
|
|
509
|
+
"target_game_id": target_game_id,
|
|
510
|
+
"amount": amount,
|
|
511
|
+
}
|
|
512
|
+
if target_player_email is not None:
|
|
513
|
+
body["target_player_email"] = target_player_email
|
|
499
514
|
raw = self._http.post(
|
|
500
|
-
"/api/transfers/initiate-transfer",
|
|
501
|
-
{
|
|
502
|
-
"client_request_id": client_request_id,
|
|
503
|
-
"source_player_name": source_player_name,
|
|
504
|
-
"source_player_email": source_player_email,
|
|
505
|
-
"source_player_phone": source_player_phone,
|
|
506
|
-
"target_player_email": target_player_email,
|
|
507
|
-
"target_player_phone": target_player_phone,
|
|
508
|
-
"target_game_id": target_game_id,
|
|
509
|
-
"amount": amount,
|
|
510
|
-
},
|
|
511
|
-
self._auth,
|
|
512
|
-
idempotent=True,
|
|
515
|
+
"/api/transfers/initiate-transfer", body, self._auth, idempotent=True
|
|
513
516
|
)
|
|
514
517
|
return self._to_initiate_result(raw)
|
|
515
518
|
|
|
@@ -831,9 +834,10 @@ class InvoServer:
|
|
|
831
834
|
if len(page.history) < size:
|
|
832
835
|
return # last (short) page
|
|
833
836
|
# Guard against a backend that ignores `offset` and returns full pages forever:
|
|
834
|
-
# stop once we've yielded the reported total.
|
|
835
|
-
#
|
|
836
|
-
|
|
837
|
+
# stop once we've yielded the reported total. The backend key is ``total_count``
|
|
838
|
+
# (``total`` kept as a fallback). Coerce like JS ``Number()`` so a string total
|
|
839
|
+
# (e.g. "100") still trips the guard.
|
|
840
|
+
total = page.pagination.get("total_count", page.pagination.get("total"))
|
|
837
841
|
try:
|
|
838
842
|
total_num = float(total) if total is not None else None
|
|
839
843
|
except (TypeError, ValueError):
|
|
@@ -847,14 +851,25 @@ class InvoServer:
|
|
|
847
851
|
def get_player_balance(
|
|
848
852
|
self, *, player_email: Optional[str] = None, player_id: Any = None
|
|
849
853
|
) -> PlayerBalanceResult:
|
|
850
|
-
"""Read a player's currency balances
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
+
"""Read a player's currency balances by EMAIL (game-secret).
|
|
855
|
+
|
|
856
|
+
Lookup is by email only -- there is no by-id balance route on the backend
|
|
857
|
+
(``player_id`` is a per-game internal id), so passing ``player_id`` raises. For a
|
|
858
|
+
client-side read use the browser ``InvoClient.getBalance()`` (identity from the token).
|
|
859
|
+
"""
|
|
860
|
+
if _is_present(player_email):
|
|
854
861
|
path = f"/api/player-balances/player/by-email/{quote(player_email, safe='')}" # type: ignore[arg-type]
|
|
862
|
+
elif player_id is not None and str(player_id).strip():
|
|
863
|
+
raise InvoError(
|
|
864
|
+
"get_player_balance is by player_email only -- there is no by-id balance "
|
|
865
|
+
"route. Use the browser InvoClient.getBalance() (player token), or pass "
|
|
866
|
+
"player_email here.",
|
|
867
|
+
code="INVALID_INPUT",
|
|
868
|
+
status=0,
|
|
869
|
+
)
|
|
855
870
|
else:
|
|
856
871
|
raise InvoError(
|
|
857
|
-
"get_player_balance requires a player_email
|
|
872
|
+
"get_player_balance requires a player_email.",
|
|
858
873
|
code="INVALID_INPUT",
|
|
859
874
|
status=0,
|
|
860
875
|
)
|
|
@@ -46,11 +46,13 @@ class TestPlayerBalance:
|
|
|
46
46
|
assert b.available_balance == "1250.00"
|
|
47
47
|
assert b.total_balance == "1250.00"
|
|
48
48
|
|
|
49
|
-
def
|
|
49
|
+
def test_by_player_id_is_rejected(self):
|
|
50
|
+
# there is no by-id balance route on the backend -> clear error, no network call
|
|
50
51
|
server, calls = _server(lambda r, i: fake_response(200, {"balances": []}))
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
assert
|
|
52
|
+
with pytest.raises(InvoError) as ei:
|
|
53
|
+
server.get_player_balance(player_id=12345)
|
|
54
|
+
assert ei.value.code == "INVALID_INPUT"
|
|
55
|
+
assert len(calls) == 0
|
|
54
56
|
|
|
55
57
|
def test_requires_email_or_id(self):
|
|
56
58
|
server, calls = _server(lambda r, i: fake_response(200, {}))
|
|
@@ -99,15 +99,25 @@ class TestLightValidation:
|
|
|
99
99
|
|
|
100
100
|
@pytest.mark.parametrize(
|
|
101
101
|
"player_phone",
|
|
102
|
-
["", " ", "5555550100", "(555) 555-0100", "+0123", "+1 555 555 0100"
|
|
102
|
+
["", " ", "5555550100", "(555) 555-0100", "+0123", "+1 555 555 0100"],
|
|
103
103
|
)
|
|
104
|
-
def
|
|
104
|
+
def test_mint_validates_e164_phone_when_provided(self, player_phone):
|
|
105
|
+
# a provided-but-malformed phone still throws...
|
|
105
106
|
server, calls = _server(lambda r, i: fake_response(200, {}))
|
|
106
107
|
with pytest.raises(InvoError) as ei:
|
|
107
108
|
server.mint_player_token(player_email="p@x.com", player_phone=player_phone)
|
|
108
109
|
assert ei.value.code == "INVALID_INPUT"
|
|
109
110
|
assert len(calls) == 0
|
|
110
111
|
|
|
112
|
+
def test_mint_email_only_omits_phone(self):
|
|
113
|
+
# ...but phone is optional: an existing email-only player mints, no player_phone sent
|
|
114
|
+
server, calls = _server(
|
|
115
|
+
lambda r, i: fake_response(200, {"token": "t", "expires_at": "e", "identity_id": "id"})
|
|
116
|
+
)
|
|
117
|
+
r = server.mint_player_token(player_email="p@x.com")
|
|
118
|
+
assert r.token == "t"
|
|
119
|
+
assert calls[0].body == {"player_email": "p@x.com"}
|
|
120
|
+
|
|
111
121
|
def test_create_checkout_requires_email(self):
|
|
112
122
|
server, calls = _server(lambda r, i: fake_response(200, {}))
|
|
113
123
|
with pytest.raises(InvoError) as ei:
|
|
@@ -72,6 +72,22 @@ class TestRequestMapping:
|
|
|
72
72
|
assert r.transaction_id == "txn_1"
|
|
73
73
|
assert r.verification_method == "in_app"
|
|
74
74
|
|
|
75
|
+
def test_initiate_send_by_phone_alone_omits_receiver_email(self):
|
|
76
|
+
transport, calls = mock_transport(
|
|
77
|
+
lambda r, i: fake_response(201, {"transaction_id": "txn_p", "verification_method": "sms"})
|
|
78
|
+
)
|
|
79
|
+
make_server(transport).initiate_send(
|
|
80
|
+
client_request_id="req_p",
|
|
81
|
+
sender_player_name="S",
|
|
82
|
+
sender_player_email="s@x.com",
|
|
83
|
+
sender_player_phone="+15555550100",
|
|
84
|
+
receiver_player_phone="+15555550111", # no receiver_player_email
|
|
85
|
+
receiving_game_id=1,
|
|
86
|
+
amount="5",
|
|
87
|
+
)
|
|
88
|
+
assert calls[0].body["receiver_player_phone"] == "+15555550111"
|
|
89
|
+
assert "receiver_player_email" not in calls[0].body
|
|
90
|
+
|
|
75
91
|
def test_initiate_transfer_maps_body(self):
|
|
76
92
|
transport, calls = mock_transport(
|
|
77
93
|
lambda r, i: fake_response(201, {"transaction_id": "txn_2", "verification_method": "sms"})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|