Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
26b46aa
fix(auth): preserve the deep link across login
antosubash Aug 21, 2026
41a5369
feat(hosting): common error states, maintenance mode, offline banner
antosubash Aug 21, 2026
c2bea3c
feat(admin): move the admin screens under /admin
antosubash Aug 21, 2026
4b05b10
fix(hosting): show maintenance copy on a planned outage
antosubash Aug 21, 2026
446fdfe
docs: record the admin section and the admin-routes hook
antosubash Aug 21, 2026
f5df34c
fix: address code review findings (round 1, pass 1)
antosubash Aug 21, 2026
5a69b40
fix(admin): admit anyone with an admin sidebar entry to /admin
antosubash Aug 21, 2026
7397577
docs: fix stale admin-section menu metadata left over from the URL move
antosubash Aug 21, 2026
18f2fcb
test(maintenance): cover POST to a GET-only public route during maint…
antosubash Aug 21, 2026
2bea26c
test(e2e): point the browser suite at the moved admin URLs
antosubash Aug 21, 2026
f18ada8
fix(ui): keep every admin screen reachable from the command palette
antosubash Aug 21, 2026
b3ec751
test(maintenance): lock in the InertiaCache/Maintenance ordering end …
antosubash Aug 21, 2026
de62bd5
test(perf): measure the moved admin routes, not their redirects
antosubash Aug 21, 2026
2e35e51
fix: address code review findings (round 1, pass 3)
antosubash Aug 21, 2026
b21ebdb
fix(dashboard): keep the Dashboard tile on the dashboard
antosubash Aug 21, 2026
6f8b791
refactor(dashboard): reuse the shared path helpers in tile resolution
antosubash Aug 21, 2026
6459c86
fix(hosting): let the browser tab name the page
antosubash Aug 21, 2026
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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,17 +74,19 @@ 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_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).
`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)` / `register_admin_routes(admin_router)` → async `on_startup` / `on_shutdown` (reverse order). `register_admin_routes` is only for modules that serve **both** public and admin pages: a module gets exactly one router per `view_prefix`, which `users` cannot express (sign-in at `/users/login`, management at `/admin/users`). Setting `ModuleMeta.admin_view_prefix` mounts a second view router there. A module whose views are *all* administrative just points `view_prefix` at `/admin/<name>` and keeps using `register_routes`. The prefix is a URL convention, not a permission — guard these routes exactly as you would any other. `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 → InertiaCache → CommitBeforeResponse → app`. `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `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.
`(ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → Maintenance → CommitBeforeResponse → app`. `InertiaCache` answers for `InertiaLayoutData` merging per-user `auth`/`menus` into every payload: a response to an `X-Inertia` request is forced to `private, no-store` with its ETag dropped, and both representations of a URL gain `Vary: X-Inertia` — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set `Cache-Control` and an ETag on the *document*; that path is left alone. `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. `Maintenance` serves a 503 page to everyone but admins while `maintenance_mode` is set on `HostSettings`; it sits inside `InertiaCache` because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable.

**Database**: per-module `Base` via `create_module_base("<name>")`. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout.

Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass with `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`. The per-request session (`get_db`) auto-commits **only if** there are pending writes (via `after_flush` listener); otherwise rollback. Service code should **not** call `session.commit()` — flush if you need DB-assigned values. The commit fires in `CommitBeforeResponseMiddleware`, at the ASGI `http.response.start` message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a `yield` dependency's exit code *after* the response is delivered, which used to make that a deterministic 404 (GH #257). `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins.

**Migrations** live in `host/migrations/versions/` — not in module packages. `host/alembic/env.py` calls `build_module_metadata()` + `make_include_object()` so autogenerate covers every installed module and ignores host-owned tables. First migration of each module should set `branch_labels = ("<module_name>",)` to enable per-module `downgrade <module>@base`.

