foldset 0.1.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.
@@ -0,0 +1,40 @@
1
+ # Dependencies
2
+ node_modules
3
+ .pnpm-store
4
+
5
+ # Build outputs
6
+ dist
7
+ # TODO: Remove these exceptions once packages are published to npm.
8
+ # Currently committing dist/ because Vercel doesn't run prepare scripts
9
+ # for git dependencies properly.
10
+ !packages/typescript/nextjs/dist
11
+ !packages/typescript/core/dist
12
+ build
13
+ .next
14
+ out
15
+
16
+ # Environment files
17
+ .env
18
+ .env.local
19
+ .env.*.local
20
+
21
+ # IDE
22
+ .idea
23
+ .vscode
24
+ *.swp
25
+ *.swo
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Debug logs
32
+ npm-debug.log*
33
+ pnpm-debug.log*
34
+
35
+ # TypeScript
36
+ *.tsbuildinfo
37
+
38
+ # Misc
39
+ *.log
40
+ .cache
foldset-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: foldset
3
+ Version: 0.1.0
4
+ Summary: Core types and utilities for Foldset payment protection
5
+ Project-URL: Homepage, https://foldset.com
6
+ Project-URL: Documentation, https://docs.foldset.com
7
+ Project-URL: Repository, https://github.com/foldset/sdks
8
+ Author-email: Foldset <team@foldset.com>
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,foldset,micropayments,stablecoin
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.28.0
20
+ Requires-Dist: upstash-redis>=1.0.0
21
+ Requires-Dist: x402[mechanisms]>=2.0.0
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import (
4
+ BotsManager,
5
+ CachedConfigManager,
6
+ FacilitatorManager,
7
+ HostConfigManager,
8
+ PaymentMethodsManager,
9
+ RestrictionsManager,
10
+ build_request_metadata,
11
+ )
12
+ from .handler import handle_request, handle_settlement
13
+ from .health import HEALTH_PATH, build_health_response
14
+ from .mcp import handle_mcp_request
15
+ from .server import HttpServerManager
16
+ from .store import create_redis_store, fetch_redis_credentials
17
+ from .types import (
18
+ ConfigStore,
19
+ FoldsetOptions,
20
+ ProcessRequestResult,
21
+ RedisCredentials,
22
+ RequestAdapter,
23
+ )
24
+
25
+ _cached_core: WorkerCore | None = None
26
+
27
+
28
+ class WorkerCore:
29
+ def __init__(
30
+ self,
31
+ store: ConfigStore,
32
+ api_key: str,
33
+ platform: str,
34
+ sdk_version: str,
35
+ ) -> None:
36
+ self.host_config = HostConfigManager(store)
37
+ self.restrictions = RestrictionsManager(store)
38
+ self.payment_methods = PaymentMethodsManager(store)
39
+ self.bots = BotsManager(store)
40
+ self.api_key = api_key
41
+ self.http_server = HttpServerManager(store)
42
+ self.platform = platform
43
+ self.sdk_version = sdk_version
44
+
45
+ @classmethod
46
+ async def from_options(cls, options: FoldsetOptions) -> WorkerCore:
47
+ global _cached_core
48
+ if _cached_core:
49
+ return _cached_core
50
+
51
+ credentials = options.redis_credentials or await fetch_redis_credentials(
52
+ options.api_key
53
+ )
54
+ store = create_redis_store(credentials)
55
+ _cached_core = cls(
56
+ store,
57
+ options.api_key,
58
+ options.platform or "unknown",
59
+ options.sdk_version or "unknown",
60
+ )
61
+ return _cached_core
62
+
63
+ async def process_request(self, adapter: RequestAdapter) -> ProcessRequestResult:
64
+ metadata = build_request_metadata()
65
+
66
+ if adapter.get_path() == HEALTH_PATH:
67
+ return ProcessRequestResult(
68
+ type="health-check",
69
+ metadata=metadata,
70
+ response=type(
71
+ "HealthResponse",
72
+ (),
73
+ {
74
+ "status": 200,
75
+ "body": build_health_response(self.platform, self.sdk_version),
76
+ "headers": {"Content-Type": "application/json"},
77
+ },
78
+ )(),
79
+ )
80
+
81
+ host_config = await self.host_config.get()
82
+ mcp_endpoint = host_config.mcp_endpoint if host_config else None
83
+
84
+ if mcp_endpoint and adapter.get_path() == mcp_endpoint:
85
+ return await handle_mcp_request(self, adapter, mcp_endpoint, metadata)
86
+
87
+ return await handle_request(self, adapter, metadata)
88
+
89
+ async def process_settlement(
90
+ self,
91
+ adapter: RequestAdapter,
92
+ payment_payload,
93
+ payment_requirements,
94
+ upstream_status_code: int,
95
+ request_id: str,
96
+ ):
97
+ return await handle_settlement(
98
+ self,
99
+ adapter,
100
+ payment_payload,
101
+ payment_requirements,
102
+ upstream_status_code,
103
+ request_id,
104
+ )
105
+
106
+
107
+ # Re-exports
108
+ __all__ = [
109
+ "WorkerCore",
110
+ # Types
111
+ "ConfigStore",
112
+ "FoldsetOptions",
113
+ "ProcessRequestResult",
114
+ "RedisCredentials",
115
+ "RequestAdapter",
116
+ # Store
117
+ "create_redis_store",
118
+ "fetch_redis_credentials",
119
+ # Paywall
120
+ "generate_paywall_html",
121
+ # Routes
122
+ "build_routes_config",
123
+ "price_to_amount",
124
+ # Config managers
125
+ "BotsManager",
126
+ "CachedConfigManager",
127
+ "FacilitatorManager",
128
+ "HostConfigManager",
129
+ "PaymentMethodsManager",
130
+ "RestrictionsManager",
131
+ # Server
132
+ "HttpServerManager",
133
+ # MCP
134
+ "build_json_rpc_error",
135
+ "build_mcp_route_key",
136
+ "build_mcp_routes_config",
137
+ "get_mcp_list_payment_requirements",
138
+ "get_mcp_route_key",
139
+ "handle_mcp_request",
140
+ "is_mcp_list_method",
141
+ "parse_mcp_request",
142
+ # Telemetry
143
+ "build_event_payload",
144
+ "log_event",
145
+ "report_error",
146
+ "send_event",
147
+ # Handlers
148
+ "handle_request",
149
+ "handle_settlement",
150
+ "format_api_payment_error",
151
+ "format_web_payment_error",
152
+ # Health
153
+ "HEALTH_PATH",
154
+ "build_health_response",
155
+ ]
156
+
157
+ # Lazy imports for __all__ items
158
+ from .paywall import generate_paywall_html # noqa: E402
159
+ from .routes import build_routes_config, price_to_amount # noqa: E402
160
+ from .mcp import ( # noqa: E402
161
+ build_json_rpc_error,
162
+ build_mcp_route_key,
163
+ build_mcp_routes_config,
164
+ get_mcp_list_payment_requirements,
165
+ get_mcp_route_key,
166
+ is_mcp_list_method,
167
+ parse_mcp_request,
168
+ )
169
+ from .telemetry import build_event_payload, log_event, report_error, send_event # noqa: E402
170
+ from .handler import handle_payment_request # noqa: E402
171
+ from .api import format_api_payment_error # noqa: E402
172
+ from .web import format_web_payment_error # noqa: E402
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .types import ApiRestriction, PaymentMethod, ProcessRequestResult
6
+
7
+
8
+ def format_api_payment_error(
9
+ result: ProcessRequestResult,
10
+ restriction: ApiRestriction,
11
+ payment_methods: list[PaymentMethod],
12
+ terms_of_service_url: str | None = None,
13
+ ) -> None:
14
+ body: dict = {
15
+ "error": "payment_required",
16
+ "version": result.metadata.version,
17
+ "request_id": result.metadata.request_id,
18
+ "timestamp": result.metadata.timestamp,
19
+ "message": restriction.description,
20
+ "price": restriction.price,
21
+ }
22
+ if terms_of_service_url:
23
+ body["terms_of_service_url"] = terms_of_service_url
24
+ body["payment_methods"] = [
25
+ {
26
+ "network": pm.caip2_id,
27
+ "asset": pm.contract_address,
28
+ "decimals": pm.decimals,
29
+ "pay_to": pm.circle_wallet_address,
30
+ "chain": pm.chain_display_name,
31
+ "asset_name": pm.asset_display_name,
32
+ }
33
+ for pm in payment_methods
34
+ ]
35
+ result.response.body = json.dumps(body)
36
+ result.response.headers["Content-Type"] = "application/json"
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from x402.http import FacilitatorConfig as X402FacilitatorConfig
10
+ from x402.http import HTTPFacilitatorClient
11
+
12
+ from .types import (
13
+ Bot,
14
+ ConfigStore,
15
+ FacilitatorConfig,
16
+ HostConfig,
17
+ PaymentMethod,
18
+ ProcessRequestResult,
19
+ RequestMetadata,
20
+ Restriction,
21
+ ApiRestriction,
22
+ McpRestriction,
23
+ WebRestriction,
24
+ )
25
+
26
+ from importlib.metadata import version as _pkg_version
27
+
28
+ PACKAGE_VERSION = _pkg_version("foldset")
29
+ CACHE_TTL_MS = 30_000
30
+ API_BASE_URL = "https://api.foldset.com"
31
+
32
+
33
+ def build_request_metadata() -> RequestMetadata:
34
+ return RequestMetadata(
35
+ version=PACKAGE_VERSION,
36
+ request_id=str(uuid.uuid4()),
37
+ timestamp=datetime.now(timezone.utc).isoformat(),
38
+ )
39
+
40
+
41
+ def no_payment_required(metadata: RequestMetadata) -> ProcessRequestResult:
42
+ return ProcessRequestResult(type="no-payment-required", metadata=metadata)
43
+
44
+
45
+ class CachedConfigManager[T]:
46
+ def __init__(self, config_store: ConfigStore, key: str, fallback: T) -> None:
47
+ self._config_store = config_store
48
+ self._key = key
49
+ self._fallback = fallback
50
+ self._cached: T = fallback
51
+ self._cache_timestamp: float = 0
52
+
53
+ def _is_cache_valid(self) -> bool:
54
+ return self._cache_timestamp > 0 and (time.time() * 1000 - self._cache_timestamp) < CACHE_TTL_MS
55
+
56
+ def _deserialize(self, raw: str) -> T:
57
+ return json.loads(raw)
58
+
59
+ async def get(self) -> T:
60
+ if self._is_cache_valid():
61
+ return self._cached
62
+ raw = await self._config_store.get(self._key)
63
+ self._cached = self._deserialize(raw) if raw else self._fallback
64
+ self._cache_timestamp = time.time() * 1000
65
+ return self._cached
66
+
67
+
68
+ def _parse_restriction(data: dict[str, Any]) -> Restriction:
69
+ rtype = data.get("type")
70
+ if rtype == "web":
71
+ return WebRestriction(
72
+ description=data["description"],
73
+ price=data["price"],
74
+ scheme=data["scheme"],
75
+ path=data.get("path", ""),
76
+ )
77
+ elif rtype == "api":
78
+ return ApiRestriction(
79
+ description=data["description"],
80
+ price=data["price"],
81
+ scheme=data["scheme"],
82
+ path=data.get("path", ""),
83
+ http_method=data.get("httpMethod"),
84
+ )
85
+ elif rtype == "mcp":
86
+ return McpRestriction(
87
+ description=data["description"],
88
+ price=data["price"],
89
+ scheme=data["scheme"],
90
+ method=data.get("method", ""),
91
+ name=data.get("name", ""),
92
+ )
93
+ raise ValueError(f"Unknown restriction type: {rtype}")
94
+
95
+
96
+ class HostConfigManager(CachedConfigManager[HostConfig | None]):
97
+ def __init__(self, store: ConfigStore) -> None:
98
+ super().__init__(store, "host-config", None)
99
+
100
+ def _deserialize(self, raw: str) -> HostConfig | None:
101
+ data = json.loads(raw)
102
+ return HostConfig(
103
+ host=data["host"],
104
+ api_protection_mode=data.get("apiProtectionMode", "bots"),
105
+ mcp_endpoint=data.get("mcpEndpoint"),
106
+ terms_of_service_url=data.get("termsOfServiceUrl"),
107
+ )
108
+
109
+
110
+ class RestrictionsManager(CachedConfigManager[list[Restriction]]):
111
+ def __init__(self, store: ConfigStore) -> None:
112
+ super().__init__(store, "restrictions", [])
113
+
114
+ def _deserialize(self, raw: str) -> list[Restriction]:
115
+ data = json.loads(raw)
116
+ return [_parse_restriction(r) for r in data]
117
+
118
+
119
+ class PaymentMethodsManager(CachedConfigManager[list[PaymentMethod]]):
120
+ def __init__(self, store: ConfigStore) -> None:
121
+ super().__init__(store, "payment-methods", [])
122
+
123
+ def _deserialize(self, raw: str) -> list[PaymentMethod]:
124
+ data = json.loads(raw)
125
+ return [
126
+ PaymentMethod(
127
+ caip2_id=pm["caip2_id"],
128
+ decimals=pm["decimals"],
129
+ contract_address=pm["contract_address"],
130
+ circle_wallet_address=pm["circle_wallet_address"],
131
+ chain_display_name=pm["chain_display_name"],
132
+ asset_display_name=pm["asset_display_name"],
133
+ extra=pm.get("extra"),
134
+ )
135
+ for pm in data
136
+ ]
137
+
138
+
139
+ class BotsManager(CachedConfigManager[list[Bot]]):
140
+ def __init__(self, store: ConfigStore) -> None:
141
+ super().__init__(store, "bots", [])
142
+
143
+ def _deserialize(self, raw: str) -> list[Bot]:
144
+ data = json.loads(raw)
145
+ return [
146
+ Bot(
147
+ user_agent=b["user_agent"].lower(),
148
+ force_200=b.get("force_200", False),
149
+ )
150
+ for b in data
151
+ ]
152
+
153
+ async def match_bot(self, user_agent: str) -> Bot | None:
154
+ bots = await self.get()
155
+ ua = user_agent.lower()
156
+ for bot in bots:
157
+ if bot.user_agent in ua:
158
+ return bot
159
+ return None
160
+
161
+
162
+ class FacilitatorManager(CachedConfigManager[HTTPFacilitatorClient | None]):
163
+ def __init__(self, store: ConfigStore) -> None:
164
+ super().__init__(store, "facilitator", None)
165
+
166
+ def _deserialize(self, raw: str) -> HTTPFacilitatorClient:
167
+ config = json.loads(raw)
168
+
169
+ has_auth_headers = (
170
+ config.get("verifyHeaders")
171
+ or config.get("settleHeaders")
172
+ or config.get("supportedHeaders")
173
+ )
174
+
175
+ facilitator_config: dict[str, Any] = {"url": config["url"]}
176
+ if has_auth_headers:
177
+ facilitator_config["create_headers"] = lambda: {
178
+ "verify": config.get("verifyHeaders") or {},
179
+ "settle": config.get("settleHeaders") or {},
180
+ "supported": config.get("supportedHeaders") or {},
181
+ }
182
+
183
+ return HTTPFacilitatorClient(facilitator_config)
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from x402.http import HTTPRequestContext, ProcessSettleResult
6
+
7
+ from .api import format_api_payment_error
8
+ from .config import no_payment_required
9
+ from .telemetry import log_event
10
+ from .types import ProcessRequestResult, RequestAdapter, RequestMetadata
11
+ from .web import format_web_payment_error
12
+
13
+ if TYPE_CHECKING:
14
+ from . import WorkerCore
15
+
16
+
17
+ def _settlement_failure(reason: str, network: str) -> ProcessSettleResult:
18
+ return ProcessSettleResult(success=False, error_reason=reason)
19
+
20
+
21
+ async def handle_payment_request(
22
+ core: WorkerCore,
23
+ adapter: RequestAdapter,
24
+ metadata: RequestMetadata,
25
+ path_override: str | None = None,
26
+ ) -> ProcessRequestResult:
27
+ http_server = await core.http_server.get()
28
+ if not http_server:
29
+ return no_payment_required(metadata)
30
+
31
+ path = path_override or adapter.get_path()
32
+
33
+ context = HTTPRequestContext(
34
+ adapter=adapter,
35
+ path=path,
36
+ method=adapter.get_method(),
37
+ payment_header=(
38
+ adapter.get_header("PAYMENT-SIGNATURE")
39
+ or adapter.get_header("X-PAYMENT")
40
+ ),
41
+ )
42
+
43
+ if not http_server.requires_payment(context):
44
+ return no_payment_required(metadata)
45
+
46
+ result = await http_server.process_http_request_with_restriction(context)
47
+ result.metadata = metadata
48
+
49
+ if result.type == "payment-error":
50
+ if result.restriction and result.restriction.price == 0:
51
+ await log_event(core, adapter, 200, metadata.request_id)
52
+ return no_payment_required(metadata)
53
+ await log_event(core, adapter, result.response.status if result.response else 402, metadata.request_id)
54
+
55
+ return result
56
+
57
+
58
+ async def handle_request(
59
+ core: WorkerCore,
60
+ adapter: RequestAdapter,
61
+ metadata: RequestMetadata,
62
+ ) -> ProcessRequestResult:
63
+ user_agent = adapter.get_user_agent()
64
+ bot = await core.bots.match_bot(user_agent) if user_agent else None
65
+ host_config = await core.host_config.get()
66
+
67
+ should_check = bot or (host_config and host_config.api_protection_mode == "all")
68
+ if not should_check:
69
+ return no_payment_required(metadata)
70
+
71
+ result = await handle_payment_request(core, adapter, metadata)
72
+
73
+ if result.type != "payment-error":
74
+ return result
75
+
76
+ # Web restrictions are always bot-only
77
+ if result.restriction and result.restriction.type == "web" and not bot:
78
+ return no_payment_required(metadata)
79
+
80
+ payment_methods = await core.payment_methods.get()
81
+
82
+ if payment_methods and result.restriction:
83
+ if result.restriction.type == "api":
84
+ format_api_payment_error(
85
+ result, result.restriction, payment_methods, host_config.terms_of_service_url if host_config else None
86
+ )
87
+ elif result.restriction.type == "web":
88
+ format_web_payment_error(
89
+ result, result.restriction, payment_methods, adapter, host_config.terms_of_service_url if host_config else None
90
+ )
91
+
92
+ if bot and bot.force_200 and result.response:
93
+ result.response.status = 200
94
+
95
+ return result
96
+
97
+
98
+ async def handle_settlement(
99
+ core: WorkerCore,
100
+ adapter: RequestAdapter,
101
+ payment_payload: Any,
102
+ payment_requirements: Any,
103
+ upstream_status_code: int,
104
+ request_id: str,
105
+ ) -> ProcessSettleResult:
106
+ http_server = await core.http_server.get()
107
+ if not http_server:
108
+ return _settlement_failure("Server not initialized", "")
109
+
110
+ if upstream_status_code >= 400:
111
+ await log_event(core, adapter, upstream_status_code, request_id)
112
+ return _settlement_failure("Upstream error", "")
113
+
114
+ result = await http_server.process_settlement(
115
+ payment_payload,
116
+ payment_requirements,
117
+ )
118
+
119
+ if result.success:
120
+ payment_response = result.headers.get("PAYMENT-RESPONSE")
121
+ await log_event(core, adapter, upstream_status_code, request_id, payment_response)
122
+ else:
123
+ await log_event(core, adapter, 402, request_id)
124
+
125
+ return result
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+
6
+ from .config import PACKAGE_VERSION
7
+
8
+ HEALTH_PATH = "/.well-known/foldset"
9
+
10
+
11
+ def build_health_response(platform: str, sdk_version: str) -> str:
12
+ return json.dumps({
13
+ "status": "ok",
14
+ "core_version": PACKAGE_VERSION,
15
+ "sdk_version": sdk_version,
16
+ "platform": platform,
17
+ "timestamp": datetime.now(timezone.utc).isoformat(),
18
+ })