usethatapp 2.0.0__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.
- usethatapp-2.2.0/CHANGELOG.md +335 -0
- usethatapp-2.2.0/MANIFEST.in +4 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/PKG-INFO +106 -11
- {usethatapp-2.0.0 → usethatapp-2.2.0}/README.md +105 -10
- {usethatapp-2.0.0 → usethatapp-2.2.0}/pyproject.toml +1 -1
- usethatapp-2.2.0/tests/conftest.py +96 -0
- usethatapp-2.2.0/tests/test_client.py +453 -0
- usethatapp-2.2.0/tests/test_config.py +177 -0
- usethatapp-2.2.0/tests/test_license_keys.py +196 -0
- usethatapp-2.2.0/tests/test_purchase.py +255 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp/__init__.py +61 -2
- usethatapp-2.2.0/usethatapp/client.py +969 -0
- usethatapp-2.2.0/usethatapp/config.py +322 -0
- usethatapp-2.2.0/usethatapp/errors.py +110 -0
- usethatapp-2.2.0/usethatapp/py.typed +0 -0
- usethatapp-2.2.0/usethatapp/types.py +213 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/PKG-INFO +106 -11
- {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/SOURCES.txt +6 -0
- usethatapp-2.0.0/tests/test_client.py +0 -210
- usethatapp-2.0.0/tests/test_config.py +0 -59
- usethatapp-2.0.0/usethatapp/client.py +0 -409
- usethatapp-2.0.0/usethatapp/config.py +0 -146
- usethatapp-2.0.0/usethatapp/errors.py +0 -59
- usethatapp-2.0.0/usethatapp/types.py +0 -79
- {usethatapp-2.0.0 → usethatapp-2.2.0}/LICENSE +0 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/setup.cfg +0 -0
- /usethatapp-2.0.0/usethatapp/py.typed → /usethatapp-2.2.0/tests/__init__.py +0 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp/discovery.py +0 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/dependency_links.txt +0 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/requires.txt +0 -0
- {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file. This project adheres to
|
|
4
|
+
[Semantic Versioning](https://semver.org/) and follows a clear, machine- and human-readable
|
|
5
|
+
format inspired by "Keep a Changelog".
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [2.2.0] - 2026-08-25
|
|
10
|
+
|
|
11
|
+
### Fixed (pre-release code review)
|
|
12
|
+
|
|
13
|
+
- The nine annotations the pre-release commits left untyped (most
|
|
14
|
+
visibly `UtaServerError.__init__`'s `retry_after`, which a
|
|
15
|
+
downstream strict type checker saw as `Any`) are restored —
|
|
16
|
+
`mypy --strict` is green again, as `py.typed` promises.
|
|
17
|
+
|
|
18
|
+
- `Entitlement.product_id` now carries the same
|
|
19
|
+
fall-back-to-`product_public_id` parsing as `LicenseState.product_id`,
|
|
20
|
+
so the alias contract ("gate on either field") holds in both
|
|
21
|
+
verification modes — previously only the License Key API parser had
|
|
22
|
+
the fallback.
|
|
23
|
+
- `configure(client_secret_path=…)` now outranks an environment
|
|
24
|
+
`UTA_CLIENT_SECRET`: secret-pair precedence is decided per layer
|
|
25
|
+
(overrides → Django settings → environment), not per key.
|
|
26
|
+
- `configure()` rejects wrong-typed values with a `UtaConfigError`
|
|
27
|
+
naming the option (e.g. a scopes *list* gets "join scope names with
|
|
28
|
+
spaces") instead of silently `str()`-coercing them into corrupt
|
|
29
|
+
values that surfaced later as opaque OAuth errors.
|
|
30
|
+
- A malformed `Retry-After` header containing Unicode digit-property
|
|
31
|
+
characters (e.g. `²`) no longer escapes as an uncaught `ValueError`
|
|
32
|
+
from a 429 — it degrades to `retry_after=None`.
|
|
33
|
+
- `userinfo()` and the token endpoint now speak the same 429/5xx
|
|
34
|
+
contract as every other endpoint: 429 raises retriable
|
|
35
|
+
`UtaServerError` with `retry_after` (a throttled token refresh is no
|
|
36
|
+
longer a `UtaTokenError`, which read as "log the user out"), and
|
|
37
|
+
their error messages cap quoted bodies like everywhere else.
|
|
38
|
+
- The ~200-character error-body cap now applies to JSON bodies too —
|
|
39
|
+
a multi-kilobyte JSON error (DRF validation map, debug-mode 500) was
|
|
40
|
+
previously quoted whole — and the truncation suffix reports the
|
|
41
|
+
amount actually cut.
|
|
42
|
+
- `timeout=0.0` (a fail-fast probe, expressible since the `float`
|
|
43
|
+
widening) is respected instead of being replaced by the configured
|
|
44
|
+
default in `get_entitlement`, `get_entitlement_async`, and the
|
|
45
|
+
License Key API calls.
|
|
46
|
+
- The `UtaServiceNotEnabledError` message points at the manage page on
|
|
47
|
+
`https://www.usethatapp.com` — not the resolved `api_url`, which a
|
|
48
|
+
`configure(api_url=…)` override can aim at a host with no manage UI.
|
|
49
|
+
- README: the error table names the add-on by its current public name
|
|
50
|
+
(Hosted sign-in), documents the 429 → `UtaServerError` +
|
|
51
|
+
`retry_after` mapping, and the `product_id` guidance matches the
|
|
52
|
+
post-cutover alias contract everywhere.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- `LicenseState.product_id` — an equal-valued alias of
|
|
57
|
+
`product_public_id` (the platform's post-cutover `prod_…` pair, the
|
|
58
|
+
same contract as the entitlement endpoint), with a parser fallback
|
|
59
|
+
against servers that predate the cutover.
|
|
60
|
+
|
|
61
|
+
- **License Key API** (server-side, bring-your-own-auth MoR
|
|
62
|
+
verification): `validate_license_key(key)`, `get_order(ref)` for the
|
|
63
|
+
`uta_order` checkout handback, and `regenerate_license_key(license_id)`
|
|
64
|
+
as the compromise kill switch — all authenticated with your app's
|
|
65
|
+
client credentials (`UTA_CLIENT_SECRET` required; `UTA_REDIRECT_URI`
|
|
66
|
+
is no longer needed unless you use the OIDC login flow). New
|
|
67
|
+
`LicenseState` dataclass and typed errors: `UtaNotFoundError` (with
|
|
68
|
+
`.code`), `UtaOrderProcessingError`, `UtaLicenseCanceledError`.
|
|
69
|
+
- `config.load()` no longer requires `UTA_REDIRECT_URI`; `begin_login()`
|
|
70
|
+
raises `UtaConfigError` if it is missing — keys-mode integrations
|
|
71
|
+
never redirect a browser.
|
|
72
|
+
|
|
73
|
+
- `UtaServiceNotEnabledError` (subclass of `UtaPermissionError`), raised
|
|
74
|
+
when the entitlement endpoint returns `403 service_not_enabled`: the
|
|
75
|
+
app's developer has not enabled the Hosted sign-in add-on. The
|
|
76
|
+
previous behavior mislabeled this case as a missing `entitlements`
|
|
77
|
+
scope; the new error says the actual fix (enable the add-on on the
|
|
78
|
+
app's manage page). Existing `except UtaPermissionError` blocks catch
|
|
79
|
+
it unchanged.
|
|
80
|
+
|
|
81
|
+
- In-code configuration: `configure(api_url=…, issuer=…, …)`,
|
|
82
|
+
`load_config()`, and `reset_config()` are now exported from the
|
|
83
|
+
package root, alongside `UtaConfig` and the `DEFAULT_API_URL` /
|
|
84
|
+
`DEFAULT_ISSUER` / `DEFAULT_SCOPES` constants — parity with the
|
|
85
|
+
JavaScript SDK's `configure()` / `loadConfig()` / `resetConfig()`.
|
|
86
|
+
Overrides win over Django settings and environment variables, and a
|
|
87
|
+
non-production `api_url` can now be corrected at runtime instead of
|
|
88
|
+
only through the process environment.
|
|
89
|
+
|
|
90
|
+
- `UtaServerError.retry_after` — the server's `Retry-After` header in
|
|
91
|
+
seconds on a 429 (`None` otherwise), so backoff loops can use the
|
|
92
|
+
interval the platform actually sent instead of guessing. Matches the
|
|
93
|
+
JavaScript SDK's `retryAfter`.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- `UtaNotFoundError`, `UtaOrderProcessingError`, and
|
|
98
|
+
`UtaLicenseCanceledError` are now exported from the package root —
|
|
99
|
+
they were defined and documented but not importable as
|
|
100
|
+
`from usethatapp import …`.
|
|
101
|
+
- The `UtaServiceNotEnabledError` message named the add-on by its
|
|
102
|
+
retired public name ("Auth & Entitlement") and hardcoded
|
|
103
|
+
`usethatapp.com`; it now says "Hosted sign-in" and points at the
|
|
104
|
+
configured `UTA_API_URL` host.
|
|
105
|
+
- A `429` from the entitlement endpoint now raises `UtaServerError`
|
|
106
|
+
(the retry-with-backoff class, matching the License Key API and the
|
|
107
|
+
documented error table) instead of the base `UtaError`.
|
|
108
|
+
- Non-JSON error bodies (HTML error pages) are collapsed and capped at
|
|
109
|
+
~200 characters when quoted into exception messages, instead of
|
|
110
|
+
inlining the entire page.
|
|
111
|
+
- `get_entitlement(timeout=)` and `get_entitlement_async(timeout=)` are
|
|
112
|
+
typed `Optional[float]`, matching every other function (they were
|
|
113
|
+
`Optional[int]`, failing strict type checks on e.g. `timeout=2.5`).
|
|
114
|
+
|
|
115
|
+
## [2.1.0] - 2026-08-11
|
|
116
|
+
|
|
117
|
+
Thin, dependency-free support for selling your app from your own website
|
|
118
|
+
— hosted purchase links, the buyer management page, and the public
|
|
119
|
+
(anonymous, CORS-enabled) app/pricing APIs — plus fixes to several
|
|
120
|
+
identity-integrity and error-contract bugs in the 2.0 OIDC path.
|
|
121
|
+
|
|
122
|
+
### Added
|
|
123
|
+
|
|
124
|
+
- `purchase_url(price_id, *, next=, ref=, email=)` — build the hosted
|
|
125
|
+
checkout URL for a public price id (`prc_…`). Pure URL builder, no
|
|
126
|
+
network.
|
|
127
|
+
- `manage_url(*, next=)` — build the buyer's subscription-management page
|
|
128
|
+
URL for your app. Pure URL builder, no network.
|
|
129
|
+
- `get_app_info()` / `get_app_info_async()` → `AppInfo(client_id, name,
|
|
130
|
+
tagline, listing_mode, url, marketplace_url)`, from the anonymous
|
|
131
|
+
public apps API.
|
|
132
|
+
- `get_prices()` / `get_prices_async()` → `AppPrices(client_id, app_name,
|
|
133
|
+
has_free_tier, prices)`, from the anonymous public pricing API —
|
|
134
|
+
render your pricing UI from this instead of hardcoding amounts.
|
|
135
|
+
- New frozen dataclasses: `AppInfo`, `Price` (`public_id`, `product_id`,
|
|
136
|
+
`product_name`, `amount` — a decimal string, `currency`, `is_recurring`,
|
|
137
|
+
`frequency`, `buy_url`), and `AppPrices`.
|
|
138
|
+
- `Entitlement.product_public_id` — the opaque public product identifier
|
|
139
|
+
(`prod_…`) matching `Price.product_id` from the pricing API. New
|
|
140
|
+
integrations should gate on it; `Entitlement.product_id` currently
|
|
141
|
+
carries a legacy UUID and will converge on the same `prod_…` value.
|
|
142
|
+
|
|
143
|
+
### Changed
|
|
144
|
+
|
|
145
|
+
- `refresh()` no longer sends a `scope` parameter. It previously sent the
|
|
146
|
+
full configured `UTA_SCOPES`, which is *broader* than the grant whenever
|
|
147
|
+
a login narrowed it via `begin_login(scopes=…)` — RFC 6749 §6 requires
|
|
148
|
+
the provider to reject that with `invalid_scope`, forcing a needless
|
|
149
|
+
interactive re-login. Omitting it asks for the original grant's scope.
|
|
150
|
+
- `begin_login(extra_params=…)` now raises `ValueError` when `extra_params`
|
|
151
|
+
collides with a reserved OAuth parameter (`state`, `nonce`,
|
|
152
|
+
`code_challenge`, `redirect_uri`, …). Such a value previously landed in
|
|
153
|
+
the authorization URL while `flow_state` kept the internally generated
|
|
154
|
+
one, so the paired `complete_login()` always failed with a misleading
|
|
155
|
+
"state mismatch — possible CSRF" error.
|
|
156
|
+
|
|
157
|
+
### Fixed
|
|
158
|
+
|
|
159
|
+
- `userinfo()` returned the response body as the user's claims for any
|
|
160
|
+
status other than 401/5xx. A 403 or 400 error body (e.g.
|
|
161
|
+
`{"detail": "insufficient scope"}`) was therefore treated as a valid
|
|
162
|
+
claim set, and `refresh()`'s no-ID-token fallback turned it into a
|
|
163
|
+
`UtaSession` with `sub=""` — collapsing every affected user onto one
|
|
164
|
+
empty-string local key. 403 now raises `UtaPermissionError` and any
|
|
165
|
+
other non-2xx raises `UtaError`.
|
|
166
|
+
- `refresh()` likewise accepted a 200 userinfo response with no `sub`,
|
|
167
|
+
again producing `sub=""`. It now raises `UtaTokenError`, matching what
|
|
168
|
+
`complete_login()` already did for the same case.
|
|
169
|
+
- ID-token validation refetched the JWKS on *any* signature or decode
|
|
170
|
+
failure, so every malformed or forged token cost a blocking HTTPS
|
|
171
|
+
round-trip, and a network blip during that refetch surfaced
|
|
172
|
+
`UtaDiscoveryError` in place of the real validation error. The refetch
|
|
173
|
+
now happens only when the token references a `kid` that is not cached
|
|
174
|
+
(genuine key rotation).
|
|
175
|
+
- `purchase_url()` validated `price_id` with `strip()` but interpolated
|
|
176
|
+
the raw value into the path, so a stray-whitespace or `/?#`-bearing id
|
|
177
|
+
produced a broken checkout link. It is now stripped and percent-encoded.
|
|
178
|
+
- `get_prices()` / `get_prices_async()` raised a bare `AttributeError` when
|
|
179
|
+
a price entry was not a JSON object, escaping the documented "catch
|
|
180
|
+
`UtaError`" contract. Malformed entries now raise `UtaError`.
|
|
181
|
+
- Gating guidance: the README quickstart and the `Entitlement.product_id`
|
|
182
|
+
docstring still pointed feature gating at `product_id` (a legacy UUID),
|
|
183
|
+
which locks out every paying customer. Both now point at
|
|
184
|
+
`product_public_id`.
|
|
185
|
+
|
|
186
|
+
## [2.0.0] - 2026-06-21
|
|
187
|
+
|
|
188
|
+
Breaking rewrite onto standard OAuth2 / OpenID Connect. usethatapp.com is
|
|
189
|
+
now an OpenID Provider; the SDK is a framework-agnostic OIDC client. The
|
|
190
|
+
v1 launch-envelope push + signed `get_version` pull are gone.
|
|
191
|
+
|
|
192
|
+
### Removed
|
|
193
|
+
|
|
194
|
+
- `get_user` / `get_user_from_request` / `get_user_from_request_async` and
|
|
195
|
+
the encrypted launch-envelope handling (`usethatapp.payloads`).
|
|
196
|
+
- `get_version` / `get_version_async` and the process-local version cache.
|
|
197
|
+
- `uta_launch_view` Django decorator and `usethatapp.django_helpers` — the
|
|
198
|
+
SDK no longer ships any framework-specific code.
|
|
199
|
+
- `UtaUser` (and its `user_key` / `version_hint`).
|
|
200
|
+
- RSA-key configuration (`UTA_PRIVATE_KEY[_PATH]`,
|
|
201
|
+
`UTA_MARKET_PUBLIC_KEY[_PATH]`) and `UTA_APP_ID`.
|
|
202
|
+
- The bundled `cryptography` dependency (now pulled transitively by
|
|
203
|
+
`joserfc`).
|
|
204
|
+
|
|
205
|
+
### Added
|
|
206
|
+
|
|
207
|
+
- OIDC login flow:
|
|
208
|
+
- `begin_login()` → `(authorization_url, flow_state)` (authorization
|
|
209
|
+
code + PKCE; `flow_state` is a JSON-able dict you stash in the session).
|
|
210
|
+
- `complete_login(code=, state=, flow_state=)` → `UtaSession`, validating
|
|
211
|
+
`state`, exchanging the code, and verifying the ID token (signature via
|
|
212
|
+
JWKS, `iss`/`aud`/`exp`/`nonce`).
|
|
213
|
+
- `refresh(refresh_token)`, `userinfo(access_token)`, `logout_url(...)`.
|
|
214
|
+
- `get_entitlement(access_token)` / `get_entitlement_async(...)` →
|
|
215
|
+
`Entitlement(entitled, version, product_id, status, is_free, period_end)`,
|
|
216
|
+
the Bearer-token replacement for `get_version`.
|
|
217
|
+
- `UtaSession` (carries the pairwise pseudonymous `sub` + tokens) and
|
|
218
|
+
`Entitlement` dataclasses.
|
|
219
|
+
- New config: `UTA_CLIENT_ID`, `UTA_CLIENT_SECRET[_PATH]`,
|
|
220
|
+
`UTA_REDIRECT_URI`, `UTA_ISSUER`, `UTA_SCOPES`.
|
|
221
|
+
- New typed errors: `UtaDiscoveryError`, `UtaAuthError`, `UtaTokenError`,
|
|
222
|
+
`UtaPermissionError` (replacing the v1 envelope/session error set).
|
|
223
|
+
|
|
224
|
+
### Changed
|
|
225
|
+
|
|
226
|
+
- Runtime dependencies are now `httpx` + `joserfc`.
|
|
227
|
+
- Identity is a pairwise, per-app pseudonymous `sub` — stable within your
|
|
228
|
+
app, uncorrelatable across apps. Key your user records off `sub`.
|
|
229
|
+
|
|
230
|
+
## [1.0.0] - 2026-05-21
|
|
231
|
+
|
|
232
|
+
Breaking rewrite for the new usethatapp.com webhook-based handoff. The browser
|
|
233
|
+
iframe / `usethatapp.js` model has been retired in favor of a server-to-server
|
|
234
|
+
push + pull flow.
|
|
235
|
+
|
|
236
|
+
### Removed
|
|
237
|
+
|
|
238
|
+
- `usethatapp.js` integration, `requestAccessLevel()` JS bridge, and all
|
|
239
|
+
iframe / `postMessage` handling.
|
|
240
|
+
- `usethatapp.webapps.get_version(envelope, public_key_path, private_key_path)` —
|
|
241
|
+
the old envelope (`type`/`responseTo`/`message{contents,signature}`) is no
|
|
242
|
+
longer accepted.
|
|
243
|
+
- `usethatapp.encryption` module (`Keys`, `decrypt_message`, `verify_signature`).
|
|
244
|
+
PEM key loading is now handled internally by `usethatapp.config`.
|
|
245
|
+
- `uid` / `username` fields on the returned user object. The v1 envelope carries
|
|
246
|
+
only an opaque `user_key`.
|
|
247
|
+
|
|
248
|
+
### Added
|
|
249
|
+
|
|
250
|
+
- New top-level public API:
|
|
251
|
+
- `get_user(payload)` — verify + decrypt the launch envelope POSTed by
|
|
252
|
+
the marketplace. Framework-agnostic; takes the raw `uta_payload`
|
|
253
|
+
string or already-parsed mapping.
|
|
254
|
+
- `get_user_from_request(request)` and `get_user_from_request_async(request)`
|
|
255
|
+
— request-aware helpers that pull `uta_payload` directly out of a
|
|
256
|
+
Django / Flask / Werkzeug / Starlette request and forward to `get_user`.
|
|
257
|
+
- `get_version(user_key)` and `get_version_async(user_key)` — signed
|
|
258
|
+
server-to-server POST to `https://usethatapp.com/licensing/getversion/`,
|
|
259
|
+
returning the current product name or `None`. Honors a process-local TTL
|
|
260
|
+
cache keyed off the server's `cache_until`.
|
|
261
|
+
- `UtaUser` frozen dataclass (`user_key`, `app_id`, `issued_at`,
|
|
262
|
+
`expires_at`, `version_hint`).
|
|
263
|
+
- `uta_launch_view` Django decorator (csrf-exempt, POST-only, injects
|
|
264
|
+
`request.uta_user`).
|
|
265
|
+
- Hybrid envelope crypto in `usethatapp.payloads`:
|
|
266
|
+
`RSA-OAEP-SHA256 + AES-256-GCM + RSA-PSS-SHA256`. The PSS signature now
|
|
267
|
+
covers `ek || iv || ct` (not the plaintext).
|
|
268
|
+
- Typed exception hierarchy under `UtaError`: `UtaSignatureError`,
|
|
269
|
+
`UtaPayloadExpiredError`, `UtaAppMismatchError`, `UtaBadRequestError`,
|
|
270
|
+
`UtaSessionRevokedError`, `UtaUnknownSessionError`, `UtaServerError`,
|
|
271
|
+
`UtaConfigError`. Every failure mode (local validation + each HTTP status)
|
|
272
|
+
maps to a specific subclass.
|
|
273
|
+
- `usethatapp.config.load()` settings resolver reading from
|
|
274
|
+
`django.conf.settings` then `os.environ`. New settings:
|
|
275
|
+
`UTA_APP_ID`, `UTA_PRIVATE_KEY`, `UTA_PRIVATE_KEY_PATH`,
|
|
276
|
+
`UTA_MARKET_PUBLIC_KEY`, `UTA_MARKET_PUBLIC_KEY_PATH`, `UTA_API_URL`,
|
|
277
|
+
`UTA_CLOCK_SKEW_SECONDS`, `UTA_REQUEST_TIMEOUT_SECONDS`. The
|
|
278
|
+
`*_PATH` variants read the PEM from a file at boot (intended for
|
|
279
|
+
hosting providers that mount secret files into the container);
|
|
280
|
+
direct values take precedence when both are set.
|
|
281
|
+
- `py.typed` marker — package now ships type information.
|
|
282
|
+
- `httpx` runtime dependency (sync + async HTTP).
|
|
283
|
+
|
|
284
|
+
### Changed
|
|
285
|
+
|
|
286
|
+
- Minimum Python is now 3.9.
|
|
287
|
+
- `cryptography` constraint relaxed to `>=42`.
|
|
288
|
+
|
|
289
|
+
### Migration
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
# Before (0.x)
|
|
293
|
+
from usethatapp.webapps import get_version
|
|
294
|
+
version = get_version(envelope, "pub.pem", "priv.pem")
|
|
295
|
+
|
|
296
|
+
# After (1.0)
|
|
297
|
+
from usethatapp import get_user_from_request, get_version
|
|
298
|
+
|
|
299
|
+
# In your launch view (Django shown):
|
|
300
|
+
user = get_user_from_request(request) # verifies envelope, returns UtaUser
|
|
301
|
+
session["uta_user_key"] = user.user_key
|
|
302
|
+
|
|
303
|
+
# Later, whenever you need the live tier:
|
|
304
|
+
version = get_version(user.user_key) # str | None
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## [0.3.0] - 2026-04-10
|
|
308
|
+
|
|
309
|
+
### Changed
|
|
310
|
+
|
|
311
|
+
- Breaking change: `get_version` now accepts a full `requestAccessLevel()` envelope instead of
|
|
312
|
+
a flat message dict. The first parameter has been renamed from `message` to `envelope` and
|
|
313
|
+
must contain a `type` field (`"level"`) and a nested `message` dict with `contents` and
|
|
314
|
+
`signature`.
|
|
315
|
+
- Error envelopes (`type == "error"`) are now detected and raise a `ValueError` with the
|
|
316
|
+
server's error description.
|
|
317
|
+
- Envelope `type` is validated; unexpected types raise a `ValueError`.
|
|
318
|
+
|
|
319
|
+
## [0.2.0] - 2026-03-29
|
|
320
|
+
|
|
321
|
+
### Changed
|
|
322
|
+
|
|
323
|
+
- Breaking change: renamed `get_product` -> `get_version` to better reflect the function's
|
|
324
|
+
purpose and improve clarity of the public API.
|
|
325
|
+
|
|
326
|
+
## [0.1.0] - 2026-03-19
|
|
327
|
+
|
|
328
|
+
### Added
|
|
329
|
+
|
|
330
|
+
- Initial release: introduced `get_product` (now renamed to `get_version`) to retrieve
|
|
331
|
+
licensing or version information from signed/encrypted messages.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
For more details, including commit-level history, see the project's Git repository.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usethatapp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries.
|
|
5
5
|
Author-email: UseThatApp <support@usethatapp.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -99,11 +99,22 @@ from usethatapp import (
|
|
|
99
99
|
refresh, # (refresh_token) -> UtaSession
|
|
100
100
|
userinfo, # (access_token) -> {"sub": ...}
|
|
101
101
|
logout_url, # (id_token=, post_logout_redirect_uri=) -> str
|
|
102
|
+
purchase_url, # (price_id, *, next=, ref=, email=) -> str (no network)
|
|
103
|
+
manage_url, # (*, next=) -> str (no network)
|
|
104
|
+
get_app_info, # () -> AppInfo (anonymous public API)
|
|
105
|
+
get_app_info_async,
|
|
106
|
+
get_prices, # () -> AppPrices (anonymous public API)
|
|
107
|
+
get_prices_async,
|
|
102
108
|
UtaSession, # sub, access_token, refresh_token, id_token, expires_at, ...
|
|
103
|
-
Entitlement, # entitled, version, product_id,
|
|
109
|
+
Entitlement, # entitled, version, product_id, product_public_id, status, ...
|
|
110
|
+
AppInfo, # client_id, name, tagline, listing_mode, url, marketplace_url
|
|
111
|
+
Price, # public_id, product_id, product_name, amount, currency, ...
|
|
112
|
+
AppPrices, # client_id, app_name, has_free_tier, prices
|
|
104
113
|
# errors:
|
|
105
114
|
UtaError, UtaConfigError, UtaDiscoveryError, UtaAuthError,
|
|
106
|
-
UtaTokenError, UtaPermissionError,
|
|
115
|
+
UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError,
|
|
116
|
+
UtaNotFoundError, UtaOrderProcessingError, UtaLicenseCanceledError,
|
|
117
|
+
UtaServerError,
|
|
107
118
|
)
|
|
108
119
|
```
|
|
109
120
|
|
|
@@ -129,9 +140,10 @@ session = complete_login(
|
|
|
129
140
|
save_to_session("uta_sub", session.sub)
|
|
130
141
|
save_to_session("uta_access_token", session.access_token)
|
|
131
142
|
|
|
132
|
-
# 3) Anywhere you gate features
|
|
143
|
+
# 3) Anywhere you gate features. Gate on product_public_id — the opaque
|
|
144
|
+
# prod_… id that matches Price.product_id from get_prices():
|
|
133
145
|
ent = get_entitlement(load_from_session("uta_access_token"))
|
|
134
|
-
if ent.entitled and ent.
|
|
146
|
+
if ent.entitled and ent.product_public_id == "prod_...":
|
|
135
147
|
...
|
|
136
148
|
```
|
|
137
149
|
|
|
@@ -142,14 +154,97 @@ are documentation only — nothing framework-specific ships in the package.
|
|
|
142
154
|
|
|
143
155
|
`get_entitlement` maps status codes to typed exceptions:
|
|
144
156
|
|
|
145
|
-
| Status | Exception
|
|
146
|
-
|
|
147
|
-
| 401 | `UtaTokenError`
|
|
148
|
-
| 403
|
|
149
|
-
|
|
|
150
|
-
|
|
|
157
|
+
| Status | Exception | Meaning |
|
|
158
|
+
|--------|-----------------------------------------------|-----------------------------------------------|
|
|
159
|
+
| 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
|
|
160
|
+
| 403 (`insufficient_scope`) | `UtaPermissionError` | Token lacks the `entitlements` scope. |
|
|
161
|
+
| 403 (`service_not_enabled`) | `UtaServiceNotEnabledError` | The Hosted sign-in add-on is switched off for this app. No retry, refresh, or re-consent will help — enable it on the app's manage page at www.usethatapp.com (Integration panel). |
|
|
162
|
+
| 400 | `UtaError` | Client not linked to an app (misconfig). |
|
|
163
|
+
| 429 | `UtaServerError` | Rate limited — back off; `e.retry_after` carries the server's `Retry-After` seconds (or `None`). |
|
|
164
|
+
| 5xx | `UtaServerError` | Retriable with backoff; `e.retry_after` when the server sent one. |
|
|
151
165
|
|
|
152
166
|
All inherit from `UtaError` — catch that for a single `except` clause.
|
|
167
|
+
`UtaServiceNotEnabledError` subclasses `UtaPermissionError`, so existing
|
|
168
|
+
`except UtaPermissionError` blocks keep working unchanged.
|
|
169
|
+
|
|
170
|
+
## License Key API — bring your own auth
|
|
171
|
+
|
|
172
|
+
If you keep your own auth (Auth0, Clerk, homegrown) and use UseThatApp
|
|
173
|
+
purely as merchant of record, skip the OIDC flow entirely and verify
|
|
174
|
+
purchases with license keys. Server-side only; set `UTA_CLIENT_SECRET`
|
|
175
|
+
(`UTA_REDIRECT_URI` is not needed for this mode).
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from usethatapp import get_order, regenerate_license_key, validate_license_key
|
|
179
|
+
|
|
180
|
+
# 1) The buyer returns from checkout with ?uta_order=... — exchange it
|
|
181
|
+
# for their key and link it to YOUR signed-in user. No email matching.
|
|
182
|
+
order = get_order(request.GET["uta_order"]) # raises UtaOrderProcessingError → retry briefly
|
|
183
|
+
your_db.save(user=current_user, license_key=order.license_key,
|
|
184
|
+
license_id=order.license_id)
|
|
185
|
+
|
|
186
|
+
# 2) Any time you need the truth (app start, feature gate, cron):
|
|
187
|
+
state = validate_license_key(stored_key) # LicenseState
|
|
188
|
+
if state.entitled: ... # cache against state.period_end
|
|
189
|
+
# canceled purchases return entitled=False, status="canceled" — an
|
|
190
|
+
# answer, not an exception. UtaNotFoundError means a key that was
|
|
191
|
+
# never issued for your app.
|
|
192
|
+
|
|
193
|
+
# 3) Compromised key? Rotate it — the old key dies instantly:
|
|
194
|
+
state = regenerate_license_key(license_id) # state.license_key is the new key
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Buyers can always re-view their key on their UseThatApp management
|
|
198
|
+
page, so "I lost my key" needs no support ticket and no rotation.
|
|
199
|
+
|
|
200
|
+
## Purchase links & pricing
|
|
201
|
+
|
|
202
|
+
usethatapp.com can sell your app **from your own website**: hosted
|
|
203
|
+
checkout links, a buyer management page, and a public pricing API. The
|
|
204
|
+
SDK helpers are thin — the URL builders make no network calls, and the
|
|
205
|
+
two GET functions hit **anonymous** endpoints (no auth, CORS-enabled), so
|
|
206
|
+
they work even on a marketing page that never logs anyone in.
|
|
207
|
+
|
|
208
|
+
Gate an upgrade CTA on the entitlement, then send the user to checkout:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from usethatapp import get_entitlement, manage_url, purchase_url
|
|
212
|
+
|
|
213
|
+
ent = get_entitlement(load_from_session("uta_access_token"))
|
|
214
|
+
if not ent.entitled:
|
|
215
|
+
# Hosted checkout. `next` must be an HTTPS URL on your registered
|
|
216
|
+
# domain (validated server-side); omit it to land on your Login URL.
|
|
217
|
+
return redirect(purchase_url("prc_...", next="https://yourapp.example/welcome"))
|
|
218
|
+
|
|
219
|
+
# Existing buyers manage (or cancel) their plan on usethatapp.com:
|
|
220
|
+
billing_link = manage_url(next="https://yourapp.example/account")
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Render pricing from the live API instead of hardcoding — sellers can
|
|
224
|
+
change prices anytime:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from usethatapp import get_prices
|
|
228
|
+
|
|
229
|
+
pricing = get_prices() # anonymous — no token needed
|
|
230
|
+
for price in pricing.prices:
|
|
231
|
+
per = f"/{price.frequency}" if price.is_recurring else " one-time"
|
|
232
|
+
print(f"{price.product_name}: {price.amount} {price.currency}{per}")
|
|
233
|
+
print(f" buy: {price.buy_url}") # ready-made hosted checkout link
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
|
|
237
|
+
`decimal.Decimal` if you need arithmetic. `Price.product_id` is the
|
|
238
|
+
opaque `prod_…` identifier to gate features on after purchase — compare
|
|
239
|
+
it against `Entitlement.product_id` or `Entitlement.product_public_id`:
|
|
240
|
+
since the platform's identifier cutover they are a permanent
|
|
241
|
+
equal-valued alias pair carrying the same `prod_…` value, so gate on
|
|
242
|
+
either.
|
|
243
|
+
|
|
244
|
+
Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
|
|
245
|
+
are handled for you). Purchases are currently **US-only**. External
|
|
246
|
+
sales is in beta — buy links return **404 until it's enabled for your
|
|
247
|
+
app**. Full guide: [docs.usethatapp.com](https://docs.usethatapp.com).
|
|
153
248
|
|
|
154
249
|
## Signing out
|
|
155
250
|
|
|
@@ -66,11 +66,22 @@ from usethatapp import (
|
|
|
66
66
|
refresh, # (refresh_token) -> UtaSession
|
|
67
67
|
userinfo, # (access_token) -> {"sub": ...}
|
|
68
68
|
logout_url, # (id_token=, post_logout_redirect_uri=) -> str
|
|
69
|
+
purchase_url, # (price_id, *, next=, ref=, email=) -> str (no network)
|
|
70
|
+
manage_url, # (*, next=) -> str (no network)
|
|
71
|
+
get_app_info, # () -> AppInfo (anonymous public API)
|
|
72
|
+
get_app_info_async,
|
|
73
|
+
get_prices, # () -> AppPrices (anonymous public API)
|
|
74
|
+
get_prices_async,
|
|
69
75
|
UtaSession, # sub, access_token, refresh_token, id_token, expires_at, ...
|
|
70
|
-
Entitlement, # entitled, version, product_id,
|
|
76
|
+
Entitlement, # entitled, version, product_id, product_public_id, status, ...
|
|
77
|
+
AppInfo, # client_id, name, tagline, listing_mode, url, marketplace_url
|
|
78
|
+
Price, # public_id, product_id, product_name, amount, currency, ...
|
|
79
|
+
AppPrices, # client_id, app_name, has_free_tier, prices
|
|
71
80
|
# errors:
|
|
72
81
|
UtaError, UtaConfigError, UtaDiscoveryError, UtaAuthError,
|
|
73
|
-
UtaTokenError, UtaPermissionError,
|
|
82
|
+
UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError,
|
|
83
|
+
UtaNotFoundError, UtaOrderProcessingError, UtaLicenseCanceledError,
|
|
84
|
+
UtaServerError,
|
|
74
85
|
)
|
|
75
86
|
```
|
|
76
87
|
|
|
@@ -96,9 +107,10 @@ session = complete_login(
|
|
|
96
107
|
save_to_session("uta_sub", session.sub)
|
|
97
108
|
save_to_session("uta_access_token", session.access_token)
|
|
98
109
|
|
|
99
|
-
# 3) Anywhere you gate features
|
|
110
|
+
# 3) Anywhere you gate features. Gate on product_public_id — the opaque
|
|
111
|
+
# prod_… id that matches Price.product_id from get_prices():
|
|
100
112
|
ent = get_entitlement(load_from_session("uta_access_token"))
|
|
101
|
-
if ent.entitled and ent.
|
|
113
|
+
if ent.entitled and ent.product_public_id == "prod_...":
|
|
102
114
|
...
|
|
103
115
|
```
|
|
104
116
|
|
|
@@ -109,14 +121,97 @@ are documentation only — nothing framework-specific ships in the package.
|
|
|
109
121
|
|
|
110
122
|
`get_entitlement` maps status codes to typed exceptions:
|
|
111
123
|
|
|
112
|
-
| Status | Exception
|
|
113
|
-
|
|
114
|
-
| 401 | `UtaTokenError`
|
|
115
|
-
| 403
|
|
116
|
-
|
|
|
117
|
-
|
|
|
124
|
+
| Status | Exception | Meaning |
|
|
125
|
+
|--------|-----------------------------------------------|-----------------------------------------------|
|
|
126
|
+
| 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
|
|
127
|
+
| 403 (`insufficient_scope`) | `UtaPermissionError` | Token lacks the `entitlements` scope. |
|
|
128
|
+
| 403 (`service_not_enabled`) | `UtaServiceNotEnabledError` | The Hosted sign-in add-on is switched off for this app. No retry, refresh, or re-consent will help — enable it on the app's manage page at www.usethatapp.com (Integration panel). |
|
|
129
|
+
| 400 | `UtaError` | Client not linked to an app (misconfig). |
|
|
130
|
+
| 429 | `UtaServerError` | Rate limited — back off; `e.retry_after` carries the server's `Retry-After` seconds (or `None`). |
|
|
131
|
+
| 5xx | `UtaServerError` | Retriable with backoff; `e.retry_after` when the server sent one. |
|
|
118
132
|
|
|
119
133
|
All inherit from `UtaError` — catch that for a single `except` clause.
|
|
134
|
+
`UtaServiceNotEnabledError` subclasses `UtaPermissionError`, so existing
|
|
135
|
+
`except UtaPermissionError` blocks keep working unchanged.
|
|
136
|
+
|
|
137
|
+
## License Key API — bring your own auth
|
|
138
|
+
|
|
139
|
+
If you keep your own auth (Auth0, Clerk, homegrown) and use UseThatApp
|
|
140
|
+
purely as merchant of record, skip the OIDC flow entirely and verify
|
|
141
|
+
purchases with license keys. Server-side only; set `UTA_CLIENT_SECRET`
|
|
142
|
+
(`UTA_REDIRECT_URI` is not needed for this mode).
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from usethatapp import get_order, regenerate_license_key, validate_license_key
|
|
146
|
+
|
|
147
|
+
# 1) The buyer returns from checkout with ?uta_order=... — exchange it
|
|
148
|
+
# for their key and link it to YOUR signed-in user. No email matching.
|
|
149
|
+
order = get_order(request.GET["uta_order"]) # raises UtaOrderProcessingError → retry briefly
|
|
150
|
+
your_db.save(user=current_user, license_key=order.license_key,
|
|
151
|
+
license_id=order.license_id)
|
|
152
|
+
|
|
153
|
+
# 2) Any time you need the truth (app start, feature gate, cron):
|
|
154
|
+
state = validate_license_key(stored_key) # LicenseState
|
|
155
|
+
if state.entitled: ... # cache against state.period_end
|
|
156
|
+
# canceled purchases return entitled=False, status="canceled" — an
|
|
157
|
+
# answer, not an exception. UtaNotFoundError means a key that was
|
|
158
|
+
# never issued for your app.
|
|
159
|
+
|
|
160
|
+
# 3) Compromised key? Rotate it — the old key dies instantly:
|
|
161
|
+
state = regenerate_license_key(license_id) # state.license_key is the new key
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Buyers can always re-view their key on their UseThatApp management
|
|
165
|
+
page, so "I lost my key" needs no support ticket and no rotation.
|
|
166
|
+
|
|
167
|
+
## Purchase links & pricing
|
|
168
|
+
|
|
169
|
+
usethatapp.com can sell your app **from your own website**: hosted
|
|
170
|
+
checkout links, a buyer management page, and a public pricing API. The
|
|
171
|
+
SDK helpers are thin — the URL builders make no network calls, and the
|
|
172
|
+
two GET functions hit **anonymous** endpoints (no auth, CORS-enabled), so
|
|
173
|
+
they work even on a marketing page that never logs anyone in.
|
|
174
|
+
|
|
175
|
+
Gate an upgrade CTA on the entitlement, then send the user to checkout:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from usethatapp import get_entitlement, manage_url, purchase_url
|
|
179
|
+
|
|
180
|
+
ent = get_entitlement(load_from_session("uta_access_token"))
|
|
181
|
+
if not ent.entitled:
|
|
182
|
+
# Hosted checkout. `next` must be an HTTPS URL on your registered
|
|
183
|
+
# domain (validated server-side); omit it to land on your Login URL.
|
|
184
|
+
return redirect(purchase_url("prc_...", next="https://yourapp.example/welcome"))
|
|
185
|
+
|
|
186
|
+
# Existing buyers manage (or cancel) their plan on usethatapp.com:
|
|
187
|
+
billing_link = manage_url(next="https://yourapp.example/account")
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Render pricing from the live API instead of hardcoding — sellers can
|
|
191
|
+
change prices anytime:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from usethatapp import get_prices
|
|
195
|
+
|
|
196
|
+
pricing = get_prices() # anonymous — no token needed
|
|
197
|
+
for price in pricing.prices:
|
|
198
|
+
per = f"/{price.frequency}" if price.is_recurring else " one-time"
|
|
199
|
+
print(f"{price.product_name}: {price.amount} {price.currency}{per}")
|
|
200
|
+
print(f" buy: {price.buy_url}") # ready-made hosted checkout link
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
|
|
204
|
+
`decimal.Decimal` if you need arithmetic. `Price.product_id` is the
|
|
205
|
+
opaque `prod_…` identifier to gate features on after purchase — compare
|
|
206
|
+
it against `Entitlement.product_id` or `Entitlement.product_public_id`:
|
|
207
|
+
since the platform's identifier cutover they are a permanent
|
|
208
|
+
equal-valued alias pair carrying the same `prod_…` value, so gate on
|
|
209
|
+
either.
|
|
210
|
+
|
|
211
|
+
Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
|
|
212
|
+
are handled for you). Purchases are currently **US-only**. External
|
|
213
|
+
sales is in beta — buy links return **404 until it's enabled for your
|
|
214
|
+
app**. Full guide: [docs.usethatapp.com](https://docs.usethatapp.com).
|
|
120
215
|
|
|
121
216
|
## Signing out
|
|
122
217
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "usethatapp"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
authors = [{ name = "UseThatApp", email = "support@usethatapp.com"}]
|