**Admin section**. Administrative screens live under `/admin/*`, register into `MenuSection.ADMIN_SIDEBAR`, and render in `AdminLayout` — all three together, not one of the three. `SidebarLayout` renders whichever menu its `menuKey` names, so a page left on `AuthenticatedLayout` after its menu item moved shows a sidebar that no longer contains it. `group=` sub-clusters *within* the admin sidebar (`Access`, `Appearance`, `System`); it is no longer used to carve an admin area out of the main sidebar. `/admin` itself is a host route (`host/routes.py`) that renders from the `adminSidebar` shared prop, so an installed module contributes a card without touching it. Old URLs 301 from `host/routes_legacy.py`. Only view URLs moved — `/api/*` is a separate contract and stays put.

**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**. 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.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ make migrate
make dev
```

Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard/` is the authenticated home, and `/dashboard/doctor` is the admin-only "smpy doctor" panel (static checks, migrations, dev server, modules).
Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard/` is the authenticated home, and `/admin/doctor/` is the admin-only "smpy doctor" panel (static checks, migrations, dev server, modules).

## Create a new module

Expand DownExpand Up@@ -109,7 +109,7 @@ docs/

## Configuration

Local deployments only need one env var — everything else has sensible defaults and is managed in the admin UI at `/settings/modules`.
Local deployments only need one env var — everything else has sensible defaults and is managed in the admin UI at `/admin/settings/`.

| Variable | Default | Required |
|---|---|---|
Expand DownExpand Up@@ -186,7 +186,7 @@ SM_AUTH_PROVIDER=keycloak

Then re-run `make gen-pages` so the frontend manifest picks up the active provider's
pages (`make dev` does this for you), and configure the realm under
`/settings/modules`. Switching back is the same knob set to `users`.
`/admin/settings/`. Switching back is the same knob set to `users`.

Two caveats when running Keycloak:

Expand DownExpand Up@@ -221,7 +221,7 @@ The auto-bootstrap is idempotent — it only creates the user if the `users_user

### Inviting users

1. Log in as admin and navigate to `/users/admin/invite`.
1. Log in as admin and navigate to `/admin/users/add`.
2. Fill in the invitee's email and optionally a full name and role(s). Click **Send invite**.
3. With the default `console` mailer, the invite link is logged to stdout (`tail -f` the server log). Copy the link and send it to the user. With `smtp`, the email is delivered automatically.
4. The invitee opens the link (`/users/invite/accept?token=…`), sets a password, and is immediately logged in.
Expand Down
2 changes: 1 addition & 1 deletion docs/e2e-testing.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

Playwright-driven smoke tests live in [tests/e2e/](../tests/e2e/) — currently
[`test_settings_ui.py`](../tests/e2e/test_settings_ui.py) (logs in, navigates to
`/settings/modules`, toggles a module setting, and verifies the change
`/admin/settings/`, toggles a module setting, and verifies the change
hot-reloads into `app.state` without a server restart) and
[`test_audit_log_ui.py`](../tests/e2e/test_audit_log_ui.py).

