-
Notifications
You must be signed in to change notification settings - Fork 0
Merchant feature parity #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
c802818
Part 1: Merchant API Foundation
nick434434 330186e
Part 2: Analytics and Request Helpers
nick434434 9493595
Part 3: High-Level Merchant Client
nick434434 86ec475
Close the http clients, process license header robustly, add examples
nick434434 b8a1c4e
Make Sec-Ch-UA header exceptions precise and up-to-date
nick434434 563c68d
Remove unnecessary re-exports
nick434434 85cf83e
Simplify base_url handling
nick434434 9d5a69e
Address comments
nick434434 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| """Internal SDK version helpers.""" | ||
|
|
||
| from functools import lru_cache | ||
| from importlib.metadata import PackageNotFoundError, version | ||
|
|
||
| _PACKAGE_NAME = "supertab-connect-sdk" | ||
| _SDK_NAME = "supertab-connect-sdk-python" | ||
|
|
||
|
|
||
| @lru_cache(maxsize=1) | ||
| def _get_sdk_user_agent() -> str: | ||
| try: | ||
| package_version = version(_PACKAGE_NAME) | ||
| except PackageNotFoundError: | ||
| package_version = "unknown" | ||
|
|
||
| return f"{_SDK_NAME}/{package_version}" |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,5 @@ | ||
| """Customer functionality for Supertab Connect.""" | ||
|
|
||
| from connect.customer.token import obtain_license_token | ||
|
|
||
| __all__ = ["obtain_license_token"] |
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| """Merchant-facing helpers for the Supertab Connect SDK.""" | ||
|
|
||
| from connect.merchant.bots import default_bot_detector | ||
| from connect.merchant.client import SupertabConnect | ||
|
|
||
| __all__ = [ | ||
| "default_bot_detector", | ||
| "SupertabConnect", | ||
| ] |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Merchant bot detection helpers.""" | ||
|
|
||
| from httpx import Request | ||
|
|
||
| _KNOWN_BOT_UA_SUBSTRINGS = ( | ||
| "chatgpt-user", | ||
| "perplexitybot", | ||
| "gptbot", | ||
| "anthropic-ai", | ||
| "ccbot", | ||
| "claude-web", | ||
| "claudebot", | ||
| "cohere-ai", | ||
| "youbot", | ||
| "diffbot", | ||
| "oai-searchbot", | ||
| "meta-externalagent", | ||
| "timpibot", | ||
| "amazonbot", | ||
| "bytespider", | ||
| "perplexity-user", | ||
| "googlebot", | ||
| "bot", | ||
| "curl", | ||
| "wget", | ||
| ) | ||
|
|
||
| _CHROMIUM_UA_SUBSTRINGS = ( | ||
| "chrome/", | ||
| "chromium/", | ||
| "edg/", | ||
| "edga/", | ||
| "opr/", | ||
| "opera/", | ||
| "samsungbrowser/", | ||
| ) | ||
| _IOS_WEBKIT_BROWSER_UA_SUBSTRINGS = ("crios/", "edgios/", "fxios/") | ||
|
|
||
|
|
||
| def _browser_lacks_client_hints_support(lower_case_user_agent: str) -> bool: | ||
| """Return whether this UA is expected to omit the Sec-CH-UA header. | ||
|
|
||
| Chromium-family browsers generally send Sec-CH-UA, but most browser | ||
| user-agent strings still include Mozilla and Safari compatibility tokens. | ||
| Keep this exception limited to browsers/platforms that do not reliably | ||
| support UA Client Hints: Safari, Firefox, and iOS WebKit browser wrappers. | ||
| """ | ||
| is_firefox = "firefox/" in lower_case_user_agent or "fxios/" in lower_case_user_agent | ||
| is_ios_webkit_browser = any(browser in lower_case_user_agent for browser in _IOS_WEBKIT_BROWSER_UA_SUBSTRINGS) | ||
| is_safari = ( | ||
| "safari/" in lower_case_user_agent | ||
| and "applewebkit/" in lower_case_user_agent | ||
| and not any(browser in lower_case_user_agent for browser in _CHROMIUM_UA_SUBSTRINGS) | ||
| ) | ||
|
|
||
| return is_firefox or is_ios_webkit_browser or is_safari | ||
|
|
||
|
|
||
| def default_bot_detector(request: Request) -> bool: | ||
| user_agent = request.headers.get("user-agent", "") | ||
| accept = request.headers.get("accept", "") | ||
| sec_ch_ua = request.headers.get("sec-ch-ua") | ||
| accept_language = request.headers.get("accept-language") | ||
|
|
||
| # 1. Basic substring check from known list | ||
| lower_case_user_agent = user_agent.lower() | ||
| bot_ua_match = any(bot in lower_case_user_agent for bot in _KNOWN_BOT_UA_SUBSTRINGS) | ||
|
|
||
| # 2. Headless browser detection | ||
| headless_indicators = "headless" in lower_case_user_agent or "puppeteer" in lower_case_user_agent or not sec_ch_ua | ||
| is_browser_missing_sec_ch_ua = ( | ||
| "headless" not in lower_case_user_agent and "puppeteer" not in lower_case_user_agent and not sec_ch_ua | ||
| ) | ||
|
|
||
| # 3. Suspicious header gaps — many bots omit these | ||
| missing_headers = not accept or not accept_language | ||
|
|
||
| # Safari and Mozilla special case: allow if Sec-CH-UA is missing but UA matches Safari/Firefox patterns | ||
| if _browser_lacks_client_hints_support(lower_case_user_agent): | ||
| if headless_indicators and is_browser_missing_sec_ch_ua: | ||
| return False | ||
|
|
||
| # Final decision: any strong indicator is sufficient | ||
| return bot_ua_match or headless_indicators or missing_headers | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| """High-level merchant client for Supertab Connect.""" | ||
|
|
||
| from collections.abc import Mapping | ||
| from typing import ClassVar | ||
|
|
||
| from httpx import Request | ||
|
|
||
| from connect.merchant.events import aclose_http_client as aclose_events_http_client | ||
| from connect.merchant.license import ( | ||
| build_block_result, | ||
| build_signal_result, | ||
| verify_and_record_event, | ||
| verify_license_token, | ||
| ) | ||
| from connect.merchant.jwks import aclose_http_client as aclose_jwks_http_client | ||
| from connect.types import ( | ||
| BotDetector, | ||
| EnforcementMode, | ||
| HandlerAction, | ||
| HandlerResult, | ||
| InvalidLicenseToken, | ||
| LicenseTokenInvalidReason, | ||
| RSLVerificationResult, | ||
| SupertabConnectConfig, | ||
| ) | ||
|
|
||
| _DEFAULT_BASE_URL = "https://api-connect.supertab.co" | ||
|
|
||
|
|
||
| class SupertabConnect: | ||
| _instance: ClassVar["SupertabConnect | None"] = None | ||
| _base_url: ClassVar[str] = _DEFAULT_BASE_URL | ||
|
|
||
| def __new__(cls, config: SupertabConnectConfig, reset: bool = False) -> "SupertabConnect": | ||
| if not reset and cls._instance is not None: | ||
| if config.api_key != cls._instance.api_key: | ||
| raise ValueError( | ||
| "Cannot create a new instance with different configuration. " | ||
| "Use reset_instance to clear the existing instance." | ||
| ) | ||
| return cls._instance | ||
|
|
||
| if reset and cls._instance is not None: | ||
| cls.reset_instance() | ||
|
|
||
| return super().__new__(cls) | ||
|
|
||
| def __init__(self, config: SupertabConnectConfig, reset: bool = False) -> None: | ||
| if getattr(self, "_initialized", False) and not reset: | ||
| return | ||
|
|
||
| if not config.api_key: | ||
| raise ValueError("Missing required configuration: api_key is required") | ||
|
|
||
| self.api_key = config.api_key | ||
| self.enforcement = config.enforcement | ||
| self.bot_detector = config.bot_detector | ||
| self.debug = config.debug | ||
| self._base_url_override = config.supertab_base_url | ||
| self._initialized = True | ||
| type(self)._instance = self | ||
|
|
||
| @classmethod | ||
| def reset_instance(cls) -> None: | ||
| cls._instance = None | ||
|
|
||
| @classmethod | ||
| def set_base_url(cls, url: str) -> None: | ||
| cls._base_url = url | ||
|
|
||
| @classmethod | ||
| def get_base_url(cls) -> str: | ||
| return cls._base_url | ||
|
|
||
| @property | ||
| def base_url(self) -> str: | ||
| return self._base_url_override or type(self)._base_url | ||
|
|
||
| async def aclose(self) -> None: | ||
| await aclose_events_http_client() | ||
| await aclose_jwks_http_client() | ||
|
|
||
| async def __aenter__(self) -> "SupertabConnect": | ||
| return self | ||
|
|
||
| async def __aexit__(self, exc_type, exc, tb) -> None: | ||
| await self.aclose() | ||
|
|
||
| @classmethod | ||
| async def verify( | ||
| cls, | ||
| *, | ||
| token: str, | ||
| resource_url: str, | ||
| base_url: str | None = None, | ||
| debug: bool = False, | ||
| ) -> RSLVerificationResult: | ||
| result = await verify_license_token( | ||
| token, | ||
| request_url=resource_url, | ||
| supertab_base_url=base_url or cls._base_url, | ||
| debug=debug, | ||
| ) | ||
|
|
||
| if not isinstance(result, InvalidLicenseToken): | ||
| return RSLVerificationResult(valid=True) | ||
|
|
||
| return RSLVerificationResult(valid=False, error=result.error) | ||
|
|
||
| async def verify_and_record( | ||
| self, | ||
| *, | ||
| token: str, | ||
| resource_url: str, | ||
| user_agent: str = "unknown", | ||
| request_headers: Mapping[str, str] | None = None, | ||
| debug: bool | None = None, | ||
| ) -> RSLVerificationResult: | ||
| result = await verify_and_record_event( | ||
| token=token, | ||
| url=resource_url, | ||
| user_agent=user_agent, | ||
| supertab_base_url=self.base_url, | ||
| debug=self.debug if debug is None else debug, | ||
| api_key=self.api_key, | ||
| request_headers=request_headers, | ||
| ) | ||
|
|
||
| if not isinstance(result, InvalidLicenseToken): | ||
| return RSLVerificationResult(valid=True) | ||
|
|
||
| return RSLVerificationResult(valid=False, error=result.error) | ||
|
|
||
| def _detect_bot(self, request: Request) -> bool: | ||
| detector: BotDetector | None = self.bot_detector | ||
| if detector is None: | ||
| return False | ||
|
|
||
| return detector(request) | ||
|
|
||
| async def handle_request(self, request: Request) -> HandlerResult: | ||
| auth = request.headers.get("authorization", "") | ||
| token = None | ||
| auth_parts = auth.split(None, 1) | ||
| if len(auth_parts) == 2 and auth_parts[0].lower() == "license": | ||
| token = auth_parts[1] | ||
| url = str(request.url) | ||
|
nick434434 marked this conversation as resolved.
|
||
| user_agent = request.headers.get("user-agent", "unknown") | ||
|
|
||
| if token: | ||
| if self.enforcement is EnforcementMode.DISABLED: | ||
| return {"action": HandlerAction.ALLOW} | ||
|
|
||
| verification = await verify_and_record_event( | ||
| token=token, | ||
| url=url, | ||
| user_agent=user_agent, | ||
| supertab_base_url=self.base_url, | ||
| debug=self.debug, | ||
| api_key=self.api_key, | ||
| request_headers=dict(request.headers.items()), | ||
| ) | ||
| if isinstance(verification, InvalidLicenseToken): | ||
| return build_block_result( | ||
| reason=verification.reason, | ||
| error=verification.error, | ||
| request_url=url, | ||
| ) | ||
| return {"action": HandlerAction.ALLOW} | ||
|
|
||
| if not self._detect_bot(request): | ||
| return {"action": HandlerAction.ALLOW} | ||
|
|
||
| if self.enforcement is EnforcementMode.STRICT: | ||
| return build_block_result( | ||
| reason=LicenseTokenInvalidReason.MISSING_TOKEN, | ||
| error="Authorization header missing or malformed", | ||
| request_url=url, | ||
| ) | ||
| if self.enforcement is EnforcementMode.SOFT: | ||
| return build_signal_result(url) | ||
| return {"action": HandlerAction.ALLOW} | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.