Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4c9e843
Use the SDK version as User-Agent in calls to Connect Backend
nick434434 Jun 22, 2026
076dd2a
Update enforcement mode enum
nick434434 Jun 22, 2026
653d4ac
First alignment with PR #31 from TS SDK
nick434434 Jun 24, 2026
4479054
feat(analytics): capture-v2 spoof-detection signals (schema_version 2)
nick434434 Jun 24, 2026
7ec60dc
docs(analytics): note Fastly native logging is N/A for the Python SDK
nick434434 Jun 24, 2026
8428b79
docs(analytics): document capture-v2 signals and cdn_signals context
nick434434 Jun 24, 2026
798ffec
Set the User-Agent header in all paths
nick434434 Jun 24, 2026
42defd3
Rename enforcement mode tests to match new modes
nick434434 Jun 24, 2026
1acd935
feat(status): self-report status endpoint
nick434434 Jul 15, 2026
8ded518
feat(analytics): default ingest to the standalone service
nick434434 Jul 15, 2026
5c9978a
chore(release): 1.0.0
nick434434 Jul 15, 2026
520be4e
fix(analytics): drain in-flight emits on close, transport-owned client
nick434434 Jul 16, 2026
c7bb797
fix(jwks): throttle key-rotation refresh to stop cache-bypass amplifi…
nick434434 Jul 16, 2026
fe7fbf3
Process only GET requests to /.well-known/supertab/status
nick434434 Jul 20, 2026
81b1dc5
Update the analytics flag based on transport override
nick434434 Jul 20, 2026
cb5e9dd
Require exp and iat claims explicitly
nick434434 Jul 20, 2026
105ecfd
Add tests and update an example
nick434434 Jul 20, 2026
b7398a1
Extract auth token for status with case-insensitive check for "bearer"
nick434434 Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] — 2026-07-15

First stable release. The SDK is now aligned with the TypeScript SDK's feature
set (relay analytics, capture-v2 signals, the self-report status endpoint) and
its public surface is considered stable under semantic versioning.

### Added

- **Self-report status endpoint.** `handle_request()` answers the platform probe
at `GET /.well-known/supertab/status`. On a valid backend-signed challenge
(an ES256 JWT scoped to the site origin with `purpose: status-probe`) it
returns a `HandlerAction.RESPOND` result with the live SDK config —
`runtime`, `component: {"kind": "python-sdk", version}`, `enforcement`, and
`eventReporting`. Without a valid challenge it returns a minimal
`{"supertab": true}` `404`. The probe short-circuits ahead of token
verification, bot detection, and analytics — no event is emitted.
- **`HandlerAction.RESPOND`** and the `RespondHandlerResult` type — a fully
formed response the caller serves verbatim without contacting origin.
- **Relay analytics** (off by default; `analytics_enabled=True`). One event per
request to `/ingest/events`, authenticated with the merchant `api_key`; the
backend derives merchant identity, so no merchant identifier is sent. Emits
`schema_version: 2` capture-v2 spoof-detection signals. Fire-and-forget and
fail-open — analytics can never block, slow, or alter request handling.
- **`analytics_base_url` config option, plus `SupertabConnect.set_analytics_base_url()` /
`get_analytics_base_url()`.** Points the analytics ingest relay at a specific
host, independent of `set_base_url` (which stays the base for token
acquisition / JWKS / verification). Mirrors the existing `base_url` pattern.
- **`HandleRequestContext`** for passing CDN-supplied per-request signals
(`source_cdn`, `client_ip`, `request_id`, `request_country`, `request_asn`,
`tls_fingerprint`, `cdn_signals`) onto the analytics event.
- **`User-Agent` header** (`supertab-connect-sdk-python/<version>`) on all
outbound calls to the Connect backend.

### Changed

- **Analytics defaults to the dedicated ingest service
(`https://ingest-connect.supertab.co`)** rather than the API host. Only
affects deployments with `analytics_enabled=True`; the `/ingest/events` path
and payload are unchanged — traffic just moves to the standalone service.
Non-prod / local setups should call `set_analytics_base_url()` to avoid
emitting to prod.
- **`EnforcementMode` values renamed** to match the backend and the TS SDK:
`SOFT` → `OBSERVE`, `STRICT` → `ENFORCE` (enum members and string values).
The default was already observe-only — a rename, not a behavior change.

### Fixed

- **Throttled JWKS refresh on key-rotation misses.** When a token's `kid` is absent
from the cached key set, the SDK refreshes the platform JWKS at most once per minute
per base URL (single-flight, per-base-URL) instead of clearing the whole cache and
refetching on every miss. Because `kid` is read from an unverified JWT header, the
previous behavior let an unauthenticated caller — e.g. via the public
`/.well-known/supertab/status` endpoint — submit tokens with rotating unknown `kid`s
to bypass the 48h cache and force a backend fetch per request (and evict cached keys
used by license verification). Genuine rotations still recover on the first miss;
signature verification was never bypassed. Applies to both the status-probe and
license-verification paths.
- **Analytics emits are drained on close.** The HTTP analytics transport now owns
its own client and in-flight emit tasks; `await client.aclose()` (or exiting an
`async with` block) flushes outstanding emits within a bounded timeout before
closing the client. Previously a fire-and-forget emit scheduled just before
`aclose()` could run afterwards, lazily recreate a fresh HTTP client that was
never closed (a leak), and race process shutdown. Emission remains fire-and-forget
and fail-open on the request path.
142 changes: 140 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,146 @@ asyncio.run(main())
```

For request-level enforcement, use `SupertabConnect.handle_request()` with an
`httpx.Request`. See the `examples` directory for complete merchant and customer
examples.
`httpx.Request`. It extracts the license token from the `Authorization` header,
verifies it, optionally emits a relay analytics event, and applies bot detection
and enforcement mode when no token is present. It returns either
`{"action": HandlerAction.ALLOW, ...}`,
`{"action": HandlerAction.BLOCK, "status": ..., "body": ..., "headers": ...}`, or
`{"action": HandlerAction.RESPOND, "status": ..., "body": ..., "headers": ...}`
(see [Self-report status endpoint](#self-report-status-endpoint) below).

`handle_request()` accepts an optional second argument, a `HandleRequestContext`,
which carries per-request signals supplied by an upstream CDN/proxy
(`source_cdn`, `client_ip`, `request_id`, `request_country`, `request_asn`,
`tls_fingerprint`, and `cdn_signals`). These are recorded on the analytics event
when present; for direct SDK use the context can be omitted.

`cdn_signals` is a `CdnRequestSignals` object carrying the richer
spoof-detection signals that cannot be read from the portable request — TLS
fingerprinting fields, the verified-bot category, the negotiated protocol, and
so on. These are platform-specific (for example, Cloudflare exposes them on
`request.cf`), so the SDK takes them from the caller rather than extracting them
itself. Everything left unset stays `null` on the event.

See the `examples` directory for complete merchant and customer examples.

## Self-report status endpoint

`handle_request()` also answers the platform's self-report probe at
`GET /.well-known/supertab/status`, which powers the portal's live-health view.
When the request carries a valid backend-signed challenge
(`Authorization: Bearer <challenge>`, an ES256 JWT scoped to the site origin with
`purpose: status-probe`), the SDK returns a `RESPOND` result reporting its live
config:

```json
{
"runtime": "cloudflare",
"component": { "kind": "python-sdk", "version": "1.0.0" },
"enforcement": "observe",
"eventReporting": false
}
```

`runtime` comes from `HandleRequestContext.source_cdn` (or `null` for direct
invocation). Without a valid challenge the SDK returns a minimal
`{"supertab": true}` with a `404` status, disclosing nothing about the
deployment. The probe short-circuits ahead of token verification, bot detection,
and analytics — no event is emitted. Both responses set `Cache-Control:
no-store`.

A `RESPOND` result must be served to the caller **verbatim** (status, body, and
headers) without forwarding to origin; it is distinguished from `ALLOW` / `BLOCK`
by `result["action"] == HandlerAction.RESPOND`.

## Analytics

The SDK can emit one analytics event per request to the Supertab Connect
**relay** endpoint at `/ingest/events`, served by the dedicated ingest service
(`https://ingest-connect.supertab.co`) — separate from the API host used for
token acquisition / JWKS / verification. This is **off by default** — enable it
by passing `analytics_enabled=True`:

```python
from supertab_connect import SupertabConnect, SupertabConnectConfig

client = SupertabConnect(
SupertabConnectConfig(
api_key="stc_live_your_api_key",
analytics_enabled=True,
)
)
```

**No extra credentials are required.** Analytics requests are authenticated with
your configured merchant `api_key` using `Authorization: Bearer <api_key>`. The
backend derives merchant identity from the API key, so the SDK sends **no
merchant identifier** in the analytics payload.

Each `AnalyticsEvent` captures the request id, source CDN, a normalized client
IP, the request path (with percent-encoding preserved), method, and selected
headers — plus, when an upstream CDN exposes them via `HandleRequestContext`, the
request country, ASN, TLS fingerprint, and HTTP Message Signature headers — along
with the verification/enforcement decision for the request.

Events emit at **`schema_version: 2`** ("capture v2"), which adds raw
spoof-detection signals for query-time classification in the warehouse (the SDK
never classifies — it emits raw signals only):

- **Portable header signals**, read directly from the request: `sec_fetch_*`,
the `sec_ch_ua*` client hints, `accept`, `host`, `has_cookies`, and
`header_names` — the lowercased, deduped, sorted set of request-header names
with edge-injected headers (`cf-*`, `fastly-*`, `cloudfront-*`,
`x-forwarded-*`, `x-real-ip`, the synthesized `Host`, …) stripped so it
reflects only what the client sent.
- **Query-string derived signals**: `query_length`, `query_param_count`, and
`query_suspicious` (a coarse exploit-marker heuristic). The raw query string
is **never** stored.
- **CDN plumbing** supplied via `HandleRequestContext.cdn_signals`:
`accept_encoding`, `http_protocol`, `tls_version`, `tls_cipher`,
`tls_client_hello_length`, `tls_client_extensions_sha1`, `as_organization`,
`client_tcp_rtt`, `cdn_verified_bot_category`, `request_priority`, and
`tls_fingerprint_ja4`.

`accept`, `sec_ch_ua`, and `as_organization` are truncated to 512 characters.
Every capture-v2 field is fail-open: anything unavailable is emitted as `null`.

**Fail-open:** analytics emission is fire-and-forget and can never block, slow,
or alter request handling. If emission fails, the error is swallowed and the
request proceeds exactly as it would with analytics disabled. Analytics is sent
only to the relay at `/ingest/events`, independent of billing event recording.

Because emission is fire-and-forget, close the client when you are done so
in-flight events are not lost: `await client.aclose()` (or an `async with
SupertabConnect(...) as client:` block) drains outstanding emits — bounded by a
short timeout — and releases the underlying HTTP client. The drain never blocks
request handling; it only applies at teardown.

Point analytics at another environment with the `analytics_base_url` config
option (or `SupertabConnect.set_analytics_base_url(...)`) — precedence is
per-instance `analytics_base_url` > `set_analytics_base_url()` > the ingest
default. This is independent of `supertab_base_url` / `set_base_url(...)`, which
control the API host for token acquisition / JWKS / verification; changing that
no longer moves analytics traffic.

For advanced use, the `AnalyticsTransport` protocol lets you inject a custom
transport (for example, an in-memory recorder in tests) via the internal
`analytics_transport` config field; `AnalyticsEvent` and `HandleRequestContext`
are exported from the package root.

### Native Fastly logging (not applicable to the Python SDK)

The TypeScript SDK can deliver analytics through a **native Fastly Compute
logging endpoint** (`FastlyLogTransport` / the `logEndpoint` option on
`fastlyHandleRequests`) instead of the HTTP relay, letting Fastly ship events
off-path to S3. That path is intentionally **not ported here**: Python does not
run on Fastly Compute (the `fastly:logger` built-in has no Python equivalent),
and — consistent with this SDK's design — the Python SDK does not embed CDN edge
handlers, receiving CDN-derived signals through `HandleRequestContext` instead.

If you need to deliver analytics somewhere other than the relay (for example, to
a log shipper that forwards to S3/Tinybird), implement the `AnalyticsTransport`
protocol and pass it via the `analytics_transport` config field.

## Error Handling

Expand Down
12 changes: 11 additions & 1 deletion examples/merchant_handle_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ async def main() -> None:
client = SupertabConnect(
SupertabConnectConfig(
api_key="your_api_key",
enforcement=EnforcementMode.STRICT,
enforcement=EnforcementMode.ENFORCE,
debug=True,
)
)
Expand All @@ -42,6 +42,16 @@ async def main() -> None:
print(result["headers"]["WWW-Authenticate"])
return

# A RESPOND result (e.g. the self-report status probe at /.well-known/supertab/status)
# must be served to the caller verbatim — status, body, and headers — and never forwarded
# to origin. Treating it as ALLOW would leak the probe through to the application.
if result["action"] is HandlerAction.RESPOND:
print("RESPOND request")
print(result["status"]) # type: ignore
print(result["headers"]) # type: ignore
print(result["body"]) # type: ignore
return

print("ALLOW request")
if "headers" in result:
print(result["headers"])
Expand Down
2 changes: 1 addition & 1 deletion examples/merchant_verify_and_record_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async def main() -> None:
client = SupertabConnect(
SupertabConnectConfig(
api_key="your_api_key",
enforcement=EnforcementMode.SOFT,
enforcement=EnforcementMode.OBSERVE,
debug=True,
)
)
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ build-backend = "hatchling.build"

[project]
name = "supertab-connect-sdk"
version = "0.1.2"
version = "1.0.0"
authors = [
{ name = "Supertab", email = "hello@supertab.co" },
]
description = "Supertab Connect SDK"
readme = "README.md"
requires-python = ">=3.12"
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
Expand Down
10 changes: 10 additions & 0 deletions supertab_connect/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Supertab Connect SDK."""

from supertab_connect.analytics.types import (
AnalyticsEvent,
AnalyticsTransport,
CdnRequestSignals,
)
from supertab_connect.customer.token import obtain_license_token
from supertab_connect.exceptions import SupertabConnectError
from supertab_connect.merchant.bots import default_bot_detector
from supertab_connect.merchant.client import SupertabConnect
from supertab_connect.merchant.license import verify_license_token
from supertab_connect.types import (
EnforcementMode,
HandleRequestContext,
HandlerAction,
HandlerResult,
RSLVerificationResult,
Expand All @@ -15,7 +21,11 @@
)

__all__ = [
"AnalyticsEvent",
"AnalyticsTransport",
"CdnRequestSignals",
"EnforcementMode",
"HandleRequestContext",
"HandlerAction",
"HandlerResult",
"RSLVerificationResult",
Expand Down
11 changes: 7 additions & 4 deletions supertab_connect/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@


@lru_cache(maxsize=1)
def _get_sdk_user_agent() -> str:
def _get_sdk_version() -> str:
try:
package_version = version(_PACKAGE_NAME)
return version(_PACKAGE_NAME)
except PackageNotFoundError:
package_version = "unknown"
return "unknown"


return f"{_SDK_NAME}/{package_version}"
@lru_cache(maxsize=1)
def _get_sdk_user_agent() -> str:
return f"{_SDK_NAME}/{_get_sdk_version()}"
41 changes: 41 additions & 0 deletions supertab_connect/analytics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Relay analytics for Supertab Connect (mirrors the TS SDK `analytics/` module)."""

from supertab_connect.analytics.build_analytics_event import (
BuildAnalyticsEventContext,
build_analytics_event,
)
from supertab_connect.analytics.ip import normalize_client_ip
from supertab_connect.analytics.transport import (
ANALYTICS_EVENTS_PATH,
HttpAnalyticsTransport,
NoopAnalyticsTransport,
)
from supertab_connect.analytics.types import (
SCHEMA_VERSION,
TOKEN_OUTCOME_BY_REASON,
AnalyticsEvent,
AnalyticsTransport,
CdnRequestSignals,
Decision,
FinalAction,
SourceCdn,
TokenOutcome,
)

__all__ = [
"ANALYTICS_EVENTS_PATH",
"SCHEMA_VERSION",
"TOKEN_OUTCOME_BY_REASON",
"AnalyticsEvent",
"AnalyticsTransport",
"BuildAnalyticsEventContext",
"CdnRequestSignals",
"Decision",
"FinalAction",
"HttpAnalyticsTransport",
"NoopAnalyticsTransport",
"SourceCdn",
"TokenOutcome",
"build_analytics_event",
"normalize_client_ip",
]
Loading
Loading