branding
White-labels the application. An administrator sets the app name, logo (plus an optional dark-background variant), favicon, primary colour, design pack, and a site-wide announcement banner — from an admin page, with no code change or redeploy.
Values persist in the shared 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, show the banner and set the footer links everywhere.
ModuleMeta
| Field | Value |
|---|---|
name | Branding |
route_prefix | /api/branding |
view_prefix | /admin/branding |
depends_on | ["Settings", "FileStorage"] |
i18n_audience | "admin" |
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" and is kept out of the guest bundle — branding's public contribution rides the shared-props provider, not i18n keys.
Routes
API (admin)
Every JSON endpoint — including the reads — requires branding.manage; they back the admin editor.
| Method + path | Body / response |
|---|---|
GET /api/branding/ | → BrandingOut |
PUT /api/branding/ | BrandingUpdate → BrandingOut |
POST /api/branding/presets/{key} | → BrandingOut (404 for an unknown key) |
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. A design_pack slug that no installed module registered is rejected with 422 — accepting it would put "<slug>-root" on the document with no stylesheet behind it, so the site would look unchanged with nothing in the UI explaining why.
API (tenant)
A tenant's own images, for tenant owners and admins (settings.tenant.edit). The tenant is always the request's active one — never an id from the URL. See Per-tenant branding.
| Method + path | Body / response |
|---|---|
POST /api/branding/tenant/{logo,logo-dark,favicon} | multipart (field file) → the tenant's effective BrandingOut |
DELETE /api/branding/tenant/{logo,logo-dark,favicon} | → BrandingOut (the tenant falls back to the system image) |
Rules for tenant overrides:
- Clearing = deleting the override. An empty-string override is treated as unset: the tenant inherits the platform value (it never blanks it).
- Dark logo pairing. A tenant that overrides the logo but not the dark logo does not get the platform's dark logo; dark surfaces fall back to the tenant's own logo.
- Images are cleared only here.
SettingService.delete/delete_scopedrefuse an image key at every scope (SYSTEM, TENANT and USER rows alike) and for every caller that holds a service built through settings' dependency: the generic settings deletes (DELETE /api/settings/tenant/current/{key}, the platformtenant/{scope_id}/{key}and by-id routes, the admin store screen) answer422pointing atDELETE /api/branding/tenant/{asset}, because only this route reaps the stored file. This route deletes withas_owner=True. A platform operator can still delete the leftover TENANT row of a tenant that no longer exists. A service constructed without the settings registry (SettingService(db), e.g. branding's own system-scope writes) does not enforce the guard. - The cache is per app (
app.state.branding.tenant_cache), and system theme saves publishsettings.valuesso other workers drop their merged tenant entries.
Uploads are validated before the bytes reach file_storage: an unsupported or unconvincing type returns 415, an oversized image 413 (see Image guard-rails).
Public assets (anonymous)
Registered through the register_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 <link rel="icon"> 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.
The system images are platform files (platform=True in file_storage): uploaded as the install rather than as the admin's active organisation, and served by a lookup that only ever matches platform-owned rows. Images uploaded before file_storage adopted tenancy were back-filled into the platform owner and keep working.
Which image a request gets follows its tenant — the subdomain for an anonymous visitor (tenants' subdomain_base), the active organisation for a member — else the system's. A value the tenant set is read as the tenant's own file (under tenant_context(tenant)); a system value as a platform file. Either way a setting pointed at anyone else's upload 404s instead of publishing it.
Responses carry Content-Disposition: attachment and X-Content-Type-Options: nosniff. Both are ignored for subresource loads (<img>, <link rel="icon">) 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
| Method + path | Inertia component | Permission |
|---|---|---|
GET /admin/branding/ | Branding/Manage | branding.view |
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=<file id>. 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.
The same URL answers per tenant (session, tenant header or subdomain), and a shared cache keys on the URL alone — so only a URL that pins the bytes by itself may be shared:
| Request | Cache-Control |
|---|---|
No tenant on the request, ?v= naming the file served | public, max-age=31536000, immutable (one year) |
A tenant on the request, ?v= naming the file served | private, max-age=31536000, immutable, Vary: Cookie (+ the tenant header when one is configured) |
No ?v=, or one naming another file | private, no-cache |
A tenant request is private even for the platform's image, so a shared cache never answers the tenant-less URL with it (or the reverse). An unversioned URL — or a ?v= left over from another tenant's page, or from before a replace — can serve other bytes later, so it is never pinned: no-cache revalidates on the next page, which may be in another organisation. A 404 is never cached, so the next request retries once the setting is fixed.
The route only ever serves a file whose type is on the image allow-list: a setting pointed at anything else (a hand-edited row) answers 404.
Public contracts
from branding.contracts import BrandingOut, BrandingUpdate| Class | Purpose |
|---|---|
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. |
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. 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
DB-backed via register_module_settings; pydantic defaults seed at boot. Edited from the dedicated Branding admin page (/branding) rather than the generic settings UI.
| Field | Default | Purpose |
|---|---|---|
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. |
design_pack | "" | Slug of a registered design pack; "" ⇒ 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_links | [] | Up to 6 {label, href} links shown in the site footer; [] ⇒ show the framework's own links. |
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.
Image guard-rails
Enforced in the API before the upload reaches file_storage:
- Max size: 2 MB (
413otherwise). - Allowed types:
image/png,image/jpeg,image/webp,image/gif,image/x-icon/image/vnd.microsoft.icon(415otherwise). - Magic-number check: the first bytes must match the signature of one of the allowed formats (
415otherwise).
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 <script>, so serving one back from the app's own origin would be stored XSS. The attachment + nosniff headers on the asset route defend in depth, but the narrower allow-list is what actually keeps executable markup out of the store.
Asset lifecycle
Replacing or clearing an image deletes the file it stopped referencing, so repeated logo tweaks don't leave orphans in file_storage — system and tenant images alike (branding.reaper):
- After commit. The delete is queued with
register_on_commitand runs, in a session of its own, once the settings write is durable. Deleting in the request removed the bytes before that: a late rollback left the setting pointing at a file that was gone. - Only when unreferenced. A file another image field of the same owner still holds — the logo and the dark logo sharing one upload — is kept; the check runs again at reap time.
- Best effort. The rebrand already succeeded, so a storage fault is logged and leaves an orphan, never a broken setting.
Presets
A preset is a named one-click look, applied through the ordinary update path so every validator still runs:
POST /api/branding/presets/oceanSeven ship with the module — emerald, ocean, indigo, violet, amber, rose, slate — each setting a primary_color.
A preset carries appearance, never identity. PRESET_FIELDS restricts a preset to primary_color and design_pack; BrandingPreset rejects any other field at construction. The app name, the uploaded images and a live banner are deployment identity or operational state, and survive applying a preset — a preset that overwrote a logo an admin had just uploaded would destroy exactly the work the branding page exists to do.
A preset's design_pack runs the same registration check a manual update gets, rather than trusting the built-in list.
Presets are not an extension point — the list is fixed in the module. A module that wants to contribute its own look ships a design pack instead, which is the registry-backed, module-contributed mechanism.
Announcement banner
A message plus a severity, rendered above every shell — app, public and auth — because an outage notice is most useful to the people who cannot sign in. An empty message hides it entirely.
Severity colours are semantic, not brand-tinted: a warning wearing the deployment's accent colour stops reading as a warning.
Footer links
The footer renders on every page — the app shell and the public site — so its links are outward-facing attribution on a deployed product. Setting footer_links replaces the framework's own Docs / Changelog / GitHub row, which points at antosubash/simple_module_python.
Leaving the list empty keeps those framework links, so a deployment that never touches this looks exactly as it did. Clearing the list back to empty is how you return to them.
Each href must be http://, https://, mailto: or a site-relative path beginning with a single /. That is an allow-list rather than tidiness: the value is rendered straight into an <a href> on every page, signed-in or not, so javascript: and data: would make the branding screen a stored-XSS sink for anyone holding branding.manage. Scheme-relative //host is rejected too — it reads as a relative path but navigates off-site.
Labels are trimmed, non-blank, ≤ 40 characters and reject control characters, on the same reasoning as app_name.
On the frontend, BrandingFooter takes an optional links prop and falls back to BRAND_FOOTER_LINKS when it is absent, null or empty; SidebarLayout and PublicLayout pass the shared prop through, so a host gets the override without forking either layout.
Dark-background logo
The sidebar and mobile bar sit on a near-black surface in every theme, while the sign-in card and public page are light — so a single logo cannot read on both. Uploading a Logo (dark backgrounds) variant swaps it in on those surfaces only.
It is optional: with none set the shared prop reports logoDarkUrl: null and the frontend falls back to logoUrl, so single-logo deployments look exactly as they did. On the frontend, use darkSurfaceLogo(branding) from @simple-module-py/ui/lib/brand, which applies that fallback in one place.
How branding reaches the frontend
On startup the module registers a shared-props provider (register_inertia_shared_provider). On every Inertia render — guest pages included — it emits a branding block built from the live module settings:
{
"branding": {
"appName": "Acme Corp",
"primaryColor": "#1d4ed8",
"designPack": "gca",
"logoUrl": "/api/branding/logo?v=<file id>",
"logoDarkUrl": "/api/branding/logo-dark?v=<file id>",
"faviconUrl": "/api/branding/favicon?v=<file id>",
"banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" },
"footerLinks": [{ "label": "Handbook", "href": "https://acme.example.org/handbook" }]
}
}primaryColor and designPack are null when unset; the three image URLs are null when no file is configured. banner is null when no message is set, so the frontend renders nothing at all rather than an empty bar. footerLinks is null when none are configured, which is what tells the frontend to keep the framework's own links rather than render an empty row.
The provider is defensive — it returns {} if branding state isn't mounted yet, so a half-booted app never errors a render. Because changes go through the settings store, a save hot-reloads app.state.branding.settings; the next render reflects the new values without a restart.
Per-tenant branding
With multi_tenant on, a tenant can override part of the theme for itself (#373): app_name, primary_color, design_pack, footer_text and the three images. Each is a settings key (branding.<field>) declared tenant_overridable, stored at settings' TENANT scope, and edited by tenant owners/admins on the organisation settings page (/tenants/settings) — scalars through /api/settings/tenant/current/{key}, images through /api/branding/tenant/{asset}. The banner and footer links stay platform-only: the banner is how the platform announces maintenance, and a tenant must not be able to silence it.
Every tenant-scope write is checked first, by the same validators as the system value (422). The three image keys are refused on every generic settings route (422 — the self-service route, the platform routes and the admin forms alike): an image is set and cleared only through /api/branding/tenant/{asset}, which validates the bytes as an image, stores them as the tenant's own file and reaps the one it replaced. A generic write could do none of that — it could point the logo at any file the tenant owns (a PDF) and would never reap what it displaced. (A generic DELETE of an image key is not checked; it drops the override and leaves the file behind for a janitor rather than reaping it.)
The organisation settings page shows each key's description; definitions also send a description_key (branding.tenant_settings.<field>) that the page translates, with the English text as fallback. Server-side error details (validator messages) are still shown as sent — they come from pydantic and are not keyed.
Reads resolve per request (branding.tenant_branding.resolve): the request's tenant overrides on top of the system theme, falling back field by field. The provider leaves the merged object on request.state.branding, which the root template's pre-hydration <head> prefers. A hand-edited invalid override degrades to the system value for that field.
Cost. The shared-props provider only resolves for requests that can render a page — nothing under /api/ or /static/, and only Inertia visits or requests accepting HTML. With multi_tenant off, or no tenant on the request, the system object is used as is — no lookup. A tenant's overrides are read at most once per 30 s per process (a TTL cache keyed by tenant id, with concurrent misses for one tenant sharing a single read); the cache subscribes to settings' settings.values invalidation channel and forgets a tenant when one of its branding.* keys changes — every tenant when a system one does — so edits show at once, in every worker once a transport is installed.
Permissions
| Code | Granted to | Purpose |
|---|---|---|
branding.view | admin | open the Branding admin page (/branding) |
branding.manage | admin | read + write branding via the API (name, colour, design pack, banner, image upload/clear) |
Menu
| Label | URL | Icon | Section | Group | Order | Roles |
|---|---|---|---|---|---|---|
Branding | /admin/branding/ | palette | ADMIN_SIDEBAR | Appearance | 105 | ["admin"] |
Inertia pages
Branding/Manage.tsx— the admin editor: app name, colour and design pack, preset picker, logo / dark logo / favicon upload and clear, banner editor, and a live preview.
Locales
branding/locales/en.json — namespace branding, top-level key manage (the admin page strings).
Notes
- Branding has no table: the system identity is SYSTEM-scope settings, a tenant's overrides are TENANT-scope settings (see Per-tenant branding).
- The primary colour overrides the
--primary/--sidebar-primaryCSS variables from a single hex; the full OKLCH colour scale is not regenerated.