Skip to content

Realign Python SDK with TS SDK - #13

Merged
nick434434 merged 18 commits into
mainfrom
feat/realign-with-typescript
Jul 20, 2026
Merged

Realign Python SDK with TS SDK#13
nick434434 merged 18 commits into
mainfrom
feat/realign-with-typescript

Conversation

@nick434434

@nick434434 nick434434 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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

  • Enforcement modes — updated the enum to the new modes and renamed tests to match (TS #31 alignment).
  • User-Agent — the SDK version is sent as the User-Agent on every call to the Connect backend, across all code paths.
  • Capture-v2 spoof-detection analytics (schema_version 2, TS #34) — events now carry the richer raw signals; classification stays query-time in the warehouse:
    • portable header signals read from the httpx request: sec_fetch_*, sec_ch_ua*, accept, host, has_cookies, and header_names (lowercased, deduped, sorted, with edge-injected cf-*/fastly-*/cloudfront-*/x-forwarded-* etc. stripped);
    • query-string derived signals (query_length, query_param_count, query_suspicious) — the raw query is never stored;
    • 512-char truncation on accept / sec_ch_ua / as_organization;
    • CDN-only signals the portable request can't provide are supplied by the caller via a new CdnRequestSignals object on HandleRequestContext.cdn_signals (Python receives signals from the caller instead of embedding CDN edge handlers).
  • Self-report status endpointhandle_request now serves GET /.well-known/supertab/status: a valid backend-signed challenge (ES256 JWT scoped to the site origin, purpose status-probe) returns the live SDK config; anything else gets a minimal {"supertab": true} 404. Adds HandlerAction.RESPOND (a response the caller serves verbatim, never forwarded to origin) and verify_status_challenge, mirroring TS status.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. The merchant_handle_request example now demonstrates dispatching RESPOND.
  • Analytics ingest moves to the standalone service — the relay defaults to https://ingest-connect.supertab.co instead of the API host (only the host changes, and only for analytics; token/JWKS/verify still use base_url). Adds analytics_base_url config plus set_analytics_base_url() / get_analytics_base_url(), with precedence per-instance > global setter > ingest default.
  • Docs — capture-v2 signals and cdn_signals documented (TS #34 README); Fastly native logging (TS #33) documented as intentionally not ported — Python doesn't run on Fastly Compute, custom delivery goes through the AnalyticsTransport protocol.
  • 1.0.0 release — version bump, Production/Stable classifier, CHANGELOG; PyPI publish is triggered by pushing the v1.0.0 tag.

Robustness fixes found in review (Python-side)

  • JWKS refresh throttling — a JWT's kid comes from the unverified header, and an unknown kid used 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.
  • Analytics transport lifecycleHttpAnalyticsTransport now 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 rebuilt AsyncClient and drop the event. This matches the TS ownership model (transport lifetime tied to the owning client instance).
  • Expanded edge-header strippingheader_names additionally 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:

  1. Status challenge requires exp + iat (supertab_connect/merchant/status.py) — PyJWT (and jose) validate exp only when present, so a signed challenge without an expiry would verify and be replayable forever. Python now passes options={"require": ["exp", "iat"]}. TS: add requiredClaims: ["exp", "iat"] to the jwtVerify call in src/status.ts.
  2. Status probe is GET-only and matched on the raw path (_is_status_probe in merchant/client.py) — non-GET requests to /.well-known/supertab/status now flow through to the application instead of being intercepted, and matching uses the percent-encoding-preserving raw path so %2F/%2E look-alikes aren't served the SDK response. TS: src/index.ts matches on pathname only — add a request.method === "GET" guard (the raw-path half was a Python-only divergence; TS's new URL().pathname already preserves encoding).
  3. eventReporting reflects the effective state (merchant/client.py) — a configured custom analytics transport emits regardless of analytics_enabled, so the status payload now reports analytics_enabled or analytics_transport is not None. TS: compute analyticsEnabled ||= config.analyticsTransport != null in 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)

  • host is stripped from header_names: httpx synthesizes a Host header on request construction while JS fetch hides it as forbidden, so stripping keeps the cross-SDK header-name set consistent (host is captured in its own field).
  • No Fastly log transport or embedded CDN edge handlers — Python doesn't run on Fastly Compute; CDN signals arrive via 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/iat and 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

nick434434 and others added 6 commits June 22, 2026 14:23
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/ENFORCE and updates handling logic + tests/examples/docs accordingly.
  • Adds a new supertab_connect.analytics module (event schema, event builder, IP normalization, transports) and wires analytics emission into SupertabConnect.handle_request(...) behind analytics_enabled.
  • Ensures SDK User-Agent is 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.

Comment thread supertab_connect/customer/token.py
Comment thread tests/merchant/test_client.py
Comment thread tests/merchant/test_client.py
nick434434 and others added 11 commits June 24, 2026 15:38
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 29 changed files in this pull request and generated 2 comments.

Comment thread supertab_connect/merchant/client.py Outdated
Comment thread supertab_connect/merchant/jwks.py
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

@ranael-garem ranael-garem left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SOLID work 👏

@nick434434
nick434434 merged commit 9a7eb08 into main Jul 20, 2026
3 checks passed
@nick434434
nick434434 deleted the feat/realign-with-typescript branch July 20, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants