constellation-node-sdk 1.0.1__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 (45) hide show
  1. constellation_node_sdk/__init__.py +123 -0
  2. constellation_node_sdk/gate/__init__.py +47 -0
  3. constellation_node_sdk/gate/client.py +108 -0
  4. constellation_node_sdk/gate/config.py +153 -0
  5. constellation_node_sdk/gate/errors.py +17 -0
  6. constellation_node_sdk/gate/policy.py +70 -0
  7. constellation_node_sdk/gate/registration.py +144 -0
  8. constellation_node_sdk/orchestrator/__init__.py +3 -0
  9. constellation_node_sdk/orchestrator/base.py +57 -0
  10. constellation_node_sdk/orchestrator/merge.py +47 -0
  11. constellation_node_sdk/orchestrator/packet_builder.py +49 -0
  12. constellation_node_sdk/orchestrator/retry.py +61 -0
  13. constellation_node_sdk/orchestrator/state.py +78 -0
  14. constellation_node_sdk/orchestrator/step_executor.py +87 -0
  15. constellation_node_sdk/py.typed +0 -0
  16. constellation_node_sdk/runtime/__init__.py +52 -0
  17. constellation_node_sdk/runtime/app.py +274 -0
  18. constellation_node_sdk/runtime/config.py +307 -0
  19. constellation_node_sdk/runtime/errors.py +112 -0
  20. constellation_node_sdk/runtime/execution.py +249 -0
  21. constellation_node_sdk/runtime/handlers.py +45 -0
  22. constellation_node_sdk/runtime/inbound_policy.py +86 -0
  23. constellation_node_sdk/runtime/lifecycle.py +29 -0
  24. constellation_node_sdk/runtime/observability.py +72 -0
  25. constellation_node_sdk/runtime/preflight.py +122 -0
  26. constellation_node_sdk/security/__init__.py +36 -0
  27. constellation_node_sdk/security/delegation.py +57 -0
  28. constellation_node_sdk/security/errors.py +25 -0
  29. constellation_node_sdk/security/signing.py +121 -0
  30. constellation_node_sdk/security/validation.py +230 -0
  31. constellation_node_sdk/security/verification.py +107 -0
  32. constellation_node_sdk/transport/__init__.py +88 -0
  33. constellation_node_sdk/transport/codec.py +21 -0
  34. constellation_node_sdk/transport/errors.py +41 -0
  35. constellation_node_sdk/transport/hashing.py +73 -0
  36. constellation_node_sdk/transport/hop_trace.py +378 -0
  37. constellation_node_sdk/transport/lineage.py +28 -0
  38. constellation_node_sdk/transport/models.py +447 -0
  39. constellation_node_sdk/transport/packet.py +331 -0
  40. constellation_node_sdk/transport/provenance.py +53 -0
  41. constellation_node_sdk/transport/tenant.py +76 -0
  42. constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
  43. constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
  44. constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
  45. constellation_node_sdk-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Callable
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any, Literal
6
+
7
+ from fastapi import FastAPI, HTTPException, Request
8
+ from fastapi.responses import JSONResponse
9
+ from starlette.responses import Response
10
+
11
+ from constellation_node_sdk.gate.registration import register_from_env
12
+ from constellation_node_sdk.transport.packet import TransportPacket
13
+
14
+ from .config import NodeRuntimeConfig, get_runtime_config
15
+ from .errors import raise_http_exception
16
+ from .execution import create_error_transport_packet, execute_transport_packet
17
+ from .inbound_policy import validate_execute_ingress_packet, validate_relay_ingress_packet
18
+ from .lifecycle import LifecycleHook, NoOpLifecycle
19
+ from .observability import configure_logging, metrics_response, record_request, set_readiness
20
+ from .preflight import run_preflight
21
+
22
+
23
+ def _key_material_from_config(config: NodeRuntimeConfig) -> tuple[bytes | str | None, str | None]:
24
+ if config.signing_algorithm == "hmac-sha256":
25
+ return config.signing_key, config.signing_algorithm
26
+ if config.signing_algorithm == "ed25519":
27
+ return config.signing_private_key, config.signing_algorithm
28
+ return None, None
29
+
30
+
31
+ def _resolve_route_validation(
32
+ *,
33
+ route_mode: Literal["execute", "relay"],
34
+ resolved_config: NodeRuntimeConfig,
35
+ ) -> dict[str, Any]:
36
+ if route_mode == "relay":
37
+ return {
38
+ "require_signature": resolved_config.relay_require_signature
39
+ if resolved_config.relay_require_signature is not None
40
+ else resolved_config.require_signature,
41
+ "verify_hop_signatures": resolved_config.relay_verify_hop_signatures
42
+ if resolved_config.relay_verify_hop_signatures is not None
43
+ else resolved_config.verify_hop_signatures,
44
+ "allowed_actions": (
45
+ resolved_config.relay_allowed_actions or resolved_config.allowed_actions or None
46
+ ),
47
+ "allowed_packet_types": (
48
+ resolved_config.relay_allowed_packet_types
49
+ or resolved_config.allowed_packet_types
50
+ or None
51
+ ),
52
+ }
53
+ return {
54
+ "require_signature": resolved_config.execute_require_signature
55
+ if resolved_config.execute_require_signature is not None
56
+ else resolved_config.require_signature,
57
+ "verify_hop_signatures": resolved_config.execute_verify_hop_signatures
58
+ if resolved_config.execute_verify_hop_signatures is not None
59
+ else resolved_config.verify_hop_signatures,
60
+ "allowed_actions": (
61
+ resolved_config.execute_allowed_actions or resolved_config.allowed_actions or None
62
+ ),
63
+ "allowed_packet_types": (
64
+ resolved_config.execute_allowed_packet_types
65
+ or resolved_config.allowed_packet_types
66
+ or None
67
+ ),
68
+ }
69
+
70
+
71
+ async def _handle_transport_request(
72
+ request: Request,
73
+ *,
74
+ execution_mode: Literal["execute", "relay"],
75
+ resolved_config: NodeRuntimeConfig,
76
+ validate_ingress: Callable[[TransportPacket], None],
77
+ ) -> JSONResponse:
78
+ packet: TransportPacket | None = None
79
+ try:
80
+ body = await request.json()
81
+ except Exception as exc:
82
+ record_request(config=resolved_config, action="unknown", status="invalid_json")
83
+ raise HTTPException(status_code=400, detail=f"invalid JSON body: {exc}") from exc
84
+
85
+ try:
86
+ if not isinstance(body, dict):
87
+ raise ValueError("request body must be a JSON object")
88
+ packet = TransportPacket.model_validate(body)
89
+ if resolved_config.enforce_gate_only_ingress:
90
+ validate_ingress(packet)
91
+
92
+ route_validation = _resolve_route_validation(
93
+ route_mode=execution_mode,
94
+ resolved_config=resolved_config,
95
+ )
96
+
97
+ response_signing_key, response_signing_algorithm = _key_material_from_config(
98
+ resolved_config
99
+ )
100
+
101
+ response_packet = await execute_transport_packet(
102
+ packet,
103
+ node_name=resolved_config.node_name,
104
+ signing_key=(
105
+ response_signing_key if response_signing_algorithm == "hmac-sha256" else None
106
+ ),
107
+ signing_private_key=(
108
+ str(response_signing_key)
109
+ if response_signing_algorithm == "ed25519" and response_signing_key
110
+ else None
111
+ ),
112
+ signing_key_id=resolved_config.signing_key_id,
113
+ signing_algorithm=response_signing_algorithm,
114
+ verifying_keys=resolved_config.verifying_keys,
115
+ require_signature=route_validation["require_signature"],
116
+ allowed_actions=route_validation["allowed_actions"],
117
+ allowed_packet_types=route_validation["allowed_packet_types"],
118
+ required_idempotency_actions=(resolved_config.require_idempotency_for_actions or None),
119
+ replay_enabled=resolved_config.replay_enabled,
120
+ dev_mode=resolved_config.dev_mode,
121
+ verify_hop_signatures=route_validation["verify_hop_signatures"],
122
+ allowed_clock_skew_seconds=resolved_config.allowed_clock_skew_seconds,
123
+ max_packet_bytes=resolved_config.max_packet_bytes,
124
+ max_hop_depth=resolved_config.max_hop_depth,
125
+ max_delegation_depth=resolved_config.max_delegation_depth,
126
+ max_attachments=resolved_config.max_attachments,
127
+ max_attachment_size_bytes=resolved_config.max_attachment_size_bytes,
128
+ allowed_attachment_schemes=resolved_config.attachment_allowed_schemes,
129
+ allow_private_attachment_hosts=resolved_config.allow_private_attachment_hosts,
130
+ )
131
+
132
+ status = str(response_packet.payload.get("status", "completed")).strip().lower()
133
+ record_request(config=resolved_config, action=packet.header.action, status=status)
134
+ return JSONResponse(content=response_packet.model_dump_json_dict())
135
+
136
+ except Exception as exc:
137
+ if packet is not None and resolved_config.return_transport_errors:
138
+ response_signing_key, response_signing_algorithm = _key_material_from_config(
139
+ resolved_config
140
+ )
141
+ failure_packet = create_error_transport_packet(
142
+ packet,
143
+ exc,
144
+ node_name=resolved_config.node_name,
145
+ signing_key=(
146
+ response_signing_key if response_signing_algorithm == "hmac-sha256" else None
147
+ ),
148
+ signing_private_key=(
149
+ str(response_signing_key)
150
+ if response_signing_algorithm == "ed25519" and response_signing_key
151
+ else None
152
+ ),
153
+ signing_key_id=resolved_config.signing_key_id,
154
+ signing_algorithm=response_signing_algorithm,
155
+ expose_internal_errors=resolved_config.expose_internal_errors,
156
+ )
157
+ record_request(
158
+ config=resolved_config,
159
+ action=packet.header.action,
160
+ status="failed",
161
+ )
162
+ return JSONResponse(content=failure_packet.model_dump_json_dict())
163
+
164
+ record_request(
165
+ config=resolved_config,
166
+ action="unknown" if packet is None else packet.header.action,
167
+ status="error",
168
+ )
169
+ raise_http_exception(exc)
170
+
171
+
172
+ def create_node_app(
173
+ *,
174
+ service_name: str | None = None,
175
+ version: str | None = None,
176
+ lifecycle_hook: LifecycleHook | None = None,
177
+ config: NodeRuntimeConfig | None = None,
178
+ auto_register_with_gate: bool = True,
179
+ ) -> FastAPI:
180
+ resolved_config = config or get_runtime_config()
181
+ resolved_service_name = service_name or resolved_config.service_name
182
+ resolved_version = version or resolved_config.service_version
183
+ resolved_lifecycle = lifecycle_hook or NoOpLifecycle()
184
+
185
+ @asynccontextmanager
186
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
187
+ configure_logging(resolved_config)
188
+ run_preflight(resolved_config)
189
+
190
+ app.state.runtime_ready = False
191
+ set_readiness(config=resolved_config, ready=False)
192
+
193
+ await resolved_lifecycle.startup()
194
+
195
+ if auto_register_with_gate and resolved_config.gate_url:
196
+ await register_from_env()
197
+
198
+ app.state.runtime_ready = True
199
+ set_readiness(config=resolved_config, ready=True)
200
+
201
+ yield
202
+
203
+ app.state.runtime_ready = False
204
+ set_readiness(config=resolved_config, ready=False)
205
+ await resolved_lifecycle.shutdown()
206
+
207
+ app = FastAPI(
208
+ title=resolved_service_name,
209
+ version=resolved_version,
210
+ lifespan=lifespan,
211
+ )
212
+ app.state.runtime_ready = False
213
+
214
+ @app.get("/v1/health")
215
+ async def health() -> dict[str, Any]:
216
+ return {
217
+ "status": "healthy" if bool(getattr(app.state, "runtime_ready", False)) else "starting",
218
+ "service_name": resolved_config.service_name,
219
+ "service_version": resolved_config.service_version,
220
+ "node_name": resolved_config.node_name,
221
+ "ready": bool(getattr(app.state, "runtime_ready", False)),
222
+ }
223
+
224
+ @app.get("/metrics")
225
+ async def metrics() -> Response:
226
+ return metrics_response()
227
+
228
+ @app.post("/v1/execute")
229
+ async def execute(request: Request) -> JSONResponse:
230
+ def _validate_execute_ingress(packet: TransportPacket) -> None:
231
+ validate_execute_ingress_packet(
232
+ packet,
233
+ local_node=resolved_config.node_name,
234
+ gate_node_name=resolved_config.gate_node_name,
235
+ require_route_kind=resolved_config.require_gate_mediation_provenance,
236
+ )
237
+
238
+ return await _handle_transport_request(
239
+ request,
240
+ execution_mode="execute",
241
+ resolved_config=resolved_config,
242
+ validate_ingress=_validate_execute_ingress,
243
+ )
244
+
245
+ if resolved_config.enable_relay_route:
246
+
247
+ @app.post("/v1/relay")
248
+ async def relay(request: Request) -> JSONResponse:
249
+ def _validate_relay_ingress(packet: TransportPacket) -> None:
250
+ validate_relay_ingress_packet(
251
+ packet,
252
+ local_node=resolved_config.node_name,
253
+ gate_node_name=resolved_config.gate_node_name,
254
+ require_route_kind=resolved_config.require_gate_mediation_provenance,
255
+ allowed_actions=(
256
+ resolved_config.relay_allowed_actions
257
+ or resolved_config.allowed_actions
258
+ or None
259
+ ),
260
+ allowed_packet_types=(
261
+ resolved_config.relay_allowed_packet_types
262
+ or resolved_config.allowed_packet_types
263
+ or None
264
+ ),
265
+ )
266
+
267
+ return await _handle_transport_request(
268
+ request,
269
+ execution_mode="relay",
270
+ resolved_config=resolved_config,
271
+ validate_ingress=_validate_relay_ingress,
272
+ )
273
+
274
+ return app
@@ -0,0 +1,307 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from functools import lru_cache
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8
+
9
+ _ALLOWED_ENVIRONMENTS = {"local", "dev", "test", "staging", "prod"}
10
+ _ALLOWED_SIGNING_ALGORITHMS = {"hmac-sha256", "ed25519"}
11
+
12
+
13
+ def _env_bool(name: str, default: bool = False) -> bool:
14
+ raw = os.getenv(name)
15
+ if raw is None:
16
+ return default
17
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
18
+
19
+
20
+ def _env_tuple(name: str) -> tuple[str, ...]:
21
+ raw = os.getenv(name, "")
22
+ return tuple(item.strip() for item in raw.split(",") if item.strip())
23
+
24
+
25
+ def _env_json_map(name: str) -> dict[str, str]:
26
+ raw = os.getenv(name, "").strip()
27
+ if not raw:
28
+ return {}
29
+ parsed = json.loads(raw)
30
+ if not isinstance(parsed, dict):
31
+ raise ValueError(f"{name} must be a JSON object")
32
+ result: dict[str, str] = {}
33
+ for key, value in parsed.items():
34
+ if not isinstance(key, str) or not isinstance(value, str):
35
+ raise ValueError(f"{name} must map strings to strings")
36
+ result[key] = value
37
+ return result
38
+
39
+
40
+ class NodeRuntimeConfig(BaseModel):
41
+ model_config = ConfigDict(frozen=True, extra="forbid")
42
+
43
+ environment: str
44
+ node_name: str
45
+ service_name: str
46
+ service_version: str
47
+
48
+ dev_mode: bool = False
49
+ require_signature: bool = False
50
+ expose_internal_errors: bool = False
51
+ return_transport_errors: bool = True
52
+
53
+ signing_algorithm: str = "hmac-sha256"
54
+ signing_key: str | None = None
55
+ signing_private_key: str | None = None
56
+ signing_key_id: str | None = None
57
+ verifying_keys: dict[str, str] = Field(default_factory=dict)
58
+
59
+ allowed_actions: tuple[str, ...] = ()
60
+ allowed_packet_types: tuple[str, ...] = ("request", "command", "delegation", "replay_request")
61
+ require_idempotency_for_actions: tuple[str, ...] = ()
62
+
63
+ allowed_clock_skew_seconds: int = Field(default=30, ge=0)
64
+ max_packet_bytes: int = Field(default=262_144, ge=1024)
65
+ max_hop_depth: int = Field(default=64, ge=1)
66
+ max_delegation_depth: int = Field(default=8, ge=1)
67
+ max_attachments: int = Field(default=32, ge=0)
68
+ max_attachment_size_bytes: int = Field(default=10_485_760, ge=0)
69
+ attachment_allowed_schemes: tuple[str, ...] = ()
70
+ allow_private_attachment_hosts: bool = False
71
+
72
+ replay_enabled: bool = True
73
+ verify_hop_signatures: bool = False
74
+
75
+ gate_node_name: str = "gate"
76
+ enforce_gate_only_ingress: bool = True
77
+ enable_relay_route: bool = True
78
+ require_gate_mediation_provenance: bool = True
79
+
80
+ execute_require_signature: bool | None = None
81
+ execute_verify_hop_signatures: bool | None = None
82
+ execute_allowed_actions: tuple[str, ...] = ()
83
+ execute_allowed_packet_types: tuple[str, ...] = ()
84
+
85
+ relay_require_signature: bool | None = None
86
+ relay_verify_hop_signatures: bool | None = None
87
+ relay_allowed_actions: tuple[str, ...] = ()
88
+ relay_allowed_packet_types: tuple[str, ...] = (
89
+ "request",
90
+ "command",
91
+ "delegation",
92
+ "replay_request",
93
+ )
94
+
95
+ gate_url: str | None = None
96
+ # Default to loopback — containers must set HOST=0.0.0.0 explicitly via env var.
97
+ # This prevents accidental public binding on bare-metal or dev environments.
98
+ host: str = "127.0.0.1"
99
+ port: int = Field(default=8000, ge=1, le=65535)
100
+
101
+ @field_validator(
102
+ "environment",
103
+ "node_name",
104
+ "gate_node_name",
105
+ "service_name",
106
+ "service_version",
107
+ "signing_algorithm",
108
+ "host",
109
+ )
110
+ @classmethod
111
+ def validate_required_strings(cls, value: str) -> str:
112
+ normalized = value.strip()
113
+ if not normalized:
114
+ raise ValueError("field must not be empty")
115
+ return normalized
116
+
117
+ @field_validator("environment")
118
+ @classmethod
119
+ def validate_environment(cls, value: str) -> str:
120
+ normalized = value.strip().lower()
121
+ if normalized not in _ALLOWED_ENVIRONMENTS:
122
+ raise ValueError(f"environment must be one of {sorted(_ALLOWED_ENVIRONMENTS)}")
123
+ return normalized
124
+
125
+ @field_validator("signing_algorithm")
126
+ @classmethod
127
+ def validate_signing_algorithm(cls, value: str) -> str:
128
+ normalized = value.strip().lower()
129
+ if normalized not in _ALLOWED_SIGNING_ALGORITHMS:
130
+ raise ValueError(
131
+ f"signing_algorithm must be one of {sorted(_ALLOWED_SIGNING_ALGORITHMS)}"
132
+ )
133
+ return normalized
134
+
135
+ @field_validator(
136
+ "allowed_actions",
137
+ "allowed_packet_types",
138
+ "require_idempotency_for_actions",
139
+ "attachment_allowed_schemes",
140
+ "execute_allowed_actions",
141
+ "execute_allowed_packet_types",
142
+ "relay_allowed_actions",
143
+ "relay_allowed_packet_types",
144
+ )
145
+ @classmethod
146
+ def validate_string_tuples(cls, value: tuple[str, ...]) -> tuple[str, ...]:
147
+ normalized = tuple(item.strip().lower() for item in value if item.strip())
148
+ if len(set(normalized)) != len(normalized):
149
+ raise ValueError("tuple entries must not contain duplicates")
150
+ return normalized
151
+
152
+ @field_validator("signing_key_id")
153
+ @classmethod
154
+ def validate_optional_key_id(cls, value: str | None) -> str | None:
155
+ if value is None:
156
+ return None
157
+ normalized = value.strip()
158
+ if not normalized:
159
+ raise ValueError("signing_key_id must not be blank")
160
+ return normalized
161
+
162
+ @field_validator("verifying_keys")
163
+ @classmethod
164
+ def validate_verifying_keys(cls, value: dict[str, str]) -> dict[str, str]:
165
+ normalized: dict[str, str] = {}
166
+ for key_id, key_value in value.items():
167
+ normalized_key = key_id.strip()
168
+ normalized_value = key_value.strip()
169
+ if not normalized_key or not normalized_value:
170
+ raise ValueError("verifying_keys must not contain blank keys or values")
171
+ normalized[normalized_key] = normalized_value
172
+ return normalized
173
+
174
+ @field_validator("gate_url")
175
+ @classmethod
176
+ def validate_gate_url(cls, value: str | None) -> str | None:
177
+ if value is None:
178
+ return None
179
+ normalized = value.strip().rstrip("/")
180
+ if not normalized:
181
+ raise ValueError("gate_url must not be blank")
182
+ if not (normalized.startswith("http://") or normalized.startswith("https://")):
183
+ raise ValueError("gate_url must start with http:// or https://")
184
+ return normalized
185
+
186
+ @model_validator(mode="after")
187
+ def validate_security_profile(self) -> NodeRuntimeConfig:
188
+ if self.environment in {"staging", "prod"} and self.dev_mode:
189
+ raise ValueError("dev_mode cannot be enabled in staging or prod")
190
+
191
+ if self.require_signature:
192
+ if self.signing_algorithm == "hmac-sha256" and not self.signing_key:
193
+ raise ValueError("hmac-sha256 requires signing_key when require_signature=true")
194
+ if self.signing_algorithm == "ed25519" and not self.signing_private_key:
195
+ raise ValueError("ed25519 requires signing_private_key when require_signature=true")
196
+
197
+ if self.signing_algorithm == "ed25519" and self.signing_key is not None:
198
+ raise ValueError("signing_key must not be used with ed25519")
199
+
200
+ known_actions = set(self.allowed_actions) | set(self.execute_allowed_actions)
201
+ invalid_idempotency = set(self.require_idempotency_for_actions) - known_actions
202
+ if invalid_idempotency:
203
+ invalid = ", ".join(sorted(invalid_idempotency))
204
+ raise ValueError(f"require_idempotency_for_actions contains unknown actions: {invalid}")
205
+
206
+ if self.max_attachment_size_bytes > self.max_packet_bytes:
207
+ raise ValueError("max_attachment_size_bytes must not exceed max_packet_bytes")
208
+
209
+ if self.max_attachments > 0 and not self.attachment_allowed_schemes:
210
+ raise ValueError(
211
+ "attachment_allowed_schemes must be configured when attachments are enabled"
212
+ )
213
+
214
+ execute_actions = (
215
+ set(self.execute_allowed_actions)
216
+ if self.execute_allowed_actions
217
+ else set(self.allowed_actions)
218
+ )
219
+ invalid_execute_idempotency = set(self.require_idempotency_for_actions) - execute_actions
220
+ if invalid_execute_idempotency:
221
+ invalid = ", ".join(sorted(invalid_execute_idempotency))
222
+ raise ValueError(
223
+ f"require_idempotency_for_actions contains unknown execute actions: {invalid}"
224
+ )
225
+
226
+ return self
227
+
228
+ def resolve_verifying_key(self, key_id: str | None) -> str | bytes | None:
229
+ if key_id is None:
230
+ return None
231
+ if key_id in self.verifying_keys:
232
+ return self.verifying_keys[key_id]
233
+ if self.signing_key_id is not None and key_id == self.signing_key_id:
234
+ if self.signing_algorithm == "hmac-sha256":
235
+ return self.signing_key
236
+ return None
237
+
238
+
239
+ @lru_cache
240
+ def get_runtime_config() -> NodeRuntimeConfig:
241
+ environment = os.getenv("L9_ENVIRONMENT", "local").strip().lower() or "local"
242
+ dev_mode = _env_bool("L9_DEV_MODE", False)
243
+ return NodeRuntimeConfig(
244
+ environment=environment,
245
+ node_name=os.getenv("L9_NODE_NAME", "unknown-node"),
246
+ service_name=os.getenv("L9_SERVICE_NAME", os.getenv("L9_NODE_NAME", "unknown-node")),
247
+ service_version=os.getenv("L9_SERVICE_VERSION", "1.0.0"),
248
+ dev_mode=dev_mode,
249
+ require_signature=_env_bool("L9_REQUIRE_SIGNATURE", False),
250
+ expose_internal_errors=_env_bool("L9_EXPOSE_INTERNAL_ERRORS", False),
251
+ return_transport_errors=_env_bool("L9_RETURN_TRANSPORT_ERRORS", True),
252
+ signing_algorithm=os.getenv("L9_SIGNING_ALGORITHM", "hmac-sha256"),
253
+ signing_key=os.getenv("L9_SIGNING_KEY") or os.getenv("L9_SIGNING_SECRET"),
254
+ signing_private_key=os.getenv("L9_SIGNING_PRIVATE_KEY"),
255
+ signing_key_id=os.getenv("L9_SIGNING_KEY_ID"),
256
+ verifying_keys=_env_json_map("L9_VERIFYING_KEYS_JSON"),
257
+ allowed_actions=_env_tuple("L9_ALLOWED_ACTIONS"),
258
+ allowed_packet_types=_env_tuple("L9_ALLOWED_PACKET_TYPES")
259
+ or ("request", "command", "delegation", "replay_request"),
260
+ require_idempotency_for_actions=_env_tuple("L9_REQUIRE_IDEMPOTENCY_FOR_ACTIONS"),
261
+ allowed_clock_skew_seconds=int(os.getenv("L9_ALLOWED_CLOCK_SKEW_SECONDS", "30")),
262
+ max_packet_bytes=int(os.getenv("L9_MAX_PACKET_BYTES", "262144")),
263
+ max_hop_depth=int(os.getenv("L9_MAX_HOP_DEPTH", "64")),
264
+ max_delegation_depth=int(os.getenv("L9_MAX_DELEGATION_DEPTH", "8")),
265
+ max_attachments=int(os.getenv("L9_MAX_ATTACHMENTS", "32")),
266
+ max_attachment_size_bytes=int(os.getenv("L9_MAX_ATTACHMENT_SIZE_BYTES", "10485760")),
267
+ attachment_allowed_schemes=_env_tuple("L9_ATTACHMENT_ALLOWED_SCHEMES"),
268
+ allow_private_attachment_hosts=_env_bool("L9_ALLOW_PRIVATE_ATTACHMENT_HOSTS", False),
269
+ replay_enabled=_env_bool("L9_REPLAY_ENABLED", True),
270
+ verify_hop_signatures=_env_bool("L9_VERIFY_HOP_SIGNATURES", False),
271
+ gate_node_name=os.getenv("L9_GATE_NODE_NAME", "gate"),
272
+ enforce_gate_only_ingress=_env_bool("L9_ENFORCE_GATE_ONLY_INGRESS", True),
273
+ enable_relay_route=_env_bool("L9_ENABLE_RELAY_ROUTE", True),
274
+ require_gate_mediation_provenance=_env_bool("L9_REQUIRE_GATE_MEDIATION_PROVENANCE", True),
275
+ execute_require_signature=(
276
+ _env_bool("L9_EXECUTE_REQUIRE_SIGNATURE", False)
277
+ if os.getenv("L9_EXECUTE_REQUIRE_SIGNATURE") is not None
278
+ else None
279
+ ),
280
+ execute_verify_hop_signatures=(
281
+ _env_bool("L9_EXECUTE_VERIFY_HOP_SIGNATURES", False)
282
+ if os.getenv("L9_EXECUTE_VERIFY_HOP_SIGNATURES") is not None
283
+ else None
284
+ ),
285
+ execute_allowed_actions=_env_tuple("L9_EXECUTE_ALLOWED_ACTIONS"),
286
+ execute_allowed_packet_types=_env_tuple("L9_EXECUTE_ALLOWED_PACKET_TYPES"),
287
+ relay_require_signature=(
288
+ _env_bool("L9_RELAY_REQUIRE_SIGNATURE", False)
289
+ if os.getenv("L9_RELAY_REQUIRE_SIGNATURE") is not None
290
+ else None
291
+ ),
292
+ relay_verify_hop_signatures=(
293
+ _env_bool("L9_RELAY_VERIFY_HOP_SIGNATURES", False)
294
+ if os.getenv("L9_RELAY_VERIFY_HOP_SIGNATURES") is not None
295
+ else None
296
+ ),
297
+ relay_allowed_actions=_env_tuple("L9_RELAY_ALLOWED_ACTIONS"),
298
+ relay_allowed_packet_types=(
299
+ _env_tuple("L9_RELAY_ALLOWED_PACKET_TYPES")
300
+ or ("request", "command", "delegation", "replay_request")
301
+ ),
302
+ gate_url=os.getenv("GATE_URL"),
303
+ # HOST env var overrides the safe 127.0.0.1 default.
304
+ # In containers set HOST=0.0.0.0. On bare metal leave unset.
305
+ host=os.getenv("HOST", "127.0.0.1"),
306
+ port=int(os.getenv("PORT", "8000")),
307
+ )
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import NoReturn
5
+
6
+ from fastapi import HTTPException
7
+
8
+ from constellation_node_sdk.transport.errors import (
9
+ PacketSizeError,
10
+ SchemaVersionError,
11
+ TenantMutationError,
12
+ TransportAuthenticationError,
13
+ TransportAuthorizationError,
14
+ TransportExpiredError,
15
+ TransportIntegrityError,
16
+ TransportNotYetValidError,
17
+ TransportValidationError,
18
+ )
19
+
20
+
21
+ class RuntimeErrorBase(Exception):
22
+ """Base exception for runtime-layer failures."""
23
+
24
+
25
+ class RuntimeConfigurationError(RuntimeErrorBase):
26
+ """Raised when runtime configuration is invalid or incomplete."""
27
+
28
+
29
+ class RuntimeValidationError(RuntimeErrorBase):
30
+ """Raised when an inbound request fails runtime validation."""
31
+
32
+
33
+ class RuntimeExecutionError(RuntimeErrorBase):
34
+ """Raised when handler execution fails within the runtime shell."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class RuntimeErrorDetail:
39
+ code: str
40
+ message: str
41
+ status_code: int
42
+
43
+
44
+ def classify_exception(exc: Exception) -> RuntimeErrorDetail:
45
+ """
46
+ Map runtime and transport exceptions to safe HTTP-facing error details.
47
+ """
48
+ if isinstance(
49
+ exc,
50
+ (
51
+ ValueError,
52
+ TransportValidationError,
53
+ TransportIntegrityError,
54
+ PacketSizeError,
55
+ SchemaVersionError,
56
+ TenantMutationError,
57
+ ),
58
+ ):
59
+ return RuntimeErrorDetail(
60
+ code="invalid_request",
61
+ message=str(exc),
62
+ status_code=400,
63
+ )
64
+
65
+ if isinstance(exc, (TransportAuthenticationError,)):
66
+ return RuntimeErrorDetail(
67
+ code="authentication_failed",
68
+ message="transport authentication failed",
69
+ status_code=401,
70
+ )
71
+
72
+ if isinstance(exc, (TransportAuthorizationError,)):
73
+ return RuntimeErrorDetail(
74
+ code="authorization_failed",
75
+ message="transport authorization failed",
76
+ status_code=403,
77
+ )
78
+
79
+ if isinstance(exc, (TransportExpiredError, TransportNotYetValidError)):
80
+ return RuntimeErrorDetail(
81
+ code="temporal_validity_failed",
82
+ message=str(exc),
83
+ status_code=409,
84
+ )
85
+
86
+ if isinstance(exc, TimeoutError):
87
+ return RuntimeErrorDetail(
88
+ code="execution_timeout",
89
+ message="handler execution timed out",
90
+ status_code=504,
91
+ )
92
+
93
+ return RuntimeErrorDetail(
94
+ code="internal_error",
95
+ message="internal server error",
96
+ status_code=500,
97
+ )
98
+
99
+
100
+ def raise_http_exception(exc: Exception) -> NoReturn:
101
+ """
102
+ Raise a FastAPI HTTPException from a classified runtime exception.
103
+ Always raises — annotated NoReturn so mypy understands no return sentinel needed.
104
+ """
105
+ detail = classify_exception(exc)
106
+ raise HTTPException(
107
+ status_code=detail.status_code,
108
+ detail={
109
+ "code": detail.code,
110
+ "message": detail.message,
111
+ },
112
+ ) from exc