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.
Files changed (54) hide show
  1. conduit/__init__.py +3 -0
  2. conduit/api/__init__.py +0 -0
  3. conduit/api/deps.py +105 -0
  4. conduit/api/middleware/__init__.py +7 -0
  5. conduit/api/middleware/l402.py +205 -0
  6. conduit/api/middleware/rate_limit.py +151 -0
  7. conduit/api/middleware/verification.py +160 -0
  8. conduit/api/routers/__init__.py +5 -0
  9. conduit/api/routers/admin.py +77 -0
  10. conduit/api/routers/lightning.py +218 -0
  11. conduit/api/routers/marketplace.py +636 -0
  12. conduit/api/routers/nostr.py +235 -0
  13. conduit/api/routers/security.py +189 -0
  14. conduit/core/__init__.py +0 -0
  15. conduit/core/config.py +129 -0
  16. conduit/core/database.py +31 -0
  17. conduit/main.py +213 -0
  18. conduit/mcp_server.py +2334 -0
  19. conduit/models/__init__.py +23 -0
  20. conduit/models/anomaly_flag.py +43 -0
  21. conduit/models/base.py +27 -0
  22. conduit/models/execution.py +74 -0
  23. conduit/models/invoice.py +54 -0
  24. conduit/models/payment.py +58 -0
  25. conduit/models/rating.py +49 -0
  26. conduit/models/skill.py +85 -0
  27. conduit/models/spending_log.py +35 -0
  28. conduit/models/wallet.py +42 -0
  29. conduit/schemas/__init__.py +0 -0
  30. conduit/schemas/invoice.py +32 -0
  31. conduit/schemas/payment.py +30 -0
  32. conduit/schemas/wallet.py +36 -0
  33. conduit/services/__init__.py +0 -0
  34. conduit/services/anomaly_detector.py +274 -0
  35. conduit/services/fee_calculator.py +61 -0
  36. conduit/services/l402.py +377 -0
  37. conduit/services/lnd.py +284 -0
  38. conduit/services/macaroon_auth.py +332 -0
  39. conduit/services/nostr.py +819 -0
  40. conduit/services/nwc.py +728 -0
  41. conduit/services/proto_generated/lightning_pb2.py +683 -0
  42. conduit/services/proto_generated/lightning_pb2_grpc.py +3377 -0
  43. conduit/services/provider_verification.py +482 -0
  44. conduit/services/rate_limiter.py +450 -0
  45. conduit/services/rating_integrity.py +201 -0
  46. conduit/services/skill_executor.py +191 -0
  47. conduit/services/spending_limiter.py +383 -0
  48. conduit/services/url_safety.py +192 -0
  49. conduit/services/wallet_backend.py +149 -0
  50. conduit_lightning-0.1.0.dist-info/METADATA +297 -0
  51. conduit_lightning-0.1.0.dist-info/RECORD +54 -0
  52. conduit_lightning-0.1.0.dist-info/WHEEL +4 -0
  53. conduit_lightning-0.1.0.dist-info/entry_points.txt +3 -0
  54. conduit_lightning-0.1.0.dist-info/licenses/LICENSE +21 -0
conduit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Conduit - Lightning Payment Rails for AI Agents."""
2
+
3
+ __version__ = "0.1.0"
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
@@ -0,0 +1,5 @@
1
+ """API routers — Lightning, Marketplace, Security, Nostr."""
2
+
3
+ from conduit.api.routers import lightning, marketplace, nostr, security
4
+
5
+ __all__ = ["lightning", "marketplace", "security", "nostr"]