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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
176 changes: 176 additions & 0 deletions docs/plans/2026-06-17-branding-feature-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
# Branding feature — design

**Date:** 2026-06-17
**Status:** design (intended state)

## Goal

Let an administrator customise the application's identity — **app name, logo,
favicon, and primary brand colour** — from the admin UI, and have those values
applied everywhere the framework currently hard-codes "SimpleModule" (browser
title, sidebar/header label and logo, favicon, primary colour).

## Scope (MVP) and explicit non-goals

In scope:

- A `branding` module exposing **app name**, **logo image**, **favicon image**,
and **primary colour (hex)**.
- A dedicated admin page (`/branding`) with uploads, a colour picker and a live
preview — gated behind a `branding.manage` permission.
- Logo/favicon **image upload** reusing the existing `file_storage` module
(stored by UUID, served via its download endpoint).
- Values persisted via the existing **settings store** (no new branding DB
table), hydrated at boot and hot-reloaded on save.
- Values surfaced to **every** Inertia page (authenticated *and* guest) via a
new generic framework extension point, then consumed in the React layout,
the document `<title>`, the favicon `<link>`, and primary-colour CSS vars.

Non-goals (deliberately deferred — note where the design leaves room):

- **Per-tenant branding.** MVP is SYSTEM scope only. The settings store already
supports TENANT/USER scope, so this is an additive change later (resolve the
tenant in the shared-props provider).
- **Full colour-scale theming.** We override the shadcn semantic tokens
(`--primary`, `--primary-foreground`, `--sidebar-primary`) from the single
hex. We do *not* regenerate the `primary-50…900` OKLCH scale used by some
gradients; that is a future enhancement.
- Custom fonts, login-page background imagery, dark/light logo variants.

## Why this shape

The framework already has every primitive this needs; the feature is mostly
*wiring*, not new infrastructure:

- **Settings store** persists arbitrary module config (SYSTEM/TENANT/USER
scope), hydrates a pydantic `BaseSettings` at boot into
`app.state.<module>.settings`, and hot-swaps it on save via
`apply_changes_and_reload(...)` which also publishes `SettingsReloaded`.
→ Branding stores its four values here; **no new table**.
- **file_storage** stores an uploaded image and returns a stable UUID; the
image is served from `GET /api/file-storage/files/{id}/download` for every
backend (local disk or S3/MinIO).
→ Branding stores `logo_file_id` / `favicon_file_id` and derives URLs.
- **`InertiaLayoutDataMiddleware`** already assembles per-page shared props
(`auth`, `menus`, `i18n`) and — crucially — avoids the `SM009`
framework→plugin import ban by reading a *module-registered callable* off
`app.state` (`principal_serializer`). Branding follows that exact precedent.

## Architecture

### New framework extension point: Inertia shared-prop providers

A generic, module-agnostic hook so any plugin can contribute layout-wide shared
props without the framework importing the plugin (mirrors `principal_serializer`).

- `app.state.inertia_shared_providers: list[Callable[[Request], dict]]`,
initialised to `[]` in `app_builder`.
- A tiny hosting helper `register_inertia_shared_provider(app, fn)` appends to it.
- `InertiaLayoutDataMiddleware` iterates the providers and merges each returned
dict into `shared` (after the built-in `auth`/`menus`/`i18n`). Providers must
be cheap and total (no exceptions); a provider that raises is skipped and
logged, never failing the request.

This is generic framework value, not a branding special-case.

### The `branding` module (plugin)