Expand Down
4 changes: 2 additions & 2 deletions docs/framework/lifecycle.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,11 +75,11 @@ def register_permissions(self, registry: PermissionRegistry) -> None:
)
```

Permissions become available in the role admin UI (`/settings/permissions`). See [Permissions](/framework/permissions).
Permissions become available in the role admin UI (`/admin/users/` (Roles tab)). See [Permissions](/framework/permissions).

## `register_feature_flags(registry)`

Declare feature flags with defaults. The admin can toggle them at `/settings/feature-flags`.
Declare feature flags with defaults. The admin can toggle them at `/admin/feature-flags/`.

```python
def register_feature_flags(self, registry: FeatureFlagRegistry) -> None:
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/permissions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -68,7 +68,7 @@ DEFAULT_ROLE_PERMISSIONS = {

Host apps customize this by:

1. Editing roles in the admin UI at `/settings/permissions`.
1. Editing roles in the admin UI at `/admin/users/` (Roles tab).
2. Or seeding via code during `on_startup` in a custom host-level module.

The framework ships only `admin: ["*"]`. The wildcard grants every declared permission.
Expand Down
4 changes: 2 additions & 2 deletions docs/framework/settings.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ There are **three** separate settings surfaces in a running app. They serve diff
|---|---|---|---|
| **Framework env** (`Settings`) | `app.state.sm.settings` | No (read once at boot) | DB URL, secret key, log level, anything needed before the DB is open. |
| **Module env** (`<Module>Env`) | `app.state.<module>.settings` | No | Module bootstrap knobs that must be resolved before DB-backed settings load. |
| **DB-backed settings** | `settings_setting` table, edited via `/settings/modules` | Yes | Everything else: SMTP creds, storage backends, feature toggles that operators tune. |
| **DB-backed settings** | `settings_setting` table, edited via `/admin/settings/` | Yes | Everything else: SMTP creds, storage backends, feature toggles that operators tune. |

## Framework settings

Expand DownExpand Up@@ -123,7 +123,7 @@ async def users_config(state: UsersStateDep):

## DB-backed settings

After bootstrap, most configuration lives in the `settings_setting` table and is edited via the admin UI at `/settings/modules`. The CRUD layer is `SettingService` (in `settings.service`); typed reads (with USER > TENANT > SYSTEM resolution and registered defaults) go through a `SettingsAccessor` that wraps it:
After bootstrap, most configuration lives in the `settings_setting` table and is edited via the admin UI at `/admin/settings/`. The CRUD layer is `SettingService` (in `settings.service`); typed reads (with USER > TENANT > SYSTEM resolution and registered defaults) go through a `SettingsAccessor` that wraps it:

```python
from settings.service import SettingService
Expand Down
12 changes: 6 additions & 6 deletions docs/guide/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
Runtime configuration has **two** sources, in order of precedence:

1. **Environment variables** (`SM_*`) — read at boot, before the DB connection is open. Use for bootstrap plumbing (DB URL, secret key, feature-flag overrides for tests).
2. **DB-backed settings** — edited from `/settings/modules` at runtime. Use for everything else: SMTP creds, storage backends, module-specific toggles.
2. **DB-backed settings** — edited from `/admin/settings/` at runtime. Use for everything else: SMTP creds, storage backends, module-specific toggles.

Most deployments only need `SM_DATABASE_URL` and `SM_SECRET_KEY` in the environment — everything else is configurable from the admin UI.

Expand All@@ -23,7 +23,7 @@ Prefix is always `SM_`. These are the pre-DB knobs read by `simple_module_hostin
| `SM_MODULES_ENABLED` | unset (all enabled) | Comma-separated allow-list to disable modules without uninstalling them. |
| `SM_AUTH_PUBLIC_PATHS` | `[]` | JSON array of anonymous-access path prefixes — a host-level escape hatch. Modules should prefer the `register_public_routes` hook. |

Multi-tenancy (`multi_tenant`, `tenant_header`) and i18n (`i18n_default_locale`, `i18n_supported_locales`, `i18n_cookie_name`) are **DB-backed host settings** now, not env vars — edit them under `host` at `/settings/modules`. (`smpy new --tenancy` still writes `SM_MULTI_TENANT=true` into `.env.example` as a scaffold convenience, and tests can override these.)
Multi-tenancy (`multi_tenant`, `tenant_header`) and i18n (`i18n_default_locale`, `i18n_supported_locales`, `i18n_cookie_name`) are **DB-backed host settings** now, not env vars — edit them under `host` at `/admin/settings/`. (`smpy new --tenancy` still writes `SM_MULTI_TENANT=true` into `.env.example` as a scaffold convenience, and tests can override these.)

## Database bootstrap knobs

Expand All@@ -38,7 +38,7 @@ Only change if you know what you're doing — these must be set *before* the DB

## Internationalization

These are **DB-backed host settings** (under `host` in `/settings/modules`), not env vars.
These are **DB-backed host settings** (under `host` in `/admin/settings/`), not env vars.

| Setting | Default | Notes |
|---|---|---|
Expand All@@ -48,7 +48,7 @@ These are **DB-backed host settings** (under `host` in `/settings/modules`), not

## Users module

Only the first-boot **bootstrap seed** is read from the env (prefix `SM_USERS_*`). Signup policy, mailer, SMTP creds, and base URL all moved to the DB-backed settings store — edit them under `users` at `/settings/modules`.
Only the first-boot **bootstrap seed** is read from the env (prefix `SM_USERS_*`). Signup policy, mailer, SMTP creds, and base URL all moved to the DB-backed settings store — edit them under `users` at `/admin/settings/`.

| Variable | Default | Notes |
|---|---|---|
Expand All@@ -61,7 +61,7 @@ DB-backed users settings (defaults): `allow_signup=false`, `mailer=console` (or

## Background tasks (Celery)

The broker/result settings are DB-backed (under `background_tasks` in `/settings/modules`) with the defaults below. The generated `docker-compose.yml` sets `SM_BG_TASKS_BROKER_URL` / `SM_BG_TASKS_RESULT_BACKEND` on the `worker` and `beat` containers so they reach the in-container `redis` service.
The broker/result settings are DB-backed (under `background_tasks` in `/admin/settings/`) with the defaults below. The generated `docker-compose.yml` sets `SM_BG_TASKS_BROKER_URL` / `SM_BG_TASKS_RESULT_BACKEND` on the `worker` and `beat` containers so they reach the in-container `redis` service.

| Setting | Default | Notes |
|---|---|---|
Expand All@@ -78,7 +78,7 @@ uv run smpy settings import-from-env

This is idempotent — it only seeds keys that don't have a DB override yet.

From then on, edit at `/settings/modules` (requires the `settings.manage` permission). Changes apply immediately; no restart needed.
From then on, edit at `/admin/settings/` (requires the `settings.manage` permission). Changes apply immediately; no restart needed.

## Per-module settings convention

Expand Down
42 changes: 42 additions & 0 deletions docs/module-authoring.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,6 +119,48 @@ class MyModule(ModuleBase):
)
```

### Administrative screens

Admin pages belong under `/admin/*`, in `MenuSection.ADMIN_SIDEBAR`, rendered
by `AdminLayout`. Do all three or none — `SidebarLayout` renders whichever
menu its `menuKey` names, so a page still on `AuthenticatedLayout` after its
menu entry moved shows a sidebar that no longer lists it.

If your module is administrative end to end, that is just a prefix:

```python
class MyModule(ModuleBase):
meta = ModuleMeta(name="MyModule", view_prefix="/admin/my-module")
```

If it serves both public and admin pages — as `users` does, with sign-in at
`/users/login` and management at `/admin/users` — one `view_prefix` cannot
express both. Declare a second mount point and fill it from
`register_admin_routes`:

```python
class MyModule(ModuleBase):
meta = ModuleMeta(
name="MyModule",
view_prefix="/my-module", # public pages
admin_view_prefix="/admin/my-module", # admin pages
)

def register_admin_routes(self, admin_router: APIRouter) -> None:
from my_module.admin.views import router as admin_views

admin_router.include_router(admin_views)
```

The prefix is a URL convention, not a permission: nothing under `/admin` is
gated automatically. Guard these routes with the same dependencies you would
use anywhere else, and give the menu item a matching `permissions=` so it is
not offered to accounts whose click would 403.

Point the menu item at the canonical path — the trailing-slash form when the
index is registered at `"/"`. Linking to the bare prefix costs a 307 on every
navigation, which `test_menu_urls_are_canonical` will fail you for.

## API stability contract

`simple_module_core` exposes `FRAMEWORK_API_VERSION` (PEP 440 string). At
Expand Down
Loading