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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,7 @@ cascade layer is inert, while unlayered CSS beats every Tailwind utility —
hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling.

**Lifecycle hooks** (in `framework/core/simple_module_core/module.py`) — all no-op by default; subclasses override as needed:
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md).
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_health_checks` / `register_public_routes` / `register_csp_sources` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_csp_sources(registry)` lets a module whitelist external asset origins (`registry.add("style-src", "https://rsms.me")`) — fetch directives only, validated at boot. `register_public_routes(registry)` lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from `AuthMiddleware`; rules are method-aware (`registry.add_regex(r"…/tilejson$", methods={"GET"})`), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See [docs/framework/public-routes.md](docs/framework/public-routes.md).

**Middleware pipeline** (Starlette `add_middleware` is LIFO — last added runs first). Execution order on a request:
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → app`. `GZip` compresses any response over 500 bytes, including the `/static` mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. `ProxyHeaders` (uvicorn's `ProxyHeadersMiddleware`) is installed only when `SM_TRUSTED_PROXY` is set, sitting outermost so the `X-Forwarded-*`-corrected scheme/client IP reach everything downstream (request logs and Inertia's absolute page url). When two modules add middleware at the same dependency tier, the module that sorts **later** wraps outermost. Use `depends_on` to express relative order — don't rely on names.
Expand All@@ -87,7 +87,7 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b

**Inertia**. `inertia.render("<ModuleName>/<PageName>", ...)` maps to `modules/<name>/<name>/pages/<PageName>.tsx`, where `<ModuleName>` is the PascalCase of the module directory (`blog_posts` → `BlogPosts`). Host-level pages under `host/client_app/pages/` use a bare `<PageName>`. `InertiaLayoutDataMiddleware` populates shared props (`auth`, `menus`, `i18n`); use `InertiaDep` from `simple_module_hosting.inertia_deps`. Mismatched keys fire `SM003` (orphan page) / `SM004` (phantom render).

**CSRF defence**. There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie (Starlette default): browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form-submit from another origin is unauthenticated. Raw `fetch()` calls in page code don't need a token header.
**CSRF defence**. Baseline protection comes from `SameSite=Lax` on the session cookie (Starlette default): browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form-submit from another origin is unauthenticated. Raw `fetch()` calls in page code don't need a token header by default. Modules wanting defence in depth opt into `simple_module_hosting.csrf` — `RequiresCsrf` as a router dependency plus `get_csrf_token(request)` exposed as a view prop; callers echo it as `X-CSRF-Token` on unsafe methods.

## Conventions to follow

Expand Down
55 changes: 55 additions & 0 deletions docs/framework-conventions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -300,6 +300,61 @@ which `AuthMiddleware` consults on every request. See
[`docs/framework/public-routes.md`](framework/public-routes.md) for match kinds
and resolution order.

### CSP sources (external assets)

The host ships a strict Content-Security-Policy. A module whose frontend loads
an asset from another origin — a font CDN, a tile server — declares it via
`register_csp_sources`, and the host folds the origins into both the dev
(Vite-widened) and production policies:

```python
def register_csp_sources(self, registry):
registry.add("style-src", "https://rsms.me")
registry.add("font-src", "https://rsms.me")
```

Only fetch directives are extendable (`style-src`, `font-src`, `img-src`,
`connect-src`, …) — never `default-src`, `base-uri`, `form-action`, or
`frame-ancestors`, which belong to the host operator. Sources are validated
single tokens; an invalid declaration raises at boot.

### CSRF (opt-in token check)

The framework baseline is `SameSite=Lax` on the session cookie. Modules that
want defence in depth opt into a session-bound token check per router:

```python
from simple_module_hosting.csrf import RequiresCsrf, get_csrf_token

