conduit-lightning 0.1.0__py3-none-any.whl
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.
- conduit/__init__.py +3 -0
- conduit/api/__init__.py +0 -0
- conduit/api/deps.py +105 -0
- conduit/api/middleware/__init__.py +7 -0
- conduit/api/middleware/l402.py +205 -0
- conduit/api/middleware/rate_limit.py +151 -0
- conduit/api/middleware/verification.py +160 -0
- conduit/api/routers/__init__.py +5 -0
- conduit/api/routers/admin.py +77 -0
- conduit/api/routers/lightning.py +218 -0
- conduit/api/routers/marketplace.py +636 -0
- conduit/api/routers/nostr.py +235 -0
- conduit/api/routers/security.py +189 -0
- conduit/core/__init__.py +0 -0
- conduit/core/config.py +129 -0
- conduit/core/database.py +31 -0
- conduit/main.py +213 -0
- conduit/mcp_server.py +2334 -0
- conduit/models/__init__.py +23 -0
- conduit/models/anomaly_flag.py +43 -0
- conduit/models/base.py +27 -0
- conduit/models/execution.py +74 -0
- conduit/models/invoice.py +54 -0
- conduit/models/payment.py +58 -0
- conduit/models/rating.py +49 -0
- conduit/models/skill.py +85 -0
- conduit/models/spending_log.py +35 -0
- conduit/models/wallet.py +42 -0
- conduit/schemas/__init__.py +0 -0
- conduit/schemas/invoice.py +32 -0
- conduit/schemas/payment.py +30 -0
- conduit/schemas/wallet.py +36 -0
- conduit/services/__init__.py +0 -0
- conduit/services/anomaly_detector.py +274 -0
- conduit/services/fee_calculator.py +61 -0
- conduit/services/l402.py +377 -0
- conduit/services/lnd.py +284 -0
- conduit/services/macaroon_auth.py +332 -0
- conduit/services/nostr.py +819 -0
- conduit/services/nwc.py +728 -0
- conduit/services/proto_generated/lightning_pb2.py +683 -0
- conduit/services/proto_generated/lightning_pb2_grpc.py +3377 -0
- conduit/services/provider_verification.py +482 -0
- conduit/services/rate_limiter.py +450 -0
- conduit/services/rating_integrity.py +201 -0
- conduit/services/skill_executor.py +191 -0
- conduit/services/spending_limiter.py +383 -0
- conduit/services/url_safety.py +192 -0
- conduit/services/wallet_backend.py +149 -0
- conduit_lightning-0.1.0.dist-info/METADATA +297 -0
- conduit_lightning-0.1.0.dist-info/RECORD +54 -0
- conduit_lightning-0.1.0.dist-info/WHEEL +4 -0
- conduit_lightning-0.1.0.dist-info/entry_points.txt +3 -0
- conduit_lightning-0.1.0.dist-info/licenses/LICENSE +21 -0
conduit/__init__.py
ADDED
conduit/api/__init__.py
ADDED
|
File without changes
|
conduit/api/deps.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Shared API dependencies — auth, LND client, database sessions."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Header, HTTPException, status
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from conduit.core.config import settings
|
|
11
|
+
from conduit.core.database import async_session_factory
|
|
12
|
+
from conduit.services.wallet_backend import WalletBackend
|
|
13
|
+
|
|
14
|
+
# =============================================================================
|
|
15
|
+
# Authentication
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def verify_api_key(
|
|
20
|
+
x_api_key: Annotated[str | None, Header()] = None,
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Validate the X-API-Key header against the configured key.
|
|
23
|
+
|
|
24
|
+
The header is declared optional so a *missing* key returns 401
|
|
25
|
+
Unauthorized (the correct status for absent credentials) instead of
|
|
26
|
+
FastAPI's default 422 for a missing required header.
|
|
27
|
+
|
|
28
|
+
Uses a constant-time comparison so an attacker probing the API over
|
|
29
|
+
the network can't recover the key one character at a time via
|
|
30
|
+
response-timing differences.
|
|
31
|
+
"""
|
|
32
|
+
expected = settings.conduit_api_key or ""
|
|
33
|
+
# Reject the default placeholder explicitly — otherwise a misconfigured
|
|
34
|
+
# server would accept "CHANGE-ME" as a valid key.
|
|
35
|
+
if not expected or expected.startswith("CHANGE-ME"):
|
|
36
|
+
raise HTTPException(
|
|
37
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
38
|
+
detail="Server is not configured: CONDUIT_API_KEY is unset.",
|
|
39
|
+
)
|
|
40
|
+
provided = x_api_key or ""
|
|
41
|
+
# compare_digest against an empty string still runs (and fails) in constant
|
|
42
|
+
# time, so a missing key follows the same path and timing as a wrong one.
|
|
43
|
+
if not hmac.compare_digest(provided.encode("utf-8"), expected.encode("utf-8")):
|
|
44
|
+
raise HTTPException(
|
|
45
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
46
|
+
detail="Missing or invalid API key",
|
|
47
|
+
)
|
|
48
|
+
return x_api_key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# LND Client (lazy singleton)
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
_wallet: WalletBackend | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_lnd() -> WalletBackend:
|
|
59
|
+
"""Get or create the wallet backend connection.
|
|
60
|
+
|
|
61
|
+
Name kept as get_lnd() for backward compatibility — all existing
|
|
62
|
+
callers use this function. The returned object implements
|
|
63
|
+
WalletBackend and may be LND, NWC, or any future backend.
|
|
64
|
+
"""
|
|
65
|
+
global _wallet
|
|
66
|
+
if _wallet is None or not _wallet.is_connected:
|
|
67
|
+
_wallet = _create_wallet_backend()
|
|
68
|
+
_wallet.connect()
|
|
69
|
+
return _wallet
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _create_wallet_backend() -> WalletBackend:
|
|
73
|
+
"""Factory: pick the right backend based on config."""
|
|
74
|
+
backend = getattr(settings, "wallet_backend", "lnd").lower()
|
|
75
|
+
|
|
76
|
+
# Auto-detect: if NWC connection string is set, use NWC
|
|
77
|
+
nwc_uri = getattr(settings, "nwc_connection_string", "") or ""
|
|
78
|
+
if backend == "auto":
|
|
79
|
+
backend = "nwc" if nwc_uri else "lnd"
|
|
80
|
+
|
|
81
|
+
if backend == "nwc":
|
|
82
|
+
if not nwc_uri:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
"WALLET_BACKEND=nwc but NWC_CONNECTION_STRING is not set. "
|
|
85
|
+
"Paste your nostr+walletconnect:// URI in .env."
|
|
86
|
+
)
|
|
87
|
+
from conduit.services.nwc import NwcWalletBackend
|
|
88
|
+
print("[api] Using NWC wallet backend", file=sys.stderr)
|
|
89
|
+
return NwcWalletBackend(nwc_uri)
|
|
90
|
+
|
|
91
|
+
# Default: LND
|
|
92
|
+
from conduit.services.lnd import LndClient
|
|
93
|
+
print("[api] Using LND wallet backend", file=sys.stderr)
|
|
94
|
+
return LndClient()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Database Session
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def get_session() -> AsyncSession:
|
|
103
|
+
"""Create a new async database session."""
|
|
104
|
+
async with async_session_factory() as session:
|
|
105
|
+
yield session
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Conduit API middleware."""
|
|
2
|
+
|
|
3
|
+
from conduit.api.middleware.l402 import L402Middleware
|
|
4
|
+
from conduit.api.middleware.rate_limit import RateLimitMiddleware
|
|
5
|
+
from conduit.api.middleware.verification import VerificationEnforcementMiddleware
|
|
6
|
+
|
|
7
|
+
__all__ = ["L402Middleware", "RateLimitMiddleware", "VerificationEnforcementMiddleware"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
L402 FastAPI middleware — adds HTTP 402 payment-gated authentication.
|
|
3
|
+
|
|
4
|
+
How it works:
|
|
5
|
+
1. Request arrives at a protected endpoint.
|
|
6
|
+
2. Middleware checks for Authorization: L402 <macaroon>:<preimage> header.
|
|
7
|
+
- If present and valid → request passes through (authenticated via L402).
|
|
8
|
+
- If present but invalid → 401 with error details.
|
|
9
|
+
3. If no L402 header, checks for X-API-Key header (backward compat).
|
|
10
|
+
- If present and valid → request passes through (API key auth).
|
|
11
|
+
4. If neither credential is present:
|
|
12
|
+
- If L402 is enabled → 402 with WWW-Authenticate header + invoice.
|
|
13
|
+
- If L402 is disabled → 401 "API key required" (existing behavior).
|
|
14
|
+
|
|
15
|
+
This means L402 and API key auth coexist. Operators who don't enable L402
|
|
16
|
+
get the exact same behavior as before. Operators who enable it get a
|
|
17
|
+
pay-per-request alternative that doesn't require pre-shared keys.
|
|
18
|
+
|
|
19
|
+
The middleware is applied as a Starlette middleware, not a FastAPI dependency,
|
|
20
|
+
so it intercepts requests before routing and can set the 402 response with
|
|
21
|
+
the correct headers regardless of which router handles the endpoint.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
30
|
+
from starlette.requests import Request
|
|
31
|
+
from starlette.responses import JSONResponse
|
|
32
|
+
|
|
33
|
+
from conduit.core.config import settings
|
|
34
|
+
from conduit.services.l402 import (
|
|
35
|
+
create_l402_challenge,
|
|
36
|
+
format_www_authenticate,
|
|
37
|
+
parse_l402_header,
|
|
38
|
+
verify_l402,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class L402Middleware(BaseHTTPMiddleware):
|
|
43
|
+
"""
|
|
44
|
+
Starlette middleware that implements the L402 payment-gate protocol.
|
|
45
|
+
|
|
46
|
+
When L402 is enabled (L402_ENABLED=true), endpoints that don't match
|
|
47
|
+
a free-route prefix will require either:
|
|
48
|
+
- A valid X-API-Key header (existing auth), OR
|
|
49
|
+
- A valid Authorization: L402 <macaroon>:<preimage> header.
|
|
50
|
+
|
|
51
|
+
If neither is present, the middleware returns 402 Payment Required
|
|
52
|
+
with a freshly minted invoice.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, app, get_lnd_fn: Callable | None = None):
|
|
56
|
+
"""
|
|
57
|
+
Args:
|
|
58
|
+
app: the ASGI application.
|
|
59
|
+
get_lnd_fn: callable that returns an LndClient. Injected so the
|
|
60
|
+
middleware doesn't import the LND singleton at module
|
|
61
|
+
level (avoids circular imports and makes testing easier).
|
|
62
|
+
"""
|
|
63
|
+
super().__init__(app)
|
|
64
|
+
self._get_lnd = get_lnd_fn
|
|
65
|
+
|
|
66
|
+
async def dispatch(self, request: Request, call_next):
|
|
67
|
+
# If L402 is not enabled, pass through — existing API key auth handles it.
|
|
68
|
+
if not settings.l402_enabled:
|
|
69
|
+
return await call_next(request)
|
|
70
|
+
|
|
71
|
+
# Check if this route is free (no payment required).
|
|
72
|
+
path = request.url.path
|
|
73
|
+
if self._is_free_route(path):
|
|
74
|
+
return await call_next(request)
|
|
75
|
+
|
|
76
|
+
# ── Try L402 credential first ───────────────────────────────
|
|
77
|
+
auth_header = request.headers.get("authorization", "")
|
|
78
|
+
l402_cred = parse_l402_header(auth_header)
|
|
79
|
+
|
|
80
|
+
if l402_cred is not None:
|
|
81
|
+
result = verify_l402(l402_cred)
|
|
82
|
+
if result.valid:
|
|
83
|
+
# Attach L402 metadata to request state for downstream use.
|
|
84
|
+
request.state.l402_payment_hash = result.payment_hash
|
|
85
|
+
request.state.l402_resource = result.resource
|
|
86
|
+
request.state.auth_method = "l402"
|
|
87
|
+
return await call_next(request)
|
|
88
|
+
else:
|
|
89
|
+
return JSONResponse(
|
|
90
|
+
status_code=401,
|
|
91
|
+
content={
|
|
92
|
+
"error": "l402_invalid",
|
|
93
|
+
"detail": result.error,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# ── Try API key (backward compat) ───────────────────────────
|
|
98
|
+
api_key = request.headers.get("x-api-key", "")
|
|
99
|
+
if api_key:
|
|
100
|
+
# Let the existing verify_api_key dependency handle validation.
|
|
101
|
+
# We just pass through here; the router dependency will 401 if bad.
|
|
102
|
+
return await call_next(request)
|
|
103
|
+
|
|
104
|
+
# ── No credentials at all → issue 402 challenge ─────────────
|
|
105
|
+
return await self._issue_challenge(request)
|
|
106
|
+
|
|
107
|
+
async def _issue_challenge(self, request: Request) -> JSONResponse:
|
|
108
|
+
"""Return a 402 Payment Required response with L402 challenge."""
|
|
109
|
+
if self._get_lnd is None:
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
status_code=503,
|
|
112
|
+
content={"error": "L402 enabled but LND client not configured"},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
lnd = self._get_lnd()
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(f"[l402] Could not connect to LND: {e}", file=sys.stderr)
|
|
119
|
+
return JSONResponse(
|
|
120
|
+
status_code=503,
|
|
121
|
+
content={"error": "Lightning node unavailable for L402 challenge"},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Determine price for this endpoint.
|
|
125
|
+
price = self._get_price_for_route(request.url.path)
|
|
126
|
+
|
|
127
|
+
# Determine resource scope from the route.
|
|
128
|
+
resource = self._route_to_resource(request.url.path)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
challenge = create_l402_challenge(
|
|
132
|
+
lnd,
|
|
133
|
+
amount_sats=price,
|
|
134
|
+
memo=f"Conduit L402: {request.method} {request.url.path}",
|
|
135
|
+
resource=resource,
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
print(f"[l402] Failed to create challenge: {e}", file=sys.stderr)
|
|
139
|
+
return JSONResponse(
|
|
140
|
+
status_code=503,
|
|
141
|
+
content={"error": "Failed to generate L402 invoice"},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return JSONResponse(
|
|
145
|
+
status_code=402,
|
|
146
|
+
content={
|
|
147
|
+
"error": "payment_required",
|
|
148
|
+
"message": (
|
|
149
|
+
"Pay the Lightning invoice, then retry with "
|
|
150
|
+
"Authorization: L402 <macaroon>:<preimage>"
|
|
151
|
+
),
|
|
152
|
+
"macaroon": challenge.macaroon,
|
|
153
|
+
"invoice": challenge.invoice,
|
|
154
|
+
"payment_hash": challenge.payment_hash,
|
|
155
|
+
"amount_sats": challenge.amount_sats,
|
|
156
|
+
"expires_at": challenge.expires_at,
|
|
157
|
+
},
|
|
158
|
+
headers={
|
|
159
|
+
"WWW-Authenticate": format_www_authenticate(challenge),
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _is_free_route(self, path: str) -> bool:
|
|
164
|
+
"""Check if a route is free (no L402 or API key required)."""
|
|
165
|
+
for prefix in settings.l402_free_route_list:
|
|
166
|
+
# Exact match for "/" to avoid matching everything
|
|
167
|
+
if prefix == "/":
|
|
168
|
+
if path == "/":
|
|
169
|
+
return True
|
|
170
|
+
elif path.startswith(prefix):
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def _get_price_for_route(self, path: str) -> int:
|
|
175
|
+
"""
|
|
176
|
+
Determine the price in sats for an endpoint.
|
|
177
|
+
|
|
178
|
+
Currently uses the global default. Future: per-skill pricing
|
|
179
|
+
based on the skill's registered price_sats.
|
|
180
|
+
"""
|
|
181
|
+
# Skill execution endpoints could use the skill's own price.
|
|
182
|
+
# For now, use the global default.
|
|
183
|
+
return settings.l402_default_price_sats
|
|
184
|
+
|
|
185
|
+
def _route_to_resource(self, path: str) -> str | None:
|
|
186
|
+
"""
|
|
187
|
+
Map a route path to an L402 resource scope.
|
|
188
|
+
|
|
189
|
+
This constrains the minted token so it can only be used for the
|
|
190
|
+
resource the client originally requested — prevents a token bought
|
|
191
|
+
for GET /balance being replayed on POST /payments.
|
|
192
|
+
"""
|
|
193
|
+
# Strip /api/v1 prefix for cleaner resource names
|
|
194
|
+
clean = path.removeprefix("/api/v1")
|
|
195
|
+
|
|
196
|
+
if clean.startswith("/lightning"):
|
|
197
|
+
return "lightning"
|
|
198
|
+
elif clean.startswith("/marketplace"):
|
|
199
|
+
return "marketplace"
|
|
200
|
+
elif clean.startswith("/security"):
|
|
201
|
+
return "security"
|
|
202
|
+
elif clean.startswith("/nostr"):
|
|
203
|
+
return "nostr"
|
|
204
|
+
|
|
205
|
+
return None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiting middleware — applies per-client, per-tool rate limits at the
|
|
3
|
+
HTTP layer.
|
|
4
|
+
|
|
5
|
+
Maps each REST route + method to its corresponding MCP tool name, extracts
|
|
6
|
+
the client identifier from the X-API-Key header (hashed for privacy), then
|
|
7
|
+
delegates to the SlidingWindowRateLimiter. Returns 429 with a Retry-After
|
|
8
|
+
header when the limit is exceeded.
|
|
9
|
+
|
|
10
|
+
This replaces the inline try/except blocks previously duplicated in every
|
|
11
|
+
router handler, centralizing rate limiting in one place so new endpoints
|
|
12
|
+
get protected automatically.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
from starlette.responses import JSONResponse
|
|
23
|
+
|
|
24
|
+
from conduit.services.rate_limiter import RateLimitExceeded, rate_limiter
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# Route → tool name mapping
|
|
28
|
+
# =============================================================================
|
|
29
|
+
|
|
30
|
+
# Each entry maps (HTTP method, route pattern) to a tool name.
|
|
31
|
+
# Patterns are matched top-to-bottom; first match wins.
|
|
32
|
+
# Use {param} for path parameters.
|
|
33
|
+
|
|
34
|
+
_ROUTE_TOOL_MAP: list[tuple[str, str, str]] = [
|
|
35
|
+
# Lightning
|
|
36
|
+
("GET", "/api/v1/lightning/node-info", "get_node_info"),
|
|
37
|
+
("GET", "/api/v1/lightning/balance", "get_balance"),
|
|
38
|
+
("POST", "/api/v1/lightning/invoices/decode", "decode_invoice"),
|
|
39
|
+
("POST", "/api/v1/lightning/invoices", "create_invoice"),
|
|
40
|
+
("POST", "/api/v1/lightning/payments", "pay_invoice"),
|
|
41
|
+
("GET", "/api/v1/lightning/payments/{param}", "check_payment"),
|
|
42
|
+
|
|
43
|
+
# Marketplace
|
|
44
|
+
("GET", "/api/v1/marketplace/skills", "discover_skills"),
|
|
45
|
+
("POST", "/api/v1/marketplace/skills", "register_skill"),
|
|
46
|
+
("GET", "/api/v1/marketplace/skills/{param}", "get_skill_details"),
|
|
47
|
+
("POST", "/api/v1/marketplace/executions", "request_skill_execution"),
|
|
48
|
+
("POST", "/api/v1/marketplace/executions/{param}/confirm", "confirm_skill_execution"),
|
|
49
|
+
("POST", "/api/v1/marketplace/executions/{param}/rate", "submit_rating"),
|
|
50
|
+
|
|
51
|
+
# Security
|
|
52
|
+
("GET", "/api/v1/security/spending", "get_spending_status"),
|
|
53
|
+
("POST", "/api/v1/security/macaroons", "create_macaroon"),
|
|
54
|
+
("GET", "/api/v1/security/permissions", "list_permissions"),
|
|
55
|
+
("GET", "/api/v1/security/anomalies", "get_anomaly_report"),
|
|
56
|
+
("POST", "/api/v1/security/verification/request", "request_verification"),
|
|
57
|
+
("POST", "/api/v1/security/verification/submit", "submit_verification"),
|
|
58
|
+
("GET", "/api/v1/security/verification/{param}", "get_verification_status"),
|
|
59
|
+
|
|
60
|
+
# Nostr
|
|
61
|
+
("POST", "/api/v1/nostr/publish", "nostr_publish_skill"),
|
|
62
|
+
("GET", "/api/v1/nostr/discover", "nostr_discover_skills"),
|
|
63
|
+
("GET", "/api/v1/nostr/profile", "nostr_get_profile"),
|
|
64
|
+
("GET", "/api/v1/nostr/relays/status", "nostr_relay_status"),
|
|
65
|
+
|
|
66
|
+
# Admin (M7: rate-limit admin routes — very low limits)
|
|
67
|
+
("GET", "/api/v1/admin/stats", "admin_stats"),
|
|
68
|
+
("DELETE", "/api/v1/admin/reset-demo", "admin_reset"),
|
|
69
|
+
("DELETE", "/api/v1/marketplace/skills/{param}", "admin_delete_skill"),
|
|
70
|
+
("DELETE", "/api/v1/marketplace/executions/{param}", "admin_delete_execution"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Pre-compile patterns: replace {param} with a regex group that matches
|
|
74
|
+
# one path segment (no slashes).
|
|
75
|
+
_COMPILED_ROUTES: list[tuple[str, re.Pattern, str]] = []
|
|
76
|
+
for method, pattern, tool in _ROUTE_TOOL_MAP:
|
|
77
|
+
regex = re.escape(pattern).replace(r"\{param\}", r"[^/]+")
|
|
78
|
+
_COMPILED_ROUTES.append((method, re.compile(f"^{regex}$"), tool))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_tool(method: str, path: str) -> str | None:
|
|
82
|
+
"""Match a request method + path to a tool name, or None if no match."""
|
|
83
|
+
for route_method, regex, tool in _COMPILED_ROUTES:
|
|
84
|
+
if method == route_method and regex.match(path):
|
|
85
|
+
return tool
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# =============================================================================
|
|
90
|
+
# Middleware
|
|
91
|
+
# =============================================================================
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_client_id(request: Request) -> str | None:
|
|
95
|
+
"""
|
|
96
|
+
Extract a client identifier from the request for per-client rate limiting.
|
|
97
|
+
|
|
98
|
+
Uses a SHA-256 hash of the API key so the actual key is never stored
|
|
99
|
+
in Redis or logs. Returns None if no API key is present (the limiter
|
|
100
|
+
will use a global counter).
|
|
101
|
+
"""
|
|
102
|
+
api_key = request.headers.get("x-api-key")
|
|
103
|
+
if not api_key:
|
|
104
|
+
return None
|
|
105
|
+
# Short hash — enough for uniqueness, not reversible
|
|
106
|
+
return hashlib.sha256(api_key.encode()).hexdigest()[:16]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
110
|
+
"""
|
|
111
|
+
Starlette middleware that enforces per-client, per-tool rate limits on
|
|
112
|
+
REST endpoints.
|
|
113
|
+
|
|
114
|
+
Sits early in the middleware stack so rate-limited requests are rejected
|
|
115
|
+
before touching auth, database, or LND. Unmatched routes (health, docs,
|
|
116
|
+
root) pass through without rate limiting.
|
|
117
|
+
|
|
118
|
+
Each API key gets its own independent rate limit window — one client
|
|
119
|
+
hitting the limit doesn't affect other clients.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
async def dispatch(self, request: Request, call_next):
|
|
123
|
+
tool = _resolve_tool(request.method, request.url.path)
|
|
124
|
+
|
|
125
|
+
if tool is None:
|
|
126
|
+
# Unrecognized route — health, docs, OpenAPI, etc. Pass through.
|
|
127
|
+
return await call_next(request)
|
|
128
|
+
|
|
129
|
+
client_id = _extract_client_id(request)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
rate_limiter.check(tool, client_id=client_id)
|
|
133
|
+
except RateLimitExceeded as e:
|
|
134
|
+
# L2: Use structured retry_after_seconds instead of regex parsing
|
|
135
|
+
retry_after = getattr(e, "retry_after_seconds", 60)
|
|
136
|
+
return JSONResponse(
|
|
137
|
+
status_code=429,
|
|
138
|
+
content={
|
|
139
|
+
"error": "rate_limit_exceeded",
|
|
140
|
+
"detail": str(e),
|
|
141
|
+
"tool": tool,
|
|
142
|
+
},
|
|
143
|
+
headers={
|
|
144
|
+
"Retry-After": str(retry_after),
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Attach the resolved tool name to request state so downstream
|
|
149
|
+
# handlers can use it for logging/metrics without re-resolving.
|
|
150
|
+
request.state.rate_limit_tool = tool
|
|
151
|
+
return await call_next(request)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Verification enforcement middleware — warns or blocks on unverified skills.
|
|
3
|
+
|
|
4
|
+
Applies to skill execution endpoints. When a consumer requests execution
|
|
5
|
+
of an unverified skill, the middleware:
|
|
6
|
+
|
|
7
|
+
1. Adds an X-Conduit-Verification-Warning header to the response so the
|
|
8
|
+
consumer's agent can surface the risk to the user.
|
|
9
|
+
2. If the consumer set ?require_verified=true (or the operator configured
|
|
10
|
+
REQUIRE_VERIFIED_SKILLS=true), returns 403 instead of proceeding.
|
|
11
|
+
|
|
12
|
+
This does NOT block skill discovery or registration — only execution of
|
|
13
|
+
unverified skills carries a warning or gate.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
23
|
+
from starlette.requests import Request
|
|
24
|
+
from starlette.responses import JSONResponse
|
|
25
|
+
|
|
26
|
+
from conduit.core.config import settings
|
|
27
|
+
|
|
28
|
+
# Execution endpoints that should check verification status.
|
|
29
|
+
# Match: POST /api/v1/marketplace/executions (request execution)
|
|
30
|
+
# Match: POST /api/v1/marketplace/executions/{id}/confirm
|
|
31
|
+
_EXECUTION_RE = re.compile(
|
|
32
|
+
r"^/api/v1/marketplace/executions(?:/[^/]+/confirm)?$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VerificationEnforcementMiddleware(BaseHTTPMiddleware):
|
|
37
|
+
"""
|
|
38
|
+
Starlette middleware that enforces provider verification on execution.
|
|
39
|
+
|
|
40
|
+
Injects itself between rate-limiting and routing. For execution
|
|
41
|
+
endpoints, looks up the skill's verification status and either:
|
|
42
|
+
- Adds a warning header (default behavior), or
|
|
43
|
+
- Blocks the request with 403 if enforcement is strict.
|
|
44
|
+
|
|
45
|
+
The skill_id comes from the request body for new executions (POST
|
|
46
|
+
/executions with skill_id in JSON) or from the execution record for
|
|
47
|
+
confirmations. For confirmations we pass through since the skill was
|
|
48
|
+
already checked at request time.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, app, get_session_fn: Callable | None = None):
|
|
52
|
+
super().__init__(app)
|
|
53
|
+
self._get_session = get_session_fn
|
|
54
|
+
|
|
55
|
+
async def dispatch(self, request: Request, call_next):
|
|
56
|
+
# Only check POST to execution endpoints
|
|
57
|
+
if request.method != "POST":
|
|
58
|
+
return await call_next(request)
|
|
59
|
+
|
|
60
|
+
path = request.url.path
|
|
61
|
+
|
|
62
|
+
# Only enforce on new execution requests (not confirm/rate)
|
|
63
|
+
if path != "/api/v1/marketplace/executions":
|
|
64
|
+
return await call_next(request)
|
|
65
|
+
|
|
66
|
+
# Check if enforcement is required (operator config or query param)
|
|
67
|
+
require_verified = settings.require_verified_skills
|
|
68
|
+
if not require_verified:
|
|
69
|
+
# Check query param override
|
|
70
|
+
require_param = request.query_params.get("require_verified", "")
|
|
71
|
+
require_verified = require_param.lower() in ("true", "1", "yes")
|
|
72
|
+
|
|
73
|
+
# Read the skill_id from the request body
|
|
74
|
+
skill_id = await self._extract_skill_id(request)
|
|
75
|
+
if not skill_id:
|
|
76
|
+
# Can't determine skill — let the router handle validation
|
|
77
|
+
return await call_next(request)
|
|
78
|
+
|
|
79
|
+
# Look up verification status
|
|
80
|
+
verification_status = await self._get_verification_status(skill_id)
|
|
81
|
+
|
|
82
|
+
if verification_status is None:
|
|
83
|
+
# Skill not found — let the router 404
|
|
84
|
+
return await call_next(request)
|
|
85
|
+
|
|
86
|
+
is_verified = verification_status in ("node_verified", "domain_verified", "fully_verified")
|
|
87
|
+
|
|
88
|
+
if not is_verified and require_verified:
|
|
89
|
+
return JSONResponse(
|
|
90
|
+
status_code=403,
|
|
91
|
+
content={
|
|
92
|
+
"error": "skill_not_verified",
|
|
93
|
+
"detail": (
|
|
94
|
+
f"Skill is '{verification_status}'. Execution of "
|
|
95
|
+
f"unverified skills is blocked by policy. The provider "
|
|
96
|
+
f"must complete node or domain verification first."
|
|
97
|
+
),
|
|
98
|
+
"verification_status": verification_status,
|
|
99
|
+
"skill_id": skill_id,
|
|
100
|
+
},
|
|
101
|
+
headers={
|
|
102
|
+
"X-Conduit-Verification": verification_status,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Proceed with the request, adding a warning header if unverified
|
|
107
|
+
response = await call_next(request)
|
|
108
|
+
|
|
109
|
+
if not is_verified:
|
|
110
|
+
response.headers["X-Conduit-Verification-Warning"] = (
|
|
111
|
+
f"Skill is '{verification_status}'. "
|
|
112
|
+
"Provider has not completed verification."
|
|
113
|
+
)
|
|
114
|
+
response.headers["X-Conduit-Verification"] = verification_status
|
|
115
|
+
|
|
116
|
+
return response
|
|
117
|
+
|
|
118
|
+
async def _extract_skill_id(self, request: Request) -> str | None:
|
|
119
|
+
"""Extract skill_id from the JSON request body.
|
|
120
|
+
|
|
121
|
+
H11: Uses request.body() instead of request.json() so the raw
|
|
122
|
+
bytes are cached on the Request object. This avoids consuming
|
|
123
|
+
the ASGI receive stream, which would leave downstream handlers
|
|
124
|
+
with an empty body in some BaseHTTPMiddleware configurations.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
import json
|
|
128
|
+
raw = await request.body()
|
|
129
|
+
body = json.loads(raw)
|
|
130
|
+
return body.get("skill_id")
|
|
131
|
+
except Exception:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
async def _get_verification_status(self, skill_id: str) -> str | None:
|
|
135
|
+
"""Look up a skill's verification status from the database."""
|
|
136
|
+
if not self._get_session:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
import uuid
|
|
141
|
+
|
|
142
|
+
from sqlalchemy import select
|
|
143
|
+
|
|
144
|
+
from conduit.models.skill import Skill
|
|
145
|
+
|
|
146
|
+
uid = uuid.UUID(skill_id)
|
|
147
|
+
session_factory = self._get_session
|
|
148
|
+
|
|
149
|
+
async with session_factory() as session:
|
|
150
|
+
result = await session.execute(
|
|
151
|
+
select(Skill.verification_status).where(Skill.id == uid)
|
|
152
|
+
)
|
|
153
|
+
row = result.scalar_one_or_none()
|
|
154
|
+
return row
|
|
155
|
+
except Exception as e:
|
|
156
|
+
print(
|
|
157
|
+
f"[verification-middleware] Could not check skill {skill_id}: {e}",
|
|
158
|
+
file=sys.stderr,
|
|
159
|
+
)
|
|
160
|
+
return None
|