```
modules/branding/branding/
├── module.py # registers settings, permission, menu, routes, provider
├── settings.py # BrandingSettings(BaseSettings): app_name, logo_file_id,
│ # favicon_file_id, primary_color
├── services.py # BrandingState(settings=...)
├── service.py # BrandingService: read current branding, apply updates,
│ # upload+set logo/favicon via file_storage + settings store
├── shared_props.py # provider: (request) -> {"branding": {...}} from app.state
├── contracts/ # BrandingOut, BrandingUpdate DTOs
├── deps.py
├── endpoints/api.py # JSON: GET current, PUT name/colour, POST logo, POST
│ # favicon, DELETE logo/favicon (all branding.manage)
├── endpoints/views.py # Inertia: GET /branding -> "Branding/Manage"
├── pages/Manage.tsx # admin page: name, uploads, colour picker, live preview
└── locales/en.json
```

- `meta = ModuleMeta(name="Branding", route_prefix="/api/branding",
view_prefix="/branding", depends_on=["Settings", "FileStorage"])`.
- `register_permissions`: group "Branding" with `branding.view`, `branding.manage`.
- `register_menu_items`: "Branding" under the "Administration" group,
`roles=["admin"]`.
- `register_settings`: `register_module_settings(app, "branding",
BrandingSettings, lambda s: BrandingState(settings=s))`.
- Shared-props provider registered in `on_startup` (after settings hydrated):
returns `{"branding": {appName, logoUrl, faviconUrl, primaryColor}}`, reading
the live `app.state.branding.settings`. URLs derived from file ids; `None`
when unset so the frontend falls back to defaults.

### Data flow

```
admin saves on /branding
→ POST /api/branding/logo (UploadFile)
→ file_storage.upload() -> StoredFileOut.id (UUID)
→ settings apply_changes_and_reload(app, bus, store, "branding",
{"logo_file_id": str(id)})
→ app.state.branding.settings hot-swapped + SettingsReloaded event
→ next request:
InertiaLayoutDataMiddleware merges branding provider output into shared
→ inertia.share(**shared)
→ usePage().props.branding in React
→ SidebarLayout (name + logo), app.tsx title, <Head> favicon,
CSS var override for --primary
```

### Frontend consumption (packages/ui + host)

- `app.tsx` title callback: `title ? \`${title} — ${appName}\` : appName`,
reading the initial-page branding prop (fallback "SimpleModule").
- `SidebarLayout.tsx`: render `branding.appName` and, when `logoUrl` set, an
`<img>` instead of the "SM" badge — both mobile and desktop. Fallback to the
current "SM"/"SimpleModule" treatment.
- New `BrandingHead` component (packages/ui) rendered in the authenticated and
public layouts: emits `<Head>` with a favicon `<link>` (when set) and a
`<style>` overriding `--primary` / `--primary-foreground` /
`--sidebar-primary` from the hex (when set).
- Branding shared prop also reaches **guest** pages (login etc.) because the
provider runs for every request.

## Validation & error handling

- Image uploads constrained to an allow-list (`image/png`, `image/jpeg`,
`image/svg+xml`, `image/x-icon`, `image/webp`) and a max size (e.g. 2 MB),
enforced in the branding endpoint before handing to file_storage.
- `primary_color` validated as a `#rrggbb` hex (pydantic field validator);
invalid input rejected with 422.
- `app_name` length-bounded (1–60 chars).
- Shared-props provider is defensive: any failure → omit branding block, log,
never 500 a page.

## Testing

- **Unit (pytest):** `BrandingSettings` defaults/validation; `BrandingService`
upload→settings-write happy path with a fake file_storage; shared-props
provider output (set vs unset values); hex validator.
- **Integration (pytest + authenticated_client):** `PUT /api/branding`
persists name/colour and the value appears in the next page's shared props;
`POST /api/branding/logo` stores a file and sets `logo_file_id`; permission
gate returns 403 without `branding.manage`.
- **Framework:** provider registry — a registered provider's dict is merged
into shared; a throwing provider is skipped, not fatal.
- **JS (vitest):** `SidebarLayout` renders custom name/logo when prop present,
falls back when absent; `BrandingHead` emits favicon + CSS var style.
- **Doctor:** `make doctor` clean (no SM00x/SM01x regressions; menu+permission
present so no SM019).

