Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5eaf681
docs: issue-batch-426-437 design
antosubash Oct 11, 2026
170ac9a
docs: issue-batch-426-437 implementation plan
antosubash Oct 11, 2026
4b114af
fix(users): announce the sign-in error to screen readers (#437)
antosubash Oct 11, 2026
fefc192
fix(cli): scaffold theme/title wiring and lazy-router-safe module tes…
antosubash Oct 11, 2026
4baa4b3
fix(settings): hide zero-field modules from the picker (#430)
antosubash Oct 11, 2026
816418e
fix(settings): import-from-env reads .env under the real environment …
antosubash Oct 11, 2026
ecc3ca8
fix(i18n): ship the ui catalog inside simple_module_hosting (#426)
antosubash Oct 11, 2026
bbfe203
fix(file_storage): pin SigV4 for S3 presigned URLs (#432)
antosubash Oct 11, 2026
481607e
feat(csp): per-response CSP providers for settings-driven origins (#434)
antosubash Oct 11, 2026
25afa9c
feat(auth): UserContext.permissions merged by the permission resolver…
antosubash Oct 11, 2026
6c0a195
fix(csp): reject non-printable-ASCII sources and never fail a respons…
antosubash Oct 11, 2026
cd34240
fix(ui): layout home links follow homeUrl instead of /dashboard/ (#427)
antosubash Oct 11, 2026
aefca2c
fix(ui): default footer caption no longer claims the MIT licence (#428)
antosubash Oct 11, 2026
7bf6884
fix(users,dashboard): count root-mounted modules as having views (#435)
antosubash Oct 11, 2026
a03c7b3
chore: satisfy lint (format, RUF071 on an intended character-wise pre…
antosubash Oct 11, 2026
48749a9
refactor(optimize): wire_module_routes reports views directly; share …
antosubash Oct 11, 2026
7cb50b4
test(settings): isolate import-from-env tests from the developer's .env
antosubash Oct 11, 2026
8aaad82
fix: address code review findings (round 1, pass 1)
antosubash Oct 11, 2026
4c04b14
fix: address code review findings (round 1, pass 2)
antosubash Oct 11, 2026
1a9426b
fix: address code review findings (round 1, pass 3)
antosubash Oct 11, 2026
568d214
test(e2e): shell home link, footer caption and sign-in alert (#426, #…
antosubash Oct 11, 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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,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_invalidations` / `register_health_checks` / `register_public_routes` / `register_csp_sources` / `register_setup_steps` → `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). `register_setup_steps(registry)` lets a module declare what a usable install still needs; while any required step is incomplete `SetupMiddleware` serves the first-run wizard at `/setup` instead of the app. The wizard ships with `simple_module_hosting` (`setup_wizard/` — routes mounted by `create_app`, page `Setup/Wizard` registered by `gen-pages`), so hosts carry no setup code; a step completes from the browser through its `SetupAction`, which runs only while *that* step is pending. A module that registers nothing never gates — that is how `keycloak` opts out, since its local users table is legitimately empty forever and a host-level superuser count would lock those installs out permanently. `register_invalidations(bus, app)` subscribes a module's **per-process caches** to `InvalidationBus`, so another worker's write drops this worker's entry instead of leaving it stale for its whole TTL; handlers may only *forget*, since there is no delivery guarantee. Publishing takes no hook — `await request.app.state.sm.invalidation.publish(channel, key=...)` from a `db.on_commit` callback. Cross-process delivery needs a transport, which `background_tasks` installs on its Redis connection (`SM_BG_TASKS_BROADCAST_INVALIDATIONS`); with none the bus is in-process and every cache still needs its TTL as a floor. See [docs/framework/invalidation.md](docs/framework/invalidation.md).
`register_settings` → `register_menu_items` / `register_permissions` / `register_feature_flags` / `register_event_handlers` / `register_invalidations` / `register_health_checks` / `register_public_routes` / `register_csp_sources` / `register_setup_steps` → `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; an origin read from DB-backed settings uses `registry.add_provider(fn)`, evaluated per response. `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_setup_steps(registry)` lets a module declare what a usable install still needs; while any required step is incomplete `SetupMiddleware` serves the first-run wizard at `/setup` instead of the app. The wizard ships with `simple_module_hosting` (`setup_wizard/` — routes mounted by `create_app`, page `Setup/Wizard` registered by `gen-pages`), so hosts carry no setup code; a step completes from the browser through its `SetupAction`, which runs only while *that* step is pending. A module that registers nothing never gates — that is how `keycloak` opts out, since its local users table is legitimately empty forever and a host-level superuser count would lock those installs out permanently. `register_invalidations(bus, app)` subscribes a module's **per-process caches** to `InvalidationBus`, so another worker's write drops this worker's entry instead of leaving it stale for its whole TTL; handlers may only *forget*, since there is no delivery guarantee. Publishing takes no hook — `await request.app.state.sm.invalidation.publish(channel, key=...)` from a `db.on_commit` callback. Cross-process delivery needs a transport, which `background_tasks` installs on its Redis connection (`SM_BG_TASKS_BROADCAST_INVALIDATIONS`); with none the bus is in-process and every cache still needs its TTL as a floor. See [docs/framework/invalidation.md](docs/framework/invalidation.md).

`MenuRegistry.add_provider(fn)` (from `register_menu_items`) contributes per-request menu items evaluated in `InertiaLayoutDataMiddleware` after auth/tenant resolution, and `PermissionRegistry.add_source(name, provider)` (from `register_permissions`) contributes runtime-defined permissions from a sync in-memory cache, refreshed with `invalidate_source(name)`; see [docs/framework/permissions.md](docs/framework/permissions.md).

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ gen-pages:
uv run --project host smpy host gen-pages --host-dir=host/client_app

# Regenerate packages/i18n/src/{generated-resources,keys.generated}.ts from every
# installed module's locales/, plus host/locales and packages/ui/locales — without
# installed module's locales/, plus host/locales and the hosting package's ui_locales — without
# booting the app. Pass flags with ARGS, e.g. `make gen-i18n ARGS=--allow-removals`.
gen-i18n:
uv run --project host python scripts/gen_i18n.py $(ARGS)
Expand Down
4 changes: 2 additions & 2 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ way.

Menus are translated **on the server**, in `MenuRegistry.get_for_user(translate=…)`, so the Inertia payload carries finished text and every render site (sidebar, topbar, command palette) keeps reading `item.label`. Two consequences worth knowing: an admin-audience module's labels don't need to be in the anonymous catalog snapshot to render, and a key that resolves to nothing falls back to `label` — a missing translation degrades to English, never to a raw dotted key on screen. Both fields are optional, so modules written before them keep working unchanged.

Group headers are shared across modules, so they live in the `ui` namespace (`ui.nav_groups.access|appearance|content|system`) rather than each module inventing its own key — otherwise one module's "System" could translate differently from another's and split a single header in two. Those four are the whole vocabulary the bundled modules use; adding a fifth means adding the key to `packages/ui/locales/*.json` so every module can share it.
Group headers are shared across modules, so they live in the `ui` namespace (`ui.nav_groups.access|appearance|content|system`) rather than each module inventing its own key — otherwise one module's "System" could translate differently from another's and split a single header in two. Those four are the whole vocabulary the bundled modules use; adding a fifth means adding the key to `simple_module_hosting/ui_locales/*.json` so every module can share it.
- `i18n` — active locale and translation bundle.

The framework does not know the shape of `auth.user`. The `auth` module registers a `principal_serializer: Callable[[UserContext], dict]` on `app.state.principal_serializer` during `register_settings(app)`; the middleware calls it with `request.state.user` to build the `auth.user` payload. Without a registered serializer, `auth.user` is `None` even when a user is authenticated.
Expand Down Expand Up @@ -676,7 +676,7 @@ Do NOT declare `const schema = z.object({ ... t('...') })` at module scope — i
### Host and shared-package strings

- Host strings (landing page, error page) live in `host/locales/` and are namespaced `host.*`.
- Shared UI strings (`packages/ui/`) live in `packages/ui/locales/`, namespaced `ui.*`.
- Shared UI strings (`packages/ui/`) live in `simple_module_hosting/ui_locales/`, namespaced `ui.*`.
- Both are auto-discovered at boot alongside module contributions.

### Strings the server composes
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Module-scope `t(...)` calls capture the first-render locale and never update.
## Host and shared-package strings

- **Host strings** (landing page, error page): `host/locales/<lang>.json`, namespace `host.*`.
- **Shared UI strings** (packages/ui): `packages/ui/locales/<lang>.json`, namespace `ui.*`.
- **Shared UI strings** (packages/ui): `simple_module_hosting/ui_locales/<lang>.json`, namespace `ui.*`.

Both are auto-discovered alongside module contributions — no manual wiring.

Expand Down
16 changes: 16 additions & 0 deletions docs/framework/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,22 @@ can be extended — never `default-src`, `base-uri`, `form-action`, or
single origin/scheme token; invalid declarations raise at boot. The origins
land in both the development (Vite-widened) and production policies.

The hook runs before DB-backed settings are hydrated, so an origin that comes
from a setting can't be a static `add()`. Register a provider instead — it is
called on every response with the app and returns `(directive, source)` pairs:

```python
def register_csp_sources(self, registry) -> None:
registry.add_provider(lambda app: [("img-src", app.state.maps.settings.tile_origin)])
```

Providers must be synchronous and read only in-memory state
(`app.state.<pkg>.settings`), never the database. Their pairs go through the
same validation as `add()`; a provider that raises, or an invalid pair, is
logged once and dropped — the static policy still applies. The finished policy
is cached by the pairs returned, so a settings edit takes effect on the next
response. With no providers the header is the precomputed boot-time string.

## `register_exception_handlers(app)`

Register FastAPI exception handlers scoped to your module's exceptions:
Expand Down
3 changes: 2 additions & 1 deletion docs/framework/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@ class UserContext:
name: str
roles: list[str] = field(default_factory=list)
tenant_id: str | None = None
permissions: list[str] = field(default_factory=list)
```

The active auth provider (the `users` or `keycloak` module) owns extraction from the session cookie / bearer token. `InertiaLayoutDataMiddleware` then expands roles into a permission set (cached on `request.state.resolved_permissions`) and serializes `auth.user`, `auth.isAuthenticated`, `auth.permissions` into the Inertia shared props. `UserContext` carries roles, not permissions — permissions are derived from the `PermissionRegistry.role_map`.
The active auth provider (the `users` or `keycloak` module) owns extraction from the session cookie / bearer token. `InertiaLayoutDataMiddleware` then expands roles into a permission set (cached on `request.state.resolved_permissions`) and serializes `auth.user`, `auth.isAuthenticated`, `auth.permissions` into the Inertia shared props. Permissions are derived mostly from roles via the `PermissionRegistry.role_map`. `UserContext.permissions` adds keys the principal carries itself — a token scope or IdP claim a provider or principal resolver maps in. `resolve_principal_permissions` (and so `RequiresPermission`, `require_permission` and `auth.permissions`) merges them into the role-derived set, **dropping `*`**: like a grant source, a claim can never confer the wildcard; admin comes from roles only. They are written into the session cookie with `roles` and frozen there until the next sign-in, so anything an admin must be able to revoke belongs in a grant source, not here. `UserContext.has_permission(key)` is an exact-match check against that list alone.

## Role shorthand for menus

Expand Down
17 changes: 16 additions & 1 deletion docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,22 @@ def register_csp_sources(self, registry):
```

Only fetch directives can be extended; each source must be a single
origin/scheme token, validated at boot. See
origin/scheme token, validated at boot.

An origin that comes from a DB-backed setting (a configurable tile server)
can't be added at boot — the hook runs before settings hydrate. Register a
provider, evaluated per response:

```python
def register_csp_sources(self, registry):
registry.add_provider(lambda app: [("img-src", app.state.maps.settings.tile_origin)])
```

Providers must be synchronous and read in-memory state only
(`app.state.<pkg>.settings`); a raising provider or an invalid pair is logged
once and dropped, leaving the static policy in force. Sources must be
printable ASCII, as CSP's own grammar requires, so an internationalised domain
is written in its punycode form (`https://xn--exmple-cua.com`). See
[docs/framework/lifecycle.md](framework/lifecycle.md#register_csp_sourcesregistry).

## First-run setup steps
Expand Down
3 changes: 2 additions & 1 deletion docs/modules/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ from auth.contracts import UserContext, AuthProvider
| `name` | `str` | display name — `full_name or email` of the user |
| `roles` | `list[str]` | role names, e.g. `["admin"]` |
| `tenant_id` | `str \| None` | only set when multi-tenant |
| `permissions` | `list[str]` | keys the principal carries itself (token scope / IdP claim), merged into the role-derived set by the resolver with `*` dropped; empty from `from_user`. Frozen in the session cookie like `roles` — revocable grants belong in a [grant source](/framework/permissions) |

Helpers: `has_role(role)` / `has_any_role(roles)` for in-handler role checks; `from_user(user)` builds a context from a duck-typed user object (`.id`, `.email`, `.full_name`, `.roles[*].name`, `.tenant_id`); `to_session_dict()` / `from_session_dict()` (de)serialise it into the signed session cookie.
Helpers: `has_role(role)` / `has_any_role(roles)` for in-handler role checks; `has_permission(key)` (exact match against `permissions` only — gate routes with `require_permission`, which also folds in roles and grant sources); `from_user(user)` builds a context from a duck-typed user object (`.id`, `.email`, `.full_name`, `.roles[*].name`, `.tenant_id`); `to_session_dict()` / `from_session_dict()` (de)serialise it into the signed session cookie (a cookie without `permissions` decodes to `[]`).

`AuthProvider` is the `Protocol` an auth-provider module implements (`resolve_user`, `get_login_url`, `get_logout_url`, `get_public_paths`, `is_bearer_request`) — see [Pluggable auth](#pluggable-auth) below.

Expand Down
1 change: 1 addition & 0 deletions docs/modules/file_storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ DB-backed via `register_module_settings`; pydantic defaults seed at boot. Live e
| `s3_access_key_id` | `""` | optional — falls back to env / IAM if empty |
| `s3_secret_access_key` | `""` | optional |
| `s3_endpoint_url` | `""` | optional — set for MinIO / Cloudflare R2 |
| `s3_signature_version` | `"s3v4"` | presign signature version; pinned so custom endpoints get SigV4 |
| `s3_presign_ttl_seconds` | `300` | TTL for presigned `GET` URLs |
| `max_file_size_bytes` | `100 * 1024 * 1024` | upload limit (100 MB) |
| `allowed_content_types` | `None` | optional MIME-type whitelist; `None` ⇒ any |
Expand Down
28 changes: 28 additions & 0 deletions docs/superpowers/plans/2026-10-11-issue-batch-426-437.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Issue batch #426–#437 — implementation plan

Design: `docs/superpowers/specs/2026-10-11-issue-batch-426-437-design.md` (approved).

Every task: write the failing test first, implement, run the touched suites
(`uv run pytest <dir>` / `npx vitest run <path>`), keep files under 300 lines.
Tasks touch disjoint files and run in parallel; implementers do **not** commit —
the lead commits each task by path once its tests pass.

| Task | Issues | Files (primary) | Hard? |
|---|---|---|---|
| T1 | #426 | `framework/hosting/simple_module_hosting/_i18n_build.py`, `ui_locales/` (moved from `packages/ui/locales/`), comments in `Makefile`, `simple_module_core/__main__.py`, `diagnostics/_i18n.py`, `docs/framework-conventions.md`, `framework/hosting/tests/` | no |
| T2 | #427, #428 | `packages/ui/src/{types.ts,lib/home-url.ts,lib/brand.ts,layouts/{AdminLayout,SidebarLayout,PublicLayout}.tsx,components/BrandingFooter*.tsx}`, `modules/branding/branding/components/BrandingPreview.tsx`, users shared-props provider + test | no |
| T3 | #429, #431 | `framework/cli/simple_module_cli/templates/host/client_app/app.tsx`, `templates/module/tests/test___PACKAGE__.py.tpl`, `framework/cli/tests/` | no |
| T4 | #430, #433 | `modules/settings/settings/pages/ModulesEdit.tsx`, `modules/settings/tests-js/ModulesEdit.test.tsx`, `modules/settings/settings/cli.py`, `modules/settings/tests/test_cli_import.py`, `.env.example` | no |
| T5 | #432 | `modules/file_storage/file_storage/{settings.py,backends/s3.py}`, `modules/file_storage/tests/test_s3_backend.py`, file_storage locales if settings labels are translated | no |
| T6 | #434 | `framework/core/simple_module_core/csp.py`, `framework/hosting/simple_module_hosting/{middleware.py,_phase_helpers.py}`, CSP tests, `docs/framework/lifecycle.md`, `docs/module-authoring.md` § External asset origins | **yes** |
| T7 | #435 | `framework/hosting/simple_module_hosting/_module_routes.py` (+ wherever `app.state.sm` is created), `modules/users/users/startup.py`, `modules/dashboard/dashboard/stats.py`, their tests | no |
| T8 | #436 | `modules/auth/auth/contracts/schemas.py`, `framework/hosting/simple_module_hosting/permissions.py`, `modules/auth/tests/test_user_context.py`, `framework/hosting/tests/test_grant_sources.py` (or new), `docs/framework/permissions.md` | **yes** |
| T9 | #437 | `modules/users/users/auth_local/components/LoginForm.tsx` + new vitest | no |

Done-check per task: the issue's repro is covered by a test that failed before
and passes after; touched suites green; `uv run ruff check`/`ruff format --check`
on touched Python, `npx biome check` on touched TS.

After all tasks: `make lint`, `make test`, `make doctor`, regenerate i18n keys
(boot the host in development or the repo's gen script) and confirm
`packages/i18n/src/keys.generated.ts` only changes where expected.
Loading
Loading