aevum-server 0.2.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.
- aevum_server-0.2.0/.gitignore +31 -0
- aevum_server-0.2.0/PKG-INFO +35 -0
- aevum_server-0.2.0/README.md +11 -0
- aevum_server-0.2.0/pyproject.toml +59 -0
- aevum_server-0.2.0/src/aevum/server/__init__.py +7 -0
- aevum_server-0.2.0/src/aevum/server/__main__.py +33 -0
- aevum_server-0.2.0/src/aevum/server/app.py +165 -0
- aevum_server-0.2.0/src/aevum/server/core/__init__.py +1 -0
- aevum_server-0.2.0/src/aevum/server/core/config.py +29 -0
- aevum_server-0.2.0/src/aevum/server/core/deps.py +86 -0
- aevum_server-0.2.0/src/aevum/server/core/security.py +47 -0
- aevum_server-0.2.0/src/aevum/server/middleware.py +57 -0
- aevum_server-0.2.0/src/aevum/server/py.typed +0 -0
- aevum_server-0.2.0/src/aevum/server/routes/__init__.py +1 -0
- aevum_server-0.2.0/src/aevum/server/routes/admin.py +108 -0
- aevum_server-0.2.0/src/aevum/server/routes/data.py +80 -0
- aevum_server-0.2.0/src/aevum/server/routes/health.py +21 -0
- aevum_server-0.2.0/src/aevum/server/routes/replay.py +34 -0
- aevum_server-0.2.0/src/aevum/server/routes/review.py +69 -0
- aevum_server-0.2.0/src/aevum/server/schemas/__init__.py +1 -0
- aevum_server-0.2.0/src/aevum/server/schemas/requests.py +37 -0
- aevum_server-0.2.0/src/aevum/server/schemas/responses.py +29 -0
- aevum_server-0.2.0/tests/conftest.py +68 -0
- aevum_server-0.2.0/tests/test_auth.py +40 -0
- aevum_server-0.2.0/tests/test_data_routes.py +159 -0
- aevum_server-0.2.0/tests/test_health.py +37 -0
- aevum_server-0.2.0/tests/test_middleware.py +37 -0
- aevum_server-0.2.0/tests/test_oidc_auth.py +160 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.venv/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# Build
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
|
|
13
|
+
# Tools
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.hypothesis/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
|
24
|
+
|
|
25
|
+
# OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
Thumbs.db
|
|
28
|
+
|
|
29
|
+
# Verify scripts (run locally, never commit)
|
|
30
|
+
verify_phase*.py
|
|
31
|
+
scripts/verify_phase*.py
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aevum-server
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Aevum HTTP API server — wraps the five functions for any consumer.
|
|
5
|
+
Project-URL: Homepage, https://aevum.build
|
|
6
|
+
Project-URL: Repository, https://github.com/aevum-labs/aevum
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: aevum-core
|
|
17
|
+
Requires-Dist: fastapi<0.136,>=0.115
|
|
18
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.50b0
|
|
19
|
+
Requires-Dist: opentelemetry-sdk>=1.25
|
|
20
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
21
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# aevum-server
|
|
26
|
+
|
|
27
|
+
FastAPI HTTP server wrapping the five governed Aevum kernel functions (`ingest`, `query`, `review`, `commit`, `replay`).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install aevum-server
|
|
31
|
+
aevum server start --graph memory
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
See the [main repository README](https://github.com/aevum-labs/aevum) for configuration and deployment options.
|
|
35
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# aevum-server
|
|
2
|
+
|
|
3
|
+
FastAPI HTTP server wrapping the five governed Aevum kernel functions (`ingest`, `query`, `review`, `commit`, `replay`).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install aevum-server
|
|
7
|
+
aevum server start --graph memory
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
See the [main repository README](https://github.com/aevum-labs/aevum) for configuration and deployment options.
|
|
11
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aevum-server"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Aevum HTTP API server — wraps the five functions for any consumer."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"aevum-core",
|
|
19
|
+
"fastapi>=0.115,<0.136",
|
|
20
|
+
"uvicorn[standard]>=0.30",
|
|
21
|
+
"pydantic-settings>=2.0",
|
|
22
|
+
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
|
23
|
+
"opentelemetry-sdk>=1.25",
|
|
24
|
+
"python-dotenv>=1.2.2", # CVE-2026-28684 fixed in 1.2.2
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://aevum.build"
|
|
29
|
+
Repository = "https://github.com/aevum-labs/aevum"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/aevum"]
|
|
37
|
+
|
|
38
|
+
[tool.uv.sources]
|
|
39
|
+
aevum-core = { workspace = true }
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
asyncio_mode = "auto"
|
|
44
|
+
addopts = "--tb=short"
|
|
45
|
+
pythonpath = ["src", "tests"]
|
|
46
|
+
|
|
47
|
+
[tool.mypy]
|
|
48
|
+
strict = true
|
|
49
|
+
python_version = "3.11"
|
|
50
|
+
mypy_path = "src"
|
|
51
|
+
explicit_package_bases = true
|
|
52
|
+
ignore_missing_imports = true
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 130
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "UP", "B", "SIM", "I", "ANN"]
|
|
59
|
+
ignore = ["ANN401"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
python -m aevum.server — development convenience entrypoint.
|
|
3
|
+
|
|
4
|
+
Starts aevum-server with an in-memory Engine and default settings.
|
|
5
|
+
NOT for production. Production operators use:
|
|
6
|
+
uvicorn aevum.server.app:create_app --factory
|
|
7
|
+
gunicorn aevum.server.app:create_app -k uvicorn.workers.UvicornWorker
|
|
8
|
+
|
|
9
|
+
Configuration via environment variables (see aevum/server/core/config.py).
|
|
10
|
+
For full CLI control use the aevum-cli package: `aevum server start`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import uvicorn
|
|
14
|
+
from aevum.core.engine import Engine
|
|
15
|
+
|
|
16
|
+
from aevum.server.app import create_app
|
|
17
|
+
from aevum.server.core.config import Settings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
settings = Settings()
|
|
22
|
+
engine = Engine(opa_url=settings.opa_url or None)
|
|
23
|
+
app = create_app(engine, settings=settings)
|
|
24
|
+
uvicorn.run(
|
|
25
|
+
app,
|
|
26
|
+
host=settings.host,
|
|
27
|
+
port=settings.port,
|
|
28
|
+
log_level="info",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
create_app — FastAPI application factory.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from aevum.core import Engine
|
|
6
|
+
from aevum.server.app import create_app
|
|
7
|
+
|
|
8
|
+
engine = Engine()
|
|
9
|
+
app = create_app(engine)
|
|
10
|
+
|
|
11
|
+
# Production:
|
|
12
|
+
# uvicorn aevum.server.app:create_app --factory
|
|
13
|
+
# gunicorn aevum.server.app:create_app -k uvicorn.workers.UvicornWorker
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from aevum.core.engine import Engine
|
|
22
|
+
from aevum.core.exceptions import (
|
|
23
|
+
AevumError,
|
|
24
|
+
BarrierViolationError,
|
|
25
|
+
ConsentRequiredError,
|
|
26
|
+
ProvenanceRequiredError,
|
|
27
|
+
ReplayNotFoundError,
|
|
28
|
+
ReviewAlreadyResolvedError,
|
|
29
|
+
ReviewNotFoundError,
|
|
30
|
+
)
|
|
31
|
+
from fastapi import FastAPI, Request
|
|
32
|
+
from fastapi.responses import JSONResponse
|
|
33
|
+
|
|
34
|
+
from aevum.server.core.config import Settings
|
|
35
|
+
from aevum.server.middleware import AevumMiddleware
|
|
36
|
+
from aevum.server.routes import admin, data, health, replay, review
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# RFC 9457 problem type → HTTP status code mapping
|
|
41
|
+
_PROBLEM_STATUS: dict[type[AevumError], int] = {
|
|
42
|
+
ConsentRequiredError: 403,
|
|
43
|
+
ProvenanceRequiredError: 400,
|
|
44
|
+
ReplayNotFoundError: 404,
|
|
45
|
+
ReviewNotFoundError: 404,
|
|
46
|
+
ReviewAlreadyResolvedError: 409,
|
|
47
|
+
BarrierViolationError: 403,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_PROBLEM_TYPES: dict[type[AevumError], str] = {
|
|
51
|
+
ConsentRequiredError: "consent-required",
|
|
52
|
+
ProvenanceRequiredError: "validation-error",
|
|
53
|
+
ReplayNotFoundError: "replay-not-found",
|
|
54
|
+
ReviewNotFoundError: "review-not-found",
|
|
55
|
+
ReviewAlreadyResolvedError: "review-already-resolved",
|
|
56
|
+
BarrierViolationError: "barrier-triggered",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _problem_response(
|
|
61
|
+
exc: AevumError,
|
|
62
|
+
status: int,
|
|
63
|
+
problem_type: str,
|
|
64
|
+
request_id: str | None = None,
|
|
65
|
+
) -> JSONResponse:
|
|
66
|
+
"""Return RFC 9457 application/problem+json response."""
|
|
67
|
+
body: dict[str, Any] = {
|
|
68
|
+
"type": f"https://aevum.build/problems/{problem_type}",
|
|
69
|
+
"title": exc.__class__.__name__,
|
|
70
|
+
"status": status,
|
|
71
|
+
"detail": str(exc),
|
|
72
|
+
}
|
|
73
|
+
if request_id:
|
|
74
|
+
body["request_id"] = request_id
|
|
75
|
+
return JSONResponse(
|
|
76
|
+
content=body,
|
|
77
|
+
status_code=status,
|
|
78
|
+
media_type="application/problem+json",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_app(
|
|
83
|
+
engine: Engine | None = None,
|
|
84
|
+
settings: Settings | None = None,
|
|
85
|
+
) -> FastAPI:
|
|
86
|
+
"""
|
|
87
|
+
Create the Aevum HTTP API FastAPI application.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
engine: Aevum kernel instance. Creates Engine() with defaults if None.
|
|
91
|
+
settings: Server settings. Reads from environment if None.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Configured FastAPI application.
|
|
95
|
+
"""
|
|
96
|
+
if engine is None:
|
|
97
|
+
engine = Engine()
|
|
98
|
+
if settings is None:
|
|
99
|
+
settings = Settings()
|
|
100
|
+
|
|
101
|
+
app = FastAPI(
|
|
102
|
+
title="Aevum HTTP API",
|
|
103
|
+
version="0.2.0",
|
|
104
|
+
description="Replay-first, policy-governed context kernel — HTTP surface.",
|
|
105
|
+
docs_url="/v1/docs",
|
|
106
|
+
openapi_url="/v1/openapi.json",
|
|
107
|
+
redoc_url=None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Store engine and settings on app state for dependency injection
|
|
111
|
+
app.state.engine = engine
|
|
112
|
+
app.state.settings = settings
|
|
113
|
+
|
|
114
|
+
# Middleware (order matters: outermost runs first on request, last on response)
|
|
115
|
+
app.add_middleware(
|
|
116
|
+
AevumMiddleware,
|
|
117
|
+
rate_limit_per_minute=settings.rate_limit_per_minute,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# OTel instrumentation (sanitize X-Aevum-Key from spans)
|
|
121
|
+
if settings.otel_enabled:
|
|
122
|
+
try:
|
|
123
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
124
|
+
FastAPIInstrumentor.instrument_app(
|
|
125
|
+
app,
|
|
126
|
+
# Sanitize auth header — must NOT appear in trace exports
|
|
127
|
+
excluded_urls="",
|
|
128
|
+
http_capture_headers_server_request=["x-request-id"],
|
|
129
|
+
http_capture_headers_server_response=["x-request-id", "x-response-time-ms"],
|
|
130
|
+
)
|
|
131
|
+
logger.info("OTel FastAPI instrumentation enabled")
|
|
132
|
+
except ImportError:
|
|
133
|
+
logger.warning("opentelemetry-instrumentation-fastapi not available")
|
|
134
|
+
|
|
135
|
+
# Global exception handler — RFC 9457 format
|
|
136
|
+
@app.exception_handler(AevumError)
|
|
137
|
+
async def aevum_error_handler(request: Request, exc: AevumError) -> JSONResponse:
|
|
138
|
+
request_id = request.headers.get("x-request-id")
|
|
139
|
+
status = _PROBLEM_STATUS.get(type(exc), 500)
|
|
140
|
+
problem_type = _PROBLEM_TYPES.get(type(exc), "internal-error")
|
|
141
|
+
return _problem_response(exc, status, problem_type, request_id)
|
|
142
|
+
|
|
143
|
+
@app.exception_handler(Exception)
|
|
144
|
+
async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
145
|
+
# Never expose internal details to callers
|
|
146
|
+
logger.exception("Unhandled exception in request %s %s", request.method, request.url.path)
|
|
147
|
+
request_id = request.headers.get("x-request-id")
|
|
148
|
+
body: dict[str, Any] = {
|
|
149
|
+
"type": "https://aevum.build/problems/internal-error",
|
|
150
|
+
"title": "Internal Server Error",
|
|
151
|
+
"status": 500,
|
|
152
|
+
"detail": "An unexpected error occurred.",
|
|
153
|
+
}
|
|
154
|
+
if request_id:
|
|
155
|
+
body["request_id"] = request_id
|
|
156
|
+
return JSONResponse(content=body, status_code=500, media_type="application/problem+json")
|
|
157
|
+
|
|
158
|
+
# Routes
|
|
159
|
+
app.include_router(health.router, prefix="/v1")
|
|
160
|
+
app.include_router(data.router, prefix="/v1")
|
|
161
|
+
app.include_router(replay.router, prefix="/v1")
|
|
162
|
+
app.include_router(review.router, prefix="/v1")
|
|
163
|
+
app.include_router(admin.router, prefix="/_aevum/v1")
|
|
164
|
+
|
|
165
|
+
return app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""aevum.server.core — config, security, shared dependencies."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server configuration via environment variables.
|
|
3
|
+
All settings have safe defaults for development.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Settings(BaseSettings):
|
|
12
|
+
model_config = SettingsConfigDict(env_prefix="AEVUM_", case_sensitive=False)
|
|
13
|
+
|
|
14
|
+
# Network
|
|
15
|
+
host: str = "0.0.0.0"
|
|
16
|
+
port: int = 8000
|
|
17
|
+
|
|
18
|
+
# Auth
|
|
19
|
+
api_key: str = "dev-insecure-key-change-in-production"
|
|
20
|
+
|
|
21
|
+
# Policy (optional — permissive stub if not set)
|
|
22
|
+
opa_url: str = ""
|
|
23
|
+
|
|
24
|
+
# Rate limiting (headers always present; enforcement is operator responsibility)
|
|
25
|
+
rate_limit_per_minute: int = 1000
|
|
26
|
+
|
|
27
|
+
# OTel
|
|
28
|
+
otel_enabled: bool = False
|
|
29
|
+
otel_service_name: str = "aevum-server"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI shared dependencies.
|
|
3
|
+
Injected via Depends() into route handlers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
from aevum.core.engine import Engine
|
|
11
|
+
from fastapi import Header, HTTPException, Request, status
|
|
12
|
+
|
|
13
|
+
from aevum.server.core.config import Settings
|
|
14
|
+
from aevum.server.core.security import require_api_key
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_engine(request: Request) -> Engine:
|
|
18
|
+
"""Extract Engine from app state (set in create_app)."""
|
|
19
|
+
return request.app.state.engine # type: ignore[no-any-return]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_settings(request: Request) -> Settings:
|
|
23
|
+
"""Extract Settings from app state."""
|
|
24
|
+
return request.app.state.settings # type: ignore[no-any-return]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_actor(
|
|
28
|
+
request: Request,
|
|
29
|
+
x_aevum_key: Annotated[str | None, Header()] = None,
|
|
30
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Validate auth and return actor identity.
|
|
34
|
+
|
|
35
|
+
Precedence:
|
|
36
|
+
1. Authorization: Bearer <token> → resolved via installed OIDC complication
|
|
37
|
+
(fail-closed: rejects Bearer if no OIDC complication is active)
|
|
38
|
+
2. X-Aevum-Key header → API key validation
|
|
39
|
+
|
|
40
|
+
The actor string returned is used throughout the kernel as the principal
|
|
41
|
+
identity for consent and audit purposes.
|
|
42
|
+
"""
|
|
43
|
+
settings: Settings = request.app.state.settings
|
|
44
|
+
engine: Engine = request.app.state.engine
|
|
45
|
+
|
|
46
|
+
if authorization and authorization.lower().startswith("bearer "):
|
|
47
|
+
token = authorization[7:].strip()
|
|
48
|
+
oidc_comp = engine.get_active_complication_by_capability("oidc-validation")
|
|
49
|
+
if oidc_comp is None:
|
|
50
|
+
raise HTTPException(
|
|
51
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
52
|
+
detail={
|
|
53
|
+
"type": "https://aevum.build/problems/authentication-required",
|
|
54
|
+
"title": "Authentication Required",
|
|
55
|
+
"status": 401,
|
|
56
|
+
"detail": "Bearer token presented but no OIDC complication is active.",
|
|
57
|
+
},
|
|
58
|
+
headers={"Content-Type": "application/problem+json"},
|
|
59
|
+
)
|
|
60
|
+
result: dict[str, Any] = await oidc_comp.run(
|
|
61
|
+
{"metadata": {"bearer_token": token}}, {}
|
|
62
|
+
)
|
|
63
|
+
if not result.get("oidc_validated"):
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
66
|
+
detail={
|
|
67
|
+
"type": "https://aevum.build/problems/authentication-required",
|
|
68
|
+
"title": "Authentication Required",
|
|
69
|
+
"status": 401,
|
|
70
|
+
"detail": result.get("reason", "Bearer token validation failed."),
|
|
71
|
+
},
|
|
72
|
+
headers={"Content-Type": "application/problem+json"},
|
|
73
|
+
)
|
|
74
|
+
actor: str = result["resolved_actor"]
|
|
75
|
+
return actor
|
|
76
|
+
|
|
77
|
+
return require_api_key(x_aevum_key, settings.api_key)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_correlation_id(
|
|
81
|
+
request: Request,
|
|
82
|
+
x_request_id: Annotated[str | None, Header()] = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Return client-provided or server-generated correlation ID."""
|
|
85
|
+
import uuid
|
|
86
|
+
return x_request_id or str(uuid.uuid4())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication — X-Aevum-Key header validation.
|
|
3
|
+
|
|
4
|
+
API key authentication only. For OIDC bearer token support install
|
|
5
|
+
the aevum-oidc complication and add it to the Engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException, status
|
|
11
|
+
from fastapi.security import APIKeyHeader
|
|
12
|
+
|
|
13
|
+
_API_KEY_HEADER = APIKeyHeader(name="X-Aevum-Key", auto_error=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def require_api_key(
|
|
17
|
+
api_key_value: str | None,
|
|
18
|
+
configured_key: str,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Validate the X-Aevum-Key header.
|
|
22
|
+
Returns the key value (used as actor identity) if valid.
|
|
23
|
+
Raises 401 if absent or invalid.
|
|
24
|
+
"""
|
|
25
|
+
if not api_key_value:
|
|
26
|
+
raise HTTPException(
|
|
27
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
28
|
+
detail={
|
|
29
|
+
"type": "https://aevum.build/problems/authentication-required",
|
|
30
|
+
"title": "Authentication Required",
|
|
31
|
+
"status": 401,
|
|
32
|
+
"detail": "X-Aevum-Key header is required.",
|
|
33
|
+
},
|
|
34
|
+
headers={"Content-Type": "application/problem+json"},
|
|
35
|
+
)
|
|
36
|
+
if api_key_value != configured_key:
|
|
37
|
+
raise HTTPException(
|
|
38
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
39
|
+
detail={
|
|
40
|
+
"type": "https://aevum.build/problems/authentication-required",
|
|
41
|
+
"title": "Authentication Required",
|
|
42
|
+
"status": 401,
|
|
43
|
+
"detail": "Invalid API key.",
|
|
44
|
+
},
|
|
45
|
+
headers={"Content-Type": "application/problem+json"},
|
|
46
|
+
)
|
|
47
|
+
return api_key_value
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Middleware — correlation IDs, security headers, rate limit headers.
|
|
3
|
+
Applied to every response via app.middleware("http").
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
|
|
12
|
+
from fastapi import Request, Response
|
|
13
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
14
|
+
from starlette.types import ASGIApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AevumMiddleware(BaseHTTPMiddleware):
|
|
18
|
+
"""
|
|
19
|
+
Single middleware handling:
|
|
20
|
+
1. Correlation ID propagation (X-Request-ID)
|
|
21
|
+
2. Security headers on every response
|
|
22
|
+
3. Rate limit info headers (headers always present; enforcement is operator concern)
|
|
23
|
+
4. Request timing
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, app: ASGIApp, rate_limit_per_minute: int = 1000) -> None:
|
|
27
|
+
super().__init__(app)
|
|
28
|
+
self._rate_limit = rate_limit_per_minute
|
|
29
|
+
|
|
30
|
+
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
|
31
|
+
start = time.perf_counter()
|
|
32
|
+
|
|
33
|
+
# Correlation ID
|
|
34
|
+
correlation_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
|
35
|
+
|
|
36
|
+
response: Response = await call_next(request)
|
|
37
|
+
|
|
38
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
39
|
+
|
|
40
|
+
# Correlation ID echo
|
|
41
|
+
response.headers["X-Request-ID"] = correlation_id
|
|
42
|
+
|
|
43
|
+
# Security headers (spec Section 10.8)
|
|
44
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
45
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
46
|
+
response.headers["Strict-Transport-Security"] = "max-age=31536000"
|
|
47
|
+
response.headers["Content-Security-Policy"] = "default-src 'none'"
|
|
48
|
+
|
|
49
|
+
# Rate limit info headers (spec Section 10.7)
|
|
50
|
+
response.headers["X-RateLimit-Limit"] = str(self._rate_limit)
|
|
51
|
+
response.headers["X-RateLimit-Remaining"] = str(self._rate_limit)
|
|
52
|
+
response.headers["X-RateLimit-Reset"] = "60"
|
|
53
|
+
|
|
54
|
+
# Timing
|
|
55
|
+
response.headers["X-Response-Time-Ms"] = str(duration_ms)
|
|
56
|
+
|
|
57
|
+
return response
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""aevum.server.routes — All route handlers."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
/_aevum/v1/* — admin API for complication lifecycle and server diagnostics.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
from aevum.core.engine import Engine
|
|
10
|
+
from aevum.core.exceptions import ComplicationError
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from aevum.server.core.deps import get_actor, get_engine
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApproveRequest(BaseModel):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SuspendRequest(BaseModel):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/complications")
|
|
28
|
+
async def list_complications(
|
|
29
|
+
actor: Annotated[str, Depends(get_actor)],
|
|
30
|
+
engine: Annotated[Engine, Depends(get_engine)],
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""List all complications with their current lifecycle state."""
|
|
33
|
+
return {"complications": engine.list_complications()}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/complications/{complication_id}/approve")
|
|
37
|
+
async def approve_complication(
|
|
38
|
+
complication_id: str,
|
|
39
|
+
body: ApproveRequest,
|
|
40
|
+
actor: Annotated[str, Depends(get_actor)],
|
|
41
|
+
engine: Annotated[Engine, Depends(get_engine)],
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Approve a PENDING complication → ACTIVE."""
|
|
44
|
+
try:
|
|
45
|
+
engine.approve_complication(complication_id)
|
|
46
|
+
return {"approved": True, "name": complication_id}
|
|
47
|
+
except ComplicationError as e:
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
50
|
+
detail={"type": "https://aevum.build/problems/complication-error",
|
|
51
|
+
"title": "Complication Error", "status": 409, "detail": str(e)},
|
|
52
|
+
) from e
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.post("/complications/{complication_id}/suspend")
|
|
56
|
+
async def suspend_complication(
|
|
57
|
+
complication_id: str,
|
|
58
|
+
body: SuspendRequest,
|
|
59
|
+
actor: Annotated[str, Depends(get_actor)],
|
|
60
|
+
engine: Annotated[Engine, Depends(get_engine)],
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Suspend an ACTIVE complication."""
|
|
63
|
+
try:
|
|
64
|
+
engine.suspend_complication(complication_id)
|
|
65
|
+
return {"suspended": True, "name": complication_id}
|
|
66
|
+
except ComplicationError as e:
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
69
|
+
detail={"type": "https://aevum.build/problems/complication-error",
|
|
70
|
+
"title": "Complication Error", "status": 409, "detail": str(e)},
|
|
71
|
+
) from e
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/complications/{complication_id}/health")
|
|
75
|
+
async def complication_health(
|
|
76
|
+
complication_id: str,
|
|
77
|
+
actor: Annotated[str, Depends(get_actor)],
|
|
78
|
+
engine: Annotated[Engine, Depends(get_engine)],
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Check health of a specific complication."""
|
|
81
|
+
complications = engine.list_complications()
|
|
82
|
+
if complication_id not in complications:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
85
|
+
detail={"type": "https://aevum.build/problems/complication-not-found",
|
|
86
|
+
"title": "Not Found", "status": 404,
|
|
87
|
+
"detail": f"Complication '{complication_id}' not found"},
|
|
88
|
+
)
|
|
89
|
+
entry = complications[complication_id]
|
|
90
|
+
return {
|
|
91
|
+
"name": complication_id,
|
|
92
|
+
"state": entry["state"],
|
|
93
|
+
"healthy": entry["state"] == "ACTIVE",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/usage")
|
|
98
|
+
async def get_usage(
|
|
99
|
+
actor: Annotated[str, Depends(get_actor)],
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
return {"usage": {}, "note": "Install a complication to see usage metrics here."}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.get("/federation/peers")
|
|
105
|
+
async def list_federation_peers(
|
|
106
|
+
actor: Annotated[str, Depends(get_actor)],
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
return {"peers": [], "note": "Install a complication to see it listed here."}
|