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.
Files changed (31) hide show
  1. usethatapp-2.2.0/CHANGELOG.md +335 -0
  2. usethatapp-2.2.0/MANIFEST.in +4 -0
  3. {usethatapp-2.0.0 → usethatapp-2.2.0}/PKG-INFO +106 -11
  4. {usethatapp-2.0.0 → usethatapp-2.2.0}/README.md +105 -10
  5. {usethatapp-2.0.0 → usethatapp-2.2.0}/pyproject.toml +1 -1
  6. usethatapp-2.2.0/tests/conftest.py +96 -0
  7. usethatapp-2.2.0/tests/test_client.py +453 -0
  8. usethatapp-2.2.0/tests/test_config.py +177 -0
  9. usethatapp-2.2.0/tests/test_license_keys.py +196 -0
  10. usethatapp-2.2.0/tests/test_purchase.py +255 -0
  11. {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp/__init__.py +61 -2
  12. usethatapp-2.2.0/usethatapp/client.py +969 -0
  13. usethatapp-2.2.0/usethatapp/config.py +322 -0
  14. usethatapp-2.2.0/usethatapp/errors.py +110 -0
  15. usethatapp-2.2.0/usethatapp/py.typed +0 -0
  16. usethatapp-2.2.0/usethatapp/types.py +213 -0
  17. {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/PKG-INFO +106 -11
  18. {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/SOURCES.txt +6 -0
  19. usethatapp-2.0.0/tests/test_client.py +0 -210
  20. usethatapp-2.0.0/tests/test_config.py +0 -59
  21. usethatapp-2.0.0/usethatapp/client.py +0 -409
  22. usethatapp-2.0.0/usethatapp/config.py +0 -146
  23. usethatapp-2.0.0/usethatapp/errors.py +0 -59
  24. usethatapp-2.0.0/usethatapp/types.py +0 -79
  25. {usethatapp-2.0.0 → usethatapp-2.2.0}/LICENSE +0 -0
  26. {usethatapp-2.0.0 → usethatapp-2.2.0}/setup.cfg +0 -0
  27. /usethatapp-2.0.0/usethatapp/py.typed → /usethatapp-2.2.0/tests/__init__.py +0 -0
  28. {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp/discovery.py +0 -0
  29. {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/dependency_links.txt +0 -0
  30. {usethatapp-2.0.0 → usethatapp-2.2.0}/usethatapp.egg-info/requires.txt +0 -0
  31. {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.
@@ -0,0 +1,4 @@
1
+ # setuptools' sdist defaults pick up tests/test_*.py but not conftest.py or
2
+ # the package marker, which ships a test suite that cannot be collected.
3
+ include CHANGELOG.md
4
+ recursive-include tests *.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usethatapp
3
- Version: 2.0.0
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, status, is_free, period_end
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, UtaServerError,
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.product_id == "...":
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 | Meaning |
146
- |--------|----------------------|-----------------------------------------------|
147
- | 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
148
- | 403 | `UtaPermissionError` | Token lacks the `entitlements` scope. |
149
- | 400 | `UtaError` | Client not linked to an app (misconfig). |
150
- | 5xx | `UtaServerError` | Retriable with backoff. |
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, status, is_free, period_end
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, UtaServerError,
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.product_id == "...":
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 | Meaning |
113
- |--------|----------------------|-----------------------------------------------|
114
- | 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
115
- | 403 | `UtaPermissionError` | Token lacks the `entitlements` scope. |
116
- | 400 | `UtaError` | Client not linked to an app (misconfig). |
117
- | 5xx | `UtaServerError` | Retriable with backoff. |
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.0.0"
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"}]