Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ loadtest: ## Run locust against a server already on $(LOCUST_H
loadtest-memray: ## Start uvicorn under memray, load-test, emit flamegraph
scripts/loadtest_memray.sh $(LOCUST_ARGS)

lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings

# Kept granular so pr.yml can run them in parallel.
ci-python-lint:
Expand All @@ -103,6 +103,11 @@ ci-js-typecheck:
ci-check-file-size:
uv run python scripts/check_file_size.py

# Enforce that permissions, role names, Inertia page ids, and module dependency
# names are declared as named constants rather than hardcoded string literals.
ci-check-hardcoded-strings:
uv run python scripts/check_hardcoded_strings.py

# Diagnostics
doctor:
uv run python -m simple_module_core
Expand Down
28 changes: 20 additions & 8 deletions framework/db/simple_module_db/listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ class TenantIsolationError(Exception):
# flush has cleared ``session.new/.dirty/.deleted``.
SESSION_HAS_WRITES_KEY = "has_writes"

# DB audit event names
_EVENT_ENTITY_CREATED = "db.entity.created"
_EVENT_ENTITY_UPDATED = "db.entity.updated"
_EVENT_ENTITY_SOFT_DELETED = "db.entity.soft_deleted"
_EVENT_ENTITY_DELETED = "db.entity.deleted"

# DB operation strings used in log extra dicts
_OP_CREATE = "create"
_OP_UPDATE = "update"
_OP_SOFT_DELETE = "soft_delete"
_OP_DELETE = "delete"


def _entity_label(obj: object) -> str:
"""Return 'ClassName' for a mapped entity instance."""
Expand Down Expand Up @@ -107,9 +119,9 @@ def _before_flush_listener(
)