## Build sequence

1. Framework extension point (registry + middleware merge + helper) — TDD.
2. Scaffold `branding` module (`make new-module`), strip CRUD shape to the
settings/singleton shape above.
3. `BrandingSettings` + state + service + contracts + provider — TDD.
4. Endpoints (JSON + Inertia view) + permissions + menu — TDD.
5. Frontend: shared-prop typing, SidebarLayout, app.tsx title, BrandingHead,
Manage.tsx page.
6. Wire provider registration; `make gen-pages`; migrations check.
7. `make lint`, `make test`, `make doctor`; e2e smoke of `/branding`.
13 changes: 12 additions & 1 deletion framework/hosting/simple_module_hosting/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,5 +3,16 @@
from simple_module_hosting.app_builder import create_app
from simple_module_hosting.logging import correlation_id, setup_logging
from simple_module_hosting.settings import Settings
from simple_module_hosting.shared_props import (
SharedPropsProvider,
register_inertia_shared_provider,
)

__all__ = ["Settings", "correlation_id", "create_app", "setup_logging"]
__all__ = [
"Settings",
"SharedPropsProvider",
"correlation_id",
"create_app",
"register_inertia_shared_provider",
"setup_logging",
]
28 changes: 28 additions & 0 deletions framework/hosting/simple_module_hosting/_inertia_shared.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
from __future__ import annotations

import logging
from typing import Any

from starlette.datastructures import Headers
from starlette.requests import Request
Expand DownExpand Up@@ -59,3 +60,30 @@ def build_i18n_block(scope: Scope, request: Request) -> dict:
"supportedLocales": registry.available_locales(),
"messages": registry.messages_snapshot(locale) if send_messages else None,
}


def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
"""Merge module-registered Inertia shared-prop providers into ``shared`` in place.

Providers are read off ``app.state.inertia_shared_providers`` (never importing
the plugin — preserves SM009). A provider that raises is skipped and logged; a
provider may not clobber a framework-owned key (auth/menus/i18n) or an earlier
provider's key.
"""
providers = getattr(app.state, "inertia_shared_providers", None) or ()
for provider in providers:
name = getattr(provider, "__name__", provider)
try:
extra = provider(request)
except Exception: # a bad provider must not break the page render
logger.warning("shared-prop provider %r raised; skipping", name, exc_info=True)
continue
for key, value in (extra or {}).items():
if key in shared:
logger.warning(
"provider %r tried to overwrite reserved shared-prop %r; ignoring",
name,
key,
)
continue
shared[key] = value
7 changes: 6 additions & 1 deletion framework/hosting/simple_module_hosting/middleware.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
from starlette.requests import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send

from simple_module_hosting._inertia_shared import build_i18n_block
from simple_module_hosting._inertia_shared import build_i18n_block, merge_shared_prop_providers
from simple_module_hosting._observability import (
CorrelationIdMiddleware,
RequestLoggingMiddleware,
Expand DownExpand Up@@ -277,6 +277,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
),
"i18n": i18n_block,
}

# Merge module-registered shared-prop providers (e.g. branding) — read off
# app.state without importing the plugin (SM009), defensively.
merge_shared_prop_providers(scope["app"], request, shared)

request.state.inertia_shared = shared

await self.app(scope, receive, send)
42 changes: 42 additions & 0 deletions framework/hosting/simple_module_hosting/shared_props.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
"""Module-registered Inertia shared-prop providers.

A generic extension point so plugin modules can contribute layout-wide Inertia
shared props (e.g. branding) on every page, without the framework importing the
plugin. This mirrors the ``principal_serializer`` precedent: the framework reads
a registered callable off ``app.state`` rather than reaching into module code,
keeping the ``SM009`` framework→plugin import ban intact.

A provider is ``Callable[[Request], dict]``. It must be cheap and total — it runs
for every request. :class:`InertiaLayoutDataMiddleware` merges each provider's
returned dict into the ``shared`` payload after the built-in ``auth``/``menus``/
``i18n`` blocks; a provider that raises is skipped and logged, never failing the
request.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from fastapi import FastAPI
from starlette.requests import Request

SharedPropsProvider = Callable[["Request"], dict]
"""A function mapping a request to a dict merged into Inertia shared props."""

_STATE_ATTR = "inertia_shared_providers"


def register_inertia_shared_provider(app: FastAPI, provider: SharedPropsProvider) -> None:
"""Register a shared-props provider on the app.