router = APIRouter(dependencies=[Depends(RequiresCsrf())])
# expose the token to the frontend as a view prop:
{"csrf_token": get_csrf_token(request)}
```

Callers echo the token back as `X-CSRF-Token` on `POST`/`PUT`/`PATCH`/`DELETE`;
safe methods are never checked, and apps without `SessionMiddleware` (bare
test apps) are exempt.

### Error responses (HTML vs JSON)

403/404/422/500 are content-negotiated. Requests under `/api/*` — the
documented prefix for every module's JSON surface — or with an explicit
`Accept: application/json` get a JSON body:

```json
{ "detail": "Permission required: pagebuilder.edit" }
```

Browser-shaped requests get the rendered Inertia error page, which carries
the layout, i18n copy, and the request's correlation id. "Browser-shaped"
means the request lists `text/html` in its Accept header without preferring
JSON over it (q-values decide: `application/json, text/html;q=0.5` still
gets JSON, and `text/html;q=0` rules html out) — navigations and Inertia
visits qualify, and that wins even under `/api/*` (OAuth login links and
file-download `<a>` hrefs are real navigations to API paths); a bare
`fetch()` sends `Accept: */*` and gets JSON there. Module endpoint code doesn't opt in or
out — raise `HTTPException` as usual and the handler picks the right shape.

### Design packs (site-wide look)

A *design pack* is a stylesheet a module ships that restyles the public site by
Expand Down
20 changes: 20 additions & 0 deletions docs/framework/lifecycle.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ register_feature_flags
register_event_handlers
register_health_checks
register_public_routes
register_csp_sources
register_design_packs
register_exception_handlers
register_middleware
Expand DownExpand Up@@ -128,6 +129,24 @@ async def _check_db(self) -> HealthCheckResult: ...

Each check returns a `HealthCheckResult(status=HealthStatus.HEALTHY | DEGRADED | UNHEALTHY, detail=...)`. The `/health/ready` endpoint runs all checks concurrently and reports the worst status (a raising check counts as `UNHEALTHY`).

## `register_csp_sources(registry)`

Whitelist external origins your frontend loads assets from — a font CDN, a
tile server, an analytics endpoint. The host ships a strict
Content-Security-Policy; without a declaration the browser blocks the request.

```python
def register_csp_sources(self, registry) -> None:
registry.add("style-src", "https://rsms.me")
registry.add("font-src", "https://rsms.me")
```

Only fetch directives (`style-src`, `font-src`, `img-src`, `connect-src`, …)
can be extended — never `default-src`, `base-uri`, `form-action`, or
`frame-ancestors`, which belong to the host operator. Each source must be a
single origin/scheme token; invalid declarations raise at boot. The origins
land in both the development (Vite-widened) and production policies.

## `register_exception_handlers(app)`

Register FastAPI exception handlers scoped to your module's exceptions:
Expand DownExpand Up@@ -214,6 +233,7 @@ class OrdersModule(ModuleBase):
def register_event_handlers(self, bus, app=None): ...
def register_health_checks(self, registry): ...
def register_public_routes(self, registry): ...
def register_csp_sources(self, registry): ...
def register_exception_handlers(self, app): ...
def register_middleware(self, app): ...
def register_routes(self, api_router, view_router): ...
Expand Down
4 changes: 2 additions & 2 deletions docs/framework/middleware.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,11 +93,11 @@ Emits a structured log line per request with method, path, status, duration, and

### `SecurityHeadersMiddleware`

Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection: 0` (the legacy auditor is disabled in favour of CSP), plus a default CSP and — outside development — HSTS. In development the CSP is widened for the Vite dev origin and HSTS is suppressed. Override on a per-route basis with your own response headers.
Sets conservative defaults: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection: 0` (the legacy auditor is disabled in favour of CSP), plus a default CSP and — outside development — HSTS. In development the CSP is widened for the Vite dev origin and HSTS is suppressed. Modules that load assets from an external origin extend the policy through the [`register_csp_sources`](lifecycle.md#register_csp_sourcesregistry) hook; both the dev and production variants honor those origins. Override on a per-route basis with your own response headers.

### `SessionMiddleware`

Starlette's built-in signed-cookie sessions. Cookie name is `session`; attributes are `HttpOnly`, `SameSite=Lax`. `SameSite=Lax` is the CSRF defence: browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form submission from another origin is unauthenticated.
Starlette's built-in signed-cookie sessions. Cookie name is `session`; attributes are `HttpOnly`, `SameSite=Lax`. `SameSite=Lax` is the baseline CSRF defence: browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form submission from another origin is unauthenticated. Modules wanting defence in depth opt into the session-bound token check in `simple_module_hosting.csrf` — `RequiresCsrf` as a router dependency, `get_csrf_token(request)` exposed as a view prop, and callers echoing it as `X-CSRF-Token` on unsafe methods.

### `TenantMiddleware` *(opt-in)*

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/overview.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ What actually happens when you run `uvicorn main:app`:
`Settings`, `DatabaseState` (engines per provider), `EventBus`, `MenuRegistry`, `PermissionRegistry`, `FeatureFlagRegistry`, `HealthRegistry`, `I18nRegistry`. They are bundled into a frozen `Services` dataclass and attached to `app.state.sm`.
3. **Discovery** — `discover_modules()` reads Python entry points under the `simple_module` group, imports each one, validates it's a `ModuleBase` subclass with a non-null `meta`, and topologically sorts by `ModuleMeta.depends_on`.
4. **Lifecycle hooks run in sorted order**. For each module, in this order:
`register_settings` → `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_public_routes` → `register_design_packs` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)`.
`register_settings` → `register_menu_items` → `register_permissions` → `register_feature_flags` → `register_event_handlers` → `register_health_checks` → `register_public_routes` → `register_csp_sources` → `register_design_packs` → `register_exception_handlers` → `register_middleware` → `register_routes(api_router, view_router)`.
5. **Middleware is installed** — framework middleware first, then whatever modules registered. See [Middleware pipeline](/framework/middleware).
6. **Routers mount** — `api_router` at `/api`, `view_router` at `/`. Each module's sub-routers were attached via `register_routes`.
7. **Lifespan `on_startup`** — each module's async `on_startup` runs in dependency order. This is where background workers, warm caches, or remote-service health probes start.
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,10 +13,10 @@ Prefix is always `SM_`. These are the pre-DB knobs read by `simple_module_hostin

| Variable | Default | Notes |
|---|---|---|
| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL. Postgres: `postgresql+asyncpg://…` |
| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL. Postgres: `postgresql+asyncpg://…` Relative sqlite paths resolve against the project root (the `.env` location), not the cwd. |
| `SM_ENVIRONMENT` | `development` | Any value other than `development`, `test`, `testing` triggers strict discovery + placeholder-secret checks. |
| `SM_SECRET_KEY` | `change-me-in-production` | **Must** be overridden in production — session cookie signing key. |
| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. |
| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. Scaffolded apps derive the Vite dev server's port and origin from this same value, so it's the single knob for moving off 5050. |
| `SM_DEBUG` | `false` | Enables debug mode (shows tracebacks in HTTP responses). |
| `SM_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR` |
| `SM_LOG_FORMAT` | `json` | `json` (structured) or `text`. |
Expand Down
37 changes: 37 additions & 0 deletions docs/module-authoring.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,43 @@ package dir (a `force-include` maps it in), and `static/dist` is gitignored
(an `artifacts` entry ships it when present without failing the build when
it isn't).

## External asset origins (CSP)

The host ships a strict Content-Security-Policy. If your pages load anything
from another origin — a font CDN, a tile server, a third-party API — declare
it, or the browser blocks the request and your feature silently breaks in
every host:

```python
def register_csp_sources(self, registry):
registry.add("style-src", "https://rsms.me")
registry.add("font-src", "https://rsms.me")
```

Only fetch directives can be extended; each source must be a single
origin/scheme token, validated at boot. See
[docs/framework/lifecycle.md](framework/lifecycle.md#register_csp_sourcesregistry).

## CSRF on mutation endpoints

The framework's baseline CSRF defence is `SameSite=Lax` on the session
cookie — plain Inertia forms need nothing extra. For defence in depth on a
module's JSON mutation surface, opt into the framework's session-bound token
check instead of rolling your own:

```python
from simple_module_hosting.csrf import RequiresCsrf, get_csrf_token

router = APIRouter(dependencies=[Depends(RequiresCsrf())])

# expose the token to your pages as a view prop:
await inertia.render("MyModule/Page", {"csrf_token": get_csrf_token(request)})
```

Frontend callers echo the token back as `X-CSRF-Token` on
`POST`/`PUT`/`PATCH`/`DELETE`. Safe methods are never checked, and bare test
apps without `SessionMiddleware` are exempt, so unit tests need no ceremony.

## Developing out-of-tree

A module in its own repo has no host around it — these are the three
Expand Down
7 changes: 4 additions & 3 deletions docs/reference/env-vars.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,15 +8,16 @@ This is the full reference. See [Configuration](/guide/configuration) for a narr

| Variable | Default | Notes |
|---|---|---|
| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL: `postgresql+asyncpg://user:pw@host:5432/db` or `sqlite+aiosqlite:///./app.db`. |
| `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | **Required in production.** Async URL: `postgresql+asyncpg://user:pw@host:5432/db` or `sqlite+aiosqlite:///./app.db`. Relative sqlite paths resolve against the project root (the `.env` location), not the process cwd — CLI tools run from `host/` hit the same file as the app. |
| `SM_ENVIRONMENT` | `development` | `development` and `testing` are the only non-prod values (placeholder-secret check is skipped for both). Any value other than `development` triggers strict module discovery. |
| `SM_SECRET_KEY` | `change-me-in-production` | **Must** be overridden in production — session cookie signing key. |
| `SM_DEBUG` | `false` | Enables debug mode (tracebacks in HTTP responses). |
| `SM_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR`. |
| `SM_LOG_FORMAT` | `json` | `text` for readable dev logs, `json` for structured logs in prod. |
| `SM_MODULES_ENABLED` | unset | Comma-separated allow-list to disable modules without uninstalling them. |
| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. |
| `SM_VITE_PORT` | `5050` | Dev only — port the Vite dev server binds to (read by `vite.config.ts`). If you change it, set `SM_VITE_DEV_URL` to match so the backend points the HMR client at the right origin. |
| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — where the Vite HMR client connects. Freshly scaffolded apps also derive the Vite dev server's port and origin from this one value (via `client_app/vite.dev-url.ts`), so changing it here is the whole move. |
| `SM_VITE_PORT` | `5050` | Dev only — port this repo's own host `vite.config.ts` binds to. If you change it, set `SM_VITE_DEV_URL` to match so the backend points the HMR client at the right origin. New scaffolds don't need it — they read `SM_VITE_DEV_URL` directly. |
| `SM_PROJECT_ROOT` | unset | Overrides project-root discovery: where the `.env` is looked up and what relative sqlite paths resolve against. Normally unnecessary — settings walk up from the cwd (stopping at repo boundaries and `$HOME`) to find the `.env` on their own. |
| `SM_AUTH_PUBLIC_PATHS` | `[]` | JSON array of host-level anonymous-access path prefixes. Escape hatch for exposing a route without a session when no module owns it; modules should prefer the method-aware `register_public_routes` hook. |

## DB connection pool
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { type Plugin, defineConfig } from 'vite';
import { viteDevServer } from './vite.dev-url';

// Force every importer (host, workspace module, wheel-installed module)
// to resolve to one React copy + a single Inertia hook context. Without
Expand All@@ -29,6 +30,10 @@ function findNodeModulesRoot(start: string): string {
}
const fsRoot = findNodeModulesRoot(__dirname);

// Walks up from client_app to the directory holding the project .env itself —
// fsRoot tracks node_modules, which (in flat mode) is NOT where .env lives.
const { origin: devUrl, port: devPort } = viteDevServer(__dirname);

// Load the module pages manifest written by the Python host at boot.
// Each entry points at an absolute pages/ directory — typically inside a
// pip-installed module wheel. Vite needs these in server.fs.allow so the
Expand DownExpand Up@@ -281,9 +286,9 @@ export default defineConfig({
},
},
server: {
port: 5050,
port: devPort,
strictPort: true,
origin: 'http://localhost:5050',
origin: devUrl,
fs: {
allow: [fsRoot, ...moduleFsAllow],
},
Expand Down
Loading