_db_logger.info(
"db.entity.created",
_EVENT_ENTITY_CREATED,
extra={
"operation": "create",
"operation": _OP_CREATE,
"entity": _entity_label(obj),
"user_id": user_id,
},
Expand All @@ -133,9 +145,9 @@ def _before_flush_listener(
raise TenantIsolationError("Cannot change tenant_id of an existing object")

_db_logger.info(
"db.entity.updated",
_EVENT_ENTITY_UPDATED,
extra={
"operation": "update",
"operation": _OP_UPDATE,
"entity": _entity_label(obj),
"entity_id": _entity_pk(obj),
"user_id": user_id,
Expand All @@ -154,19 +166,19 @@ def _before_flush_listener(
session.add(obj)

_db_logger.info(
"db.entity.soft_deleted",
_EVENT_ENTITY_SOFT_DELETED,
extra={
"operation": "soft_delete",
"operation": _OP_SOFT_DELETE,
"entity": _entity_label(obj),
"entity_id": _entity_pk(obj),
"user_id": user_id,
},
)
else:
_db_logger.info(
"db.entity.deleted",
_EVENT_ENTITY_DELETED,
extra={
"operation": "delete",
"operation": _OP_DELETE,
"entity": _entity_label(obj),
"entity_id": _entity_pk(obj),
"user_id": user_id,
Expand Down
13 changes: 9 additions & 4 deletions framework/hosting/simple_module_hosting/_inertia_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@

logger = logging.getLogger(__name__)

_INERTIA_VERSION = "1.0"
_ROOT_TEMPLATE_FILENAME = "index.html"
_ENTRYPOINT_FILENAME = "main.tsx"
_ROOT_DIRECTORY = "."


def setup_inertia(
app: FastAPI,
Expand Down Expand Up @@ -54,12 +59,12 @@ def setup_inertia(

inertia_config = InertiaConfig(
environment=settings.environment,
version="1.0",
version=_INERTIA_VERSION,
dev_url=settings.vite_dev_url if settings.is_development else "",
templates=templates,
root_template_filename="index.html",
entrypoint_filename="main.tsx",
root_directory=".",
root_template_filename=_ROOT_TEMPLATE_FILENAME,
entrypoint_filename=_ENTRYPOINT_FILENAME,
root_directory=_ROOT_DIRECTORY,
use_flash_errors=True,
)

Expand Down
4 changes: 3 additions & 1 deletion framework/hosting/simple_module_hosting/_inertia_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
logger = logging.getLogger(__name__)

_I18N_SESSION_LOCALE_KEY = "__i18n_locale"
_INERTIA_HEADER = "x-inertia"
_INERTIA_HEADER_TRUE = "true"


def build_i18n_block(scope: Scope, request: Request) -> dict:
Expand Down Expand Up @@ -38,7 +40,7 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
)
return {"locale": "en", "supportedLocales": ["en"], "messages": {}}

is_inertia = Headers(scope=scope).get("x-inertia") == "true"
is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE
session_dict = scope.get("session")
# When the session is absent (pre-session-middleware routes, WebSocket
# upgrades), treat locale as "unchanged" so Inertia XHR requests still
Expand Down
20 changes: 13 additions & 7 deletions framework/hosting/simple_module_hosting/_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@

from simple_module_hosting.logging import correlation_id

_request_logger = logging.getLogger("simple_module.request")
_LOGGER_NAME = "simple_module.request"
_request_logger = logging.getLogger(_LOGGER_NAME)

_SCOPE_HTTP = "http"
_MSG_RESPONSE_START = "http.response.start"
_EVENT_REQUEST_STARTED = "request.started"
_EVENT_REQUEST_COMPLETED = "request.completed"

# Paths that produce noisy, low-value log entries
_QUIET_PREFIXES = ("/health", "/static/")
Expand All @@ -32,14 +38,14 @@ def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
if scope["type"] != _SCOPE_HTTP:
await self.app(scope, receive, send)
return

cid = Headers(scope=scope).get(self.HEADER) or uuid.uuid4().hex

async def send_with_header(message: Message) -> None:
if message["type"] == "http.response.start":
if message["type"] == _MSG_RESPONSE_START:
headers = MutableHeaders(scope=message)
headers[self.HEADER] = cid
await send(message)
Expand All @@ -58,7 +64,7 @@ def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
if scope["type"] != _SCOPE_HTTP:
await self.app(scope, receive, send)
return

Expand All @@ -72,7 +78,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
client_ip = client[0] if client else "unknown"

_request_logger.debug(
"request.started",
_EVENT_REQUEST_STARTED,
extra={"method": method, "path": path, "client_ip": client_ip},
)

Expand All @@ -81,7 +87,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:

async def send_capture(message: Message) -> None:
nonlocal status_code
if message["type"] == "http.response.start":
if message["type"] == _MSG_RESPONSE_START:
status_code = message["status"]
await send(message)

Expand All @@ -91,7 +97,7 @@ async def send_capture(message: Message) -> None:
# Log completion even when the inner app raises, so 500s are observable.
duration_ms = round((time.perf_counter() - start) * 1000, 2)
_request_logger.info(
"request.completed",
_EVENT_REQUEST_COMPLETED,
extra={
"method": method,
"path": path,
Expand Down
22 changes: 15 additions & 7 deletions framework/hosting/simple_module_hosting/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@

logger = logging.getLogger(__name__)

_APP_TITLE = "SimpleModule"
_APP_VERSION = "0.1.0"
_DOCS_URL = "/api/docs"
_REDOC_URL = "/api/redoc"
_STATIC_MOUNT_PATH = "/static"
_STATIC_DIR_NAME = "static"
_ENV_PROJECT_ROOT = "SM_PROJECT_ROOT"


def _resolve_project_root() -> Path:
"""Return the project root directory.
Expand All @@ -48,7 +56,7 @@ def _resolve_project_root() -> Path:
Falls back to ``parents[3]`` for the workspace-install dev loop
(simple_module_hosting/ → hosting/ → framework/ → project root).
"""
override = os.environ.get("SM_PROJECT_ROOT")
override = os.environ.get(_ENV_PROJECT_ROOT)
if override:
return Path(override)
return Path(__file__).resolve().parents[3]
Expand Down Expand Up @@ -152,10 +160,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await app.state.sm.db.engine.dispose()

app = FastAPI(
title="SimpleModule",
version="0.1.0",
docs_url="/api/docs" if settings.is_development else None,
redoc_url="/api/redoc" if settings.is_development else None,
title=_APP_TITLE,
version=_APP_VERSION,
docs_url=_DOCS_URL if settings.is_development else None,
redoc_url=_REDOC_URL if settings.is_development else None,
lifespan=lifespan,
)

Expand Down Expand Up @@ -210,9 +218,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

app.include_router(health_router)

static_dir = _PROJECT_ROOT / "host" / "static"
static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
if static_dir.is_dir():
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.mount(_STATIC_MOUNT_PATH, StaticFiles(directory=static_dir), name=_STATIC_DIR_NAME)

mount_module_static_dirs(app, modules)

Expand Down
9 changes: 6 additions & 3 deletions framework/hosting/simple_module_hosting/csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@
"""Session-dict key under which :class:`InertiaLayoutDataMiddleware` mints
the per-session CSRF token that :class:`CSRFMiddleware` validates."""

_SCOPE_HTTP = "http"
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
_HEADER_NAMES = ("x-csrf-token", "x-xsrf-token")
_EVENT_CSRF_REJECTED = "csrf.rejected"
_CSRF_VALIDATION_FAILED_MSG = "CSRF validation failed"


class CSRFMiddleware:
Expand All @@ -45,7 +48,7 @@ def __init__(
self.exempt_path_prefixes = exempt_path_prefixes

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
if scope["type"] != _SCOPE_HTTP:
await self.app(scope, receive, send)
return

Expand All @@ -72,10 +75,10 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:

if not expected or not provided or not secrets.compare_digest(str(expected), provided):
logger.info(
"csrf.rejected",
_EVENT_CSRF_REJECTED,
extra={"method": method, "path": path, "has_token": bool(expected)},
)
response = PlainTextResponse("CSRF validation failed", status_code=403)
response = PlainTextResponse(_CSRF_VALIDATION_FAILED_MSG, status_code=403)
await response(scope, receive, send)
return

Expand Down
21 changes: 14 additions & 7 deletions framework/hosting/simple_module_hosting/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@

router = APIRouter(tags=["Health"])

_KEY_STATUS = "status"
_KEY_CHECKS = "checks"
_KEY_MIGRATION = "migration"
_KEY_DETAIL = "detail"
_STATUS_HEALTHY = "healthy"
_STATUS_ALIVE = "alive"

# Severity ordering for aggregation: worst status wins
_STATUS_SEVERITY = {
HealthStatus.HEALTHY: 0,
Expand All @@ -24,14 +31,14 @@
async def health(request: Request) -> dict:
migration = getattr(request.app.state, "migration", None)
return {
"status": "healthy",
"migration": migration,
_KEY_STATUS: _STATUS_HEALTHY,
_KEY_MIGRATION: migration,
}


@router.get("/health/live")
async def liveness() -> dict:
return {"status": "alive"}
return {_KEY_STATUS: _STATUS_ALIVE}


@router.get("/health/ready")
Expand All @@ -40,7 +47,7 @@ async def readiness(request: Request) -> dict:
checks = registry.all_checks

if not checks:
return {"status": "healthy", "checks": {}}
return {_KEY_STATUS: _STATUS_HEALTHY, _KEY_CHECKS: {}}

# Run all checks concurrently
async def _run_check(name: str, check_fn):
Expand All @@ -64,9 +71,9 @@ async def _run_check(name: str, check_fn):
worst = result.status

return {
"status": worst.value,
"checks": {
name: {"status": r.status.value, **({"detail": r.detail} if r.detail else {})}
_KEY_STATUS: worst.value,
_KEY_CHECKS: {
name: {_KEY_STATUS: r.status.value, **({_KEY_DETAIL: r.detail} if r.detail else {})}
for name, r in results.items()
},
}
Loading