Idempotently initialises ``app.state.inertia_shared_providers`` (a list) and
appends ``provider``. Safe to call from a module lifecycle hook before the
framework has set up the list.
"""
providers = getattr(app.state, _STATE_ATTR, None)
if providers is None:
providers = []
setattr(app.state, _STATE_ATTR, providers)
providers.append(provider)
91 changes: 91 additions & 0 deletions framework/hosting/tests/test_inertia_shared_providers.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
"""Verify InertiaLayoutDataMiddleware merges module-registered shared-prop providers.

Modules contribute layout-wide Inertia shared props (e.g. branding) without the
framework importing the plugin — mirroring the ``principal_serializer`` precedent.
Providers are registered on ``app.state.inertia_shared_providers`` and merged into
the ``shared`` dict for every request.
"""

from __future__ import annotations

import logging

from fastapi import FastAPI
from simple_module_core.menu import MenuRegistry
from simple_module_core.permissions import PermissionRegistry
from simple_module_hosting.middleware import InertiaLayoutDataMiddleware
from simple_module_hosting.shared_props import register_inertia_shared_provider
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.testclient import TestClient


def _build_app() -> FastAPI:
app = FastAPI()

@app.get("/shared")
def shared(request: Request) -> JSONResponse:
return JSONResponse(request.state.inertia_shared)

app.add_middleware(
InertiaLayoutDataMiddleware,
menu_registry=MenuRegistry(),
permission_registry=PermissionRegistry(),
)
app.add_middleware(SessionMiddleware, secret_key="test-secret")
return app


def test_registered_provider_dict_merged_into_shared() -> None:
app = _build_app()
register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}})

body = TestClient(app).get("/shared").json()

assert body["branding"] == {"appName": "Acme"}
# Built-in blocks still present.
assert "auth" in body
assert "menus" in body


def test_provider_that_raises_is_skipped_not_fatal(caplog) -> None:
app = _build_app()

def boom(_req: Request) -> dict:
raise RuntimeError("provider blew up")

register_inertia_shared_provider(app, boom)
register_inertia_shared_provider(app, lambda _req: {"branding": {"appName": "Acme"}})

with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"):
resp = TestClient(app).get("/shared")

assert resp.status_code == 200
body = resp.json()
# The good provider still applied; the failing one was skipped.
assert body["branding"] == {"appName": "Acme"}
assert "auth" in body
assert any("shared-prop" in rec.message.lower() for rec in caplog.records)


def test_no_providers_leaves_shared_unchanged() -> None:
body = TestClient(_build_app()).get("/shared").json()
assert "branding" not in body
assert "auth" in body and "menus" in body


def test_provider_cannot_clobber_framework_keys(caplog) -> None:
app = _build_app()
# A misbehaving provider tries to overwrite the framework-owned auth block.
register_inertia_shared_provider(
app, lambda _req: {"auth": "HIJACKED", "branding": {"ok": True}}
)

with caplog.at_level(logging.WARNING, logger="simple_module_hosting.middleware"):
body = TestClient(app).get("/shared").json()

# auth stays the framework's dict; only the non-reserved key is added.
assert isinstance(body["auth"], dict)
assert body["branding"] == {"ok": True}
assert any("reserved shared-prop" in rec.message for rec in caplog.records)
Loading
Loading