Realign Python SDK with TS SDK - #13
Merged
Merged
Conversation
This is not yet including the changes merged to it from #33 and #34
Aligns with TS SDK PR #34 (capture-v2). Emits the richer spoof-detection signals on the analytics event as schema_version 2; the warehouse keeps doing classification at query time — the SDK emits raw signals only. - Portable header signals (read from the httpx Request): sec_fetch_*, sec_ch_ua*, accept, host, has_cookies, and header_names (lowercased, deduped, sorted; edge-injected cf-*/fastly-*/cloudfront-*/x-forwarded-*/ x-real-ip/x-original-request-url stripped — plus the synthesized Host, which httpx adds on construction but the JS fetch Request hides, so the cross-SDK header-name set stays consistent). - Query-string derived signals: query_length, query_param_count, query_suspicious. The raw query is never stored. - CDN plumbing not derivable from the portable Request is supplied by the caller via a new CdnRequestSignals object threaded through HandleRequestContext.cdn_signals (mirrors TS's cdnSignals handler-context field; Python takes the signals from the caller rather than porting the edge handlers). - 512-char truncation on accept / sec_ch_ua / as_organization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aligns with TS SDK PR #33 (FastlyLogTransport / logEndpoint). The native Fastly Compute logging transport is intentionally not ported: Python does not run on Fastly Compute (no fastly:logger equivalent), and the Python SDK does not embed CDN edge handlers — it receives CDN signals via HandleRequestContext. Documents the gap and points to the AnalyticsTransport protocol for custom, non-relay delivery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the schema_version 2 analytics fields added in the previous commit: the portable header signals (sec_fetch_*, client hints, header_names, …), the query-string derived signals, and the CdnRequestSignals plumbing passed through HandleRequestContext.cdn_signals — mirroring TS SDK PR #34's README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR realigns the Python SDK’s enforcement semantics and request/analytics behavior with the TypeScript SDK by renaming enforcement modes, adding relay analytics emission (schema v2), and standardizing SDK User-Agent headers across outbound HTTP calls.
Changes:
- Renames merchant enforcement modes to
OBSERVE/ENFORCEand updates handling logic + tests/examples/docs accordingly. - Adds a new
supertab_connect.analyticsmodule (event schema, event builder, IP normalization, transports) and wires analytics emission intoSupertabConnect.handle_request(...)behindanalytics_enabled. - Ensures SDK
User-Agentis sent for JWKS/customer/token/analytics HTTP clients (with added tests).
Reviewed changes
Copilot reviewed 21 out of 22 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/merchant/test_jwks.py | Adds coverage to ensure JWKS fetch requests include SDK User-Agent. |
| tests/merchant/test_client.py | Updates tests for renamed enforcement modes. |
| tests/merchant/test_client_analytics.py | New tests validating analytics emission from SupertabConnect.handle_request. |
| tests/customer/test_tokens.py | Adds coverage to ensure customer HTTP client includes SDK User-Agent. |
| tests/analytics/test_transport.py | New tests for analytics HTTP/noop transports and fire-and-forget behavior. |
| tests/analytics/test_ip.py | New tests for client-IP normalization logic. |
| tests/analytics/test_build_analytics_event.py | New tests for analytics event construction and schema v2 capture rules. |
| tests/analytics/conftest.py | Adds autouse fixture to reset analytics module-level HTTP client between tests. |
| tests/analytics/init.py | Initializes analytics test package. |
| supertab_connect/types.py | Renames enforcement modes; adds analytics config fields and HandleRequestContext. |
| supertab_connect/merchant/jwks.py | Sets SDK User-Agent on the shared JWKS AsyncClient. |
| supertab_connect/merchant/client.py | Wires analytics transport + event emission into handle_request, adds context support. |
| supertab_connect/customer/token.py | Sets SDK User-Agent on the customer AsyncClient used for license.xml/token calls. |
| supertab_connect/analytics/types.py | Defines analytics event schema, transport protocol, and token-outcome mapping. |
| supertab_connect/analytics/transport.py | Implements noop + HTTP relay transports with background task handling. |
| supertab_connect/analytics/ip.py | Adds client-IP normalization helper. |
| supertab_connect/analytics/build_analytics_event.py | Implements request→event builder including capture-v2 signals and truncation rules. |
| supertab_connect/analytics/init.py | Exposes analytics public surface and re-exports key symbols. |
| supertab_connect/init.py | Re-exports analytics types and HandleRequestContext from package root. |
| README.md | Documents updated handle_request semantics and the new analytics feature/configuration. |
| examples/merchant_verify_and_record_event.py | Updates example to use EnforcementMode.OBSERVE. |
| examples/merchant_handle_request.py | Updates example to use EnforcementMode.ENFORCE. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Serve GET /.well-known/supertab/status from handle_request: on a valid
backend-signed challenge (ES256 JWT scoped to the site origin, purpose
"status-probe") return the live SDK config; otherwise a minimal
{"supertab": true} 404. Powers the portal's live-health view.
Adds HandlerAction.RESPOND (a response the caller serves verbatim without
contacting origin) and verify_status_challenge, mirroring the TS SDK
status.ts. The probe short-circuits ahead of token verification, bot
detection, and analytics — no event is emitted. The payload carries a
self-describing component identity {"kind": "python-sdk", version} so the
backend can resolve the correct update registry per integration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The analytics relay now defaults to the dedicated ingest host (https://ingest-connect.supertab.co) instead of the API host, draining ingest load off the main API. Only affects deployments with analytics_enabled=True; the /ingest/events path and payload are unchanged — only the host moves, and only for analytics. Token acquisition / JWKS / verify still use the API base_url. Adds the public analytics_base_url config option plus set_analytics_base_url() / get_analytics_base_url(), mirroring base_url / set_base_url. Precedence: per-instance analytics_base_url > set_analytics_base_url() > the ingest default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First stable release. Bumps the version to 1.0.0, marks the package Production/Stable, and adds a CHANGELOG documenting the release — the SDK is now aligned with the TypeScript SDK's feature set (relay analytics, capture-v2 signals, self-report status endpoint) and its public surface is stable under semantic versioning. Publishing to PyPI is triggered by pushing the v1.0.0 tag (see .github/workflows/publish-pypi.yml), which verifies the tag matches this version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fire-and-forget analytics transport tracked in-flight emit tasks and its HTTP client in module-level globals, and SupertabConnect.aclose() closed the client without draining those tasks. An emit scheduled just before aclose() could then run afterwards, hit the `client is None` branch, and lazily rebuild a fresh AsyncClient that was never closed — a leaked client — while racing process shutdown and potentially losing the event. Make HttpAnalyticsTransport own its client and its in-flight tasks, and give it bounded flush()/aclose(): aclose() blocks new emits, awaits scheduled sends up to a timeout, cancels any stragglers, then closes the client. This mirrors the TS SDK, where the transport's lifetime is tied to its owning client instance rather than a module singleton. SupertabConnect.aclose() now drains the transport via a duck-typed aclose(), so the emit-only AnalyticsTransport protocol and injected custom transports are unaffected. events.py/jwks.py keep their module-level clients — they await their POST inline and have no such race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cation A JWT's `kid` is read from its unverified header, and both the status-probe and license-verification paths responded to an unknown `kid` by clearing the entire JWKS cache and refetching. An unauthenticated caller — trivially, via the public /.well-known/supertab/status endpoint — could therefore submit tokens with rotating unknown kids to bypass the 48h cache and force a backend JWKS fetch per request, while evicting the cached keys that license verification relies on. Replace the unconditional clear+refetch with a centralized refresh_platform_jwks_on_miss(): per-base-URL invalidation, single-flight via a per-base-URL lock, and a 60s refresh cooldown. The cooldown is spent before the fetch so a failing backend can't reopen the amplification, and force=True leaves the last-known keys cached if the refetch fails. Genuine key rotations still recover on the first miss, and signature verification itself was never bypassed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch brings the Python SDK to feature parity with the TypeScript SDK (TS PRs #31, #33, #34), cuts the first stable 1.0.0 release on top of that, and then hardens the newly ported surface with fixes found in review. Three of those fixes go beyond the current TS SDK and need to be ported back — they are listed separately at the bottom.
Parity with the TS SDK
User-Agenton every call to the Connect backend, across all code paths.schema_version2, TS #34) — events now carry the richer raw signals; classification stays query-time in the warehouse:httpxrequest:sec_fetch_*,sec_ch_ua*,accept,host,has_cookies, andheader_names(lowercased, deduped, sorted, with edge-injectedcf-*/fastly-*/cloudfront-*/x-forwarded-*etc. stripped);query_length,query_param_count,query_suspicious) — the raw query is never stored;accept/sec_ch_ua/as_organization;CdnRequestSignalsobject onHandleRequestContext.cdn_signals(Python receives signals from the caller instead of embedding CDN edge handlers).handle_requestnow servesGET /.well-known/supertab/status: a valid backend-signed challenge (ES256 JWT scoped to the site origin, purposestatus-probe) returns the live SDK config; anything else gets a minimal{"supertab": true}404. AddsHandlerAction.RESPOND(a response the caller serves verbatim, never forwarded to origin) andverify_status_challenge, mirroring TSstatus.ts. The probe short-circuits ahead of token verification, bot detection, and analytics, and the payload self-describes as{"kind": "python-sdk", version}. Powers the portal's live-health view. Themerchant_handle_requestexample now demonstrates dispatchingRESPOND.https://ingest-connect.supertab.coinstead of the API host (only the host changes, and only for analytics; token/JWKS/verify still usebase_url). Addsanalytics_base_urlconfig plusset_analytics_base_url()/get_analytics_base_url(), with precedence per-instance > global setter > ingest default.cdn_signalsdocumented (TS #34 README); Fastly native logging (TS #33) documented as intentionally not ported — Python doesn't run on Fastly Compute, custom delivery goes through theAnalyticsTransportprotocol.v1.0.0tag.Robustness fixes found in review (Python-side)
kidcomes from the unverified header, and an unknownkidused to clear the whole JWKS cache and refetch. Via the public status endpoint, an unauthenticated caller could rotate fake kids to bypass the 48h cache and force a backend fetch per request. Refresh is now per-base-URL, single-flight, with a 60s cooldown (spent before the fetch, so a failing backend can't reopen the amplification); genuine rotations still recover on the first miss.HttpAnalyticsTransportnow owns its HTTP client and in-flight emit tasks (previously module-level globals).aclose()blocks new emits, drains scheduled sends with a timeout, cancels stragglers, then closes the client — fixing a shutdown race that could leak a rebuiltAsyncClientand drop the event. This matches the TS ownership model (transport lifetime tied to the owning client instance).header_namesadditionally drops portable proxy/CDN service-chain artifacts (cdn-loop,x-varnish,via,surrogate-key,surrogate-control); deployment-specific injected headers must still be stripped at the edge.Ahead of the TS SDK — port-back needed⚠️
These are fixed here but not yet in the TS SDK; each has a concrete TS follow-up:
exp+iat(supertab_connect/merchant/status.py) — PyJWT (and jose) validateexponly when present, so a signed challenge without an expiry would verify and be replayable forever. Python now passesoptions={"require": ["exp", "iat"]}. TS: addrequiredClaims: ["exp", "iat"]to thejwtVerifycall insrc/status.ts._is_status_probeinmerchant/client.py) — non-GET requests to/.well-known/supertab/statusnow flow through to the application instead of being intercepted, and matching uses the percent-encoding-preserving raw path so%2F/%2Elook-alikes aren't served the SDK response. TS:src/index.tsmatches on pathname only — add arequest.method === "GET"guard (the raw-path half was a Python-only divergence; TS'snew URL().pathnamealready preserves encoding).eventReportingreflects the effective state (merchant/client.py) — a configured custom analytics transport emits regardless ofanalytics_enabled, so the status payload now reportsanalytics_enabled or analytics_transport is not None. TS: computeanalyticsEnabled ||= config.analyticsTransport != nullin the constructor (currently only reachable via the test seam, so low priority).Deferred for both SDKs: cap the status-challenge lifetime (
exp − iat≤ some max) — extra replay hardening that needs a decision on the maximum.Deliberate divergences from TS (no action)
hostis stripped fromheader_names: httpx synthesizes aHostheader on request construction while JSfetchhides it as forbidden, so stripping keeps the cross-SDK header-name set consistent (host is captured in its own field).HandleRequestContext.cdn_signals.Tests
New/extended coverage: capture-v2 event building (
tests/analytics/test_build_analytics_event.py), transport lifecycle (test_transport.py), IP extraction (test_ip.py), status challenge verification incl. missing-exp/iatand non-GET/encoded-path routing (tests/merchant/test_status.py), analytics wiring and base-URL precedence (test_client_analytics*.py), JWKS throttling (test_jwks.py).🤖 Generated with Claude Code