diff --git a/docs/framework/discovery.md b/docs/framework/discovery.md index b2168016..48c17720 100644 --- a/docs/framework/discovery.md +++ b/docs/framework/discovery.md @@ -44,6 +44,7 @@ meta = ModuleMeta( view_prefix="/orders", # where the view router mounts depends_on=["Products"], # hard ordering requirements version="1.0.0", # semver for the module + i18n_audience="public", # who gets this module's locale catalog ) ``` @@ -72,6 +73,16 @@ A list of other modules' `meta.name` values that **must be loaded first**. The f Semver string. Used in diagnostic/boot logging to help operators correlate deployed module versions with bug reports. Unless a module declares `requires_framework`, it is not parsed for automated version-range checks. +### `requires_framework` + +Optional PEP 440 specifier (e.g. `">=1.0,<2.0"`) for the framework API version the module supports. When set, the module is rejected at boot if the installed `simple_module_core.FRAMEWORK_API_VERSION` doesn't satisfy it. When `None` (the default), no compatibility check runs. See [module authoring](/module-authoring). + +### `i18n_audience` + +Who the module's locale catalog is shipped to — `"public"` (the default) or `"admin"`. + +Catalogs ride the Inertia shared props on full page loads, so a module whose UI sits entirely behind login can declare `i18n_audience="admin"` and stop anonymous visitors downloading admin form labels on every public page. The catalog is shipped as soon as the user authenticates, and server-side `Translator` lookups always see every namespace either way. See [Internationalization → Audience](/framework/i18n#audience). + ## The `simple_module` group Entry points are the same mechanism that ships with `importlib.metadata`: diff --git a/docs/framework/overview.md b/docs/framework/overview.md index 939fa066..62169f11 100644 --- a/docs/framework/overview.md +++ b/docs/framework/overview.md @@ -92,4 +92,4 @@ This is how the `auth.user` shared prop is built: framework middleware calls wha - [Lifecycle hooks](/framework/lifecycle) — the lifecycle hooks in call order with examples. - [Middleware pipeline](/framework/middleware) — execution order and how to slot your own in. - [Settings & app.state](/framework/settings) — framework vs. module state. -- [Bundled modules](/modules/) — the ten first-party modules and what each one ships. +- [Bundled modules](/modules/) — the twelve first-party modules and what each one ships. diff --git a/docs/guide/installation.md b/docs/guide/installation.md index f0d659e0..a4b3bf5f 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -58,7 +58,7 @@ smpy new myapp --preset standard --with background_tasks,file_storage Dependencies between modules are resolved automatically — e.g. `users` always pulls in `auth`, and `file_storage` pulls in `settings`. The `settings` module ships only in the `full` preset (or when added explicitly with `--with settings`). -After scaffolding, `smpy new` runs `uv sync`, `npm install`, and `alembic upgrade head` for you (skip with `--no-install` if you'd rather drive that yourself). +After scaffolding, `smpy new` runs `uv sync`, `npm install`, and `alembic upgrade heads` for you (skip with `--no-install` if you'd rather drive that yourself). ## Boot it diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index f504d3c9..865c890d 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -17,7 +17,7 @@ smpy new myapp --yes cd myapp ``` -`--yes` accepts the defaults (SQLite, no multi-tenancy, the `standard` preset: `auth`, `users`, `dashboard`, `permissions`). The CLI runs `uv sync`, `npm install`, and `alembic upgrade head` for you. +`--yes` accepts the defaults (SQLite, no multi-tenancy, the `standard` preset: `auth`, `users`, `dashboard`, `permissions`). The CLI runs `uv sync`, `npm install`, and `alembic upgrade heads` for you. For an interactive run with prompts, drop the `--yes`. For a preset + extras: `smpy new myapp --preset standard --with background_tasks,file_storage --yes`. diff --git a/docs/index.md b/docs/index.md index a7fda0e4..3e869c8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,7 +26,7 @@ features: link: /guide/first-module linkText: Build a module - title: Use a bundled module - details: Eleven first-party modules ship with the framework — auth, users, keycloak, permissions, settings, file_storage, background_tasks, feature_flags, audit_log, dashboard, site_lock. + details: Twelve first-party modules ship with the framework — auth, users, keycloak, permissions, settings, file_storage, branding, background_tasks, feature_flags, audit_log, dashboard, site_lock. link: /modules/ linkText: Browse modules - title: Operate it in production diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 4301a1d6..c7659113 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -200,7 +200,7 @@ installed or a module's models change: pip install simple_module_my_module alembic revision --autogenerate -m "add my-module" # review the generated file -alembic upgrade head +alembic upgrade heads ``` The host's `env.py` (scaffolded from the framework's template) calls: diff --git a/docs/modules/branding.md b/docs/modules/branding.md index ac93ab74..a7f657b0 100644 --- a/docs/modules/branding.md +++ b/docs/modules/branding.md @@ -1,6 +1,8 @@ # branding -Lets an administrator customise the application's identity — **app name**, **logo**, **favicon**, and **primary colour** — from an admin page, with no code change or redeploy. Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page (authenticated *and* guest) through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, and apply the brand colour everywhere. +White-labels the application. An administrator sets the **app name**, **logo** (plus an optional dark-background variant), **favicon**, **primary colour**, **[design pack](/framework-conventions#design-packs-site-wide-look)**, a site-wide **announcement banner**, and a **configurable footer** — from an admin page, with no code change or redeploy. + +Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page — authenticated *and* guest — through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, apply the brand colour, and show the banner and footer everywhere. ## ModuleMeta @@ -10,25 +12,49 @@ Lets an administrator customise the application's identity — **app name**, **l | `route_prefix` | `/api/branding` | | `view_prefix` | `/branding` | | `depends_on` | `["Settings", "FileStorage"]` | +| `i18n_audience` | `"admin"` | -It depends on `settings` for storage and `file_storage` for the uploaded logo/favicon bytes. +It depends on `settings` for storage and `file_storage` for the uploaded logo/favicon bytes. Its catalog is admin-form strings, so it declares [`i18n_audience="admin"`](/framework/i18n#audience) and is kept out of the guest bundle — branding's *public* contribution rides the shared-props provider, not i18n keys. ## Routes -### API +### API (admin) -All JSON endpoints — including the read — require `branding.manage` (they back the admin editor). +Every JSON endpoint — including the reads — requires `branding.manage`; they back the admin editor. -| Method + path | Body / response | Permission | -|---|---|---| -| `GET /api/branding/` | → `BrandingOut` | `branding.manage` | -| `PUT /api/branding/` | `BrandingUpdate` → `BrandingOut` | `branding.manage` | -| `POST /api/branding/logo` | `multipart` (field `file`) → `BrandingOut` | `branding.manage` | -| `POST /api/branding/favicon` | `multipart` (field `file`) → `BrandingOut` | `branding.manage` | -| `DELETE /api/branding/logo` | → `BrandingOut` (logo cleared) | `branding.manage` | -| `DELETE /api/branding/favicon` | → `BrandingOut` (favicon cleared) | `branding.manage` | +| Method + path | Body / response | +|---|---| +| `GET /api/branding/` | → `BrandingOut` | +| `PUT /api/branding/` | `BrandingUpdate` → `BrandingOut` | +| `POST /api/branding/presets/{key}` | → `BrandingOut` (`404` for an unknown key) | +| `GET /api/branding/footer` | → `FooterConfig` | +| `PUT /api/branding/footer` | `FooterConfig` → `FooterConfig` (whole-object replace) | +| `POST /api/branding/logo` | `multipart` (field `file`) → `BrandingOut` | +| `POST /api/branding/logo-dark` | `multipart` (field `file`) → `BrandingOut` | +| `POST /api/branding/favicon` | `multipart` (field `file`) → `BrandingOut` | +| `DELETE /api/branding/logo` | → `BrandingOut` (logo cleared) | +| `DELETE /api/branding/logo-dark` | → `BrandingOut` (dark logo cleared) | +| `DELETE /api/branding/favicon` | → `BrandingOut` (favicon cleared) | + +`PUT /` only touches the text fields (`app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`); images are set and cleared through their dedicated upload/delete routes, and the footer through `PUT /footer`. A `design_pack` slug that no installed module registered is rejected with `422` — accepting it would put `"-root"` on the document with no stylesheet behind it, so the site would look unchanged with nothing in the UI explaining why. + +Uploads are validated **before** the bytes reach `file_storage`: an unsupported or unconvincing type returns `415`, an oversized image `413` (see [Image guard-rails](#image-guard-rails)). + +### Public assets (anonymous) -`PUT` only touches the text fields (`app_name`, `primary_color`); the logo and favicon are set/cleared through their dedicated upload/delete routes. Uploads are validated **before** the bytes are handed to `file_storage` — an unsupported MIME type returns `415`, an oversized image returns `413` (see [Image guard-rails](#image-guard-rails)). +Registered through the [`register_public_routes`](/framework/public-routes) hook as `exact` + **GET-only** rules, so uploading and clearing the same paths stay behind `branding.manage`. + +| Method + path | Response | +|---|---| +| `GET /api/branding/logo` | The configured logo bytes (`404` when unset) | +| `GET /api/branding/logo-dark` | The dark-background variant (`404` when unset) | +| `GET /api/branding/favicon` | The configured favicon (`404` when unset) | + +Branding serves these itself rather than linking `file_storage`'s download route, which is gated by `file_storage.download` — no logged-out visitor carries that permission, and the sign-in page, the public landing page and every `` are exactly where the logo has to appear. Each route resolves **only** the id currently held in branding settings and streams that one file, so it is not a way to read arbitrary files out of `file_storage`. + +Responses carry `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`. Both are ignored for subresource loads (``, ``) but stop a direct visit rendering the bytes as a document at the app's own origin. + +When `file_storage` is backed by S3-compatible storage, the route returns a `302` to a presigned URL. That redirect is deliberately **uncached** — the target expires, so caching it would hand out a dead link after the TTL. ### View @@ -36,22 +62,39 @@ All JSON endpoints — including the read — require `branding.manage` (they ba |---|---|---| | `GET /branding/` | `Branding/Manage` | `branding.view` | -The page reads the current values from the shared `branding` prop, so the view endpoint passes no page props of its own. +Current branding reaches the page through the shared `branding` prop. The endpoint passes only what the shared prop *can't* carry: `designPacks` (which packs the installed modules registered) and `presets` (the built-in list, with swatches). + +## Asset caching + +The published URL carries `?v=`. Replacing an image stores a **new** `file_storage` file, so the id doubles as a content address — the URL changes and caches invalidate for free. + +| Request | `Cache-Control` | +|---|---| +| With a `?v=` version | `public, max-age=31536000, immutable` (one year) | +| Without a version | `public, max-age=3600` (one hour) | + +An unversioned URL can serve new bytes later, so it must never be immutable; the short TTL lets it self-correct. A `404` is never cached, so the next request retries once the setting is fixed. ## Public contracts ```python from branding.contracts import BrandingOut, BrandingUpdate +from branding.contracts.footer import FooterConfig, FooterColumn, FooterLink ``` | Class | Purpose | |---|---| -| `BrandingOut` | Current branding with logo/favicon resolved to download URLs: `app_name`, `primary_color`, `logo_url`, `favicon_url`. | -| `BrandingUpdate` | Editable text fields only: `app_name` (≤ 60 chars, non-blank), `primary_color` (`#rrggbb` or empty). | +| `BrandingOut` | Current branding with images resolved to URLs: `app_name`, `primary_color`, `design_pack`, `logo_url`, `logo_dark_url`, `favicon_url`, `banner_message`, `banner_severity`. | +| `BrandingUpdate` | Editable text fields, all optional: `app_name`, `primary_color`, `design_pack`, `banner_message`, `banner_severity`. | +| `FooterConfig` | The whole footer: `tagline`, `copyright_owner`, `note`, `columns`, `social_links`. | +| `FooterColumn` | A titled group: `title` + `links`. | +| `FooterLink` | One `label` + `href`. | + +`BrandingUpdate` is the strict one. An unknown `banner_severity` is a clear `422` here, while the settings validator normalises it to `info` — settings hydrate from the DB, where a hand-edited row must degrade to a readable banner rather than stop the app from booting. `design_pack` and `primary_color` are shape-checked in the DTO for the same reason: a malformed value becomes a `422` instead of a `500` when `BrandingSettings` re-validates. ## Models -**None.** Branding owns no tables. The four values are stored in the shared settings store at **SYSTEM** scope, hydrated into `app.state.branding.settings` at boot, and hot-swapped on save via the settings reload path. +**None.** Branding owns no tables. Every value is stored in the shared settings store at **SYSTEM** scope, hydrated into `app.state.branding.settings` at boot, and hot-swapped on save via the settings reload path. ## Settings @@ -59,17 +102,86 @@ DB-backed via `register_module_settings`; pydantic defaults seed at boot. Edited | Field | Default | Purpose | |---|---|---| -| `app_name` | `"SimpleModule"` | Application name (trimmed; must be non-blank and ≤ 60 chars). | +| `app_name` | `"SimpleModule"` | Application name (trimmed; non-blank, ≤ 60 chars, no control characters). | | `primary_color` | `""` | Brand colour as a lowercase `#rrggbb` hex string; `""` ⇒ use the theme default. | -| `logo_file_id` | `""` | `file_storage` UUID of the uploaded logo; `""` ⇒ no custom logo. | -| `favicon_file_id` | `""` | `file_storage` UUID of the uploaded favicon; `""` ⇒ no custom favicon. | +| `design_pack` | `""` | Slug of a registered [design pack](/framework-conventions#design-packs-site-wide-look); `""` ⇒ base tokens only. | +| `logo_file_id` | `""` | `file_storage` UUID of the logo; `""` ⇒ no custom logo. | +| `logo_dark_file_id` | `""` | UUID of the dark-background variant; `""` ⇒ fall back to `logo_file_id`. | +| `favicon_file_id` | `""` | UUID of the favicon; `""` ⇒ no custom favicon. | +| `banner_message` | `""` | Site-wide announcement text (≤ 500 chars); `""` ⇒ no banner. | +| `banner_severity` | `"info"` | One of `info`, `warning`, `danger`. Unknown values normalise to `info`. | +| `footer_tagline` | `""` | Footer tagline line. | +| `footer_copyright_owner` | `""` | Name in the copyright line. | +| `footer_note` | `""` | Small footer caption. | +| `footer_columns` | `""` | JSON blob of `FooterColumn` entries (see [Configurable footer](#configurable-footer)). | +| `footer_social_links` | `""` | JSON blob of `FooterLink` entries. | + +`app_name` rejects control characters, not just blanks: the name is used in HTML titles and — critically — email `Subject` headers, where an embedded CR/LF would survive a bare `strip()` and then raise, breaking every transactional email. + +The two footer lists are strings because the settings store holds strings; `branding.footer` owns (de)serialising them, leniently, so a malformed row degrades to "no configured footer" rather than an error. Each blob is capped at 8 000 characters so a single setting row can't grow unbounded. ### Image guard-rails Enforced in the API before the upload reaches `file_storage`: - **Max size:** 2 MB (`413` otherwise). -- **Allowed types:** `image/png`, `image/jpeg`, `image/svg+xml`, `image/webp`, `image/gif`, `image/x-icon` / `image/vnd.microsoft.icon` (`415` otherwise). +- **Allowed types:** `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/x-icon` / `image/vnd.microsoft.icon` (`415` otherwise). +- **Magic-number check:** the first bytes must match the signature of *one of* the allowed formats (`415` otherwise). + +The declared `Content-Type` on a multipart part is chosen by the caller, so it is a claim rather than a fact — `payload.html` renamed `logo.png` would otherwise be stored and later served back under an `image/*` type. The signature check is what rules that out. + +Note the exact property: the bytes must look like **some** allowed image format, not like the one the caller declared. A genuine PNG uploaded as `image/jpeg` passes and is stored as `image/jpeg`. That mismatch is harmless here — every allowed format is a raster or icon the browser renders inertly — and the check still does the job it exists for, which is keeping non-images out of the store. + +**SVG is excluded on purpose.** It is an XML document that can carry `