Skip to content

feat(branding): resolve branding per tenant over the system theme (#373) [stack 11/11] - #395

Merged
antosubash merged 19 commits into
tenancy/10-settingsfrom
tenancy/11-branding
Oct 1, 2026
Merged

antosubash merged 19 commits into
tenancy/10-settingsfrom
tenancy/11-branding

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Closes #373. Stack 11/11, the top of the tenancy-adoption series (base: #394).

What

  • Tenant-overridable branding. A tenant can override app_name, primary_color, design_pack, footer_text, the logo, the dark logo and the favicon. Writes go through the same validators as system branding.
    • Image ids must be live files owned by the tenant being written. Any other id returns 404, even when a platform operator sets it.
    • The banner and footer links stay platform-only.
  • Per-request resolution (tenant_branding.py):
    • With multi_tenant off, or no tenant on the request, it returns the system theme as before, with no lookup.
    • Otherwise the tenant's overrides are merged over the system theme. The result is cached per tenant with a 30-second TTL, cleared forget-only on settings.values notices, with an epoch guard against races.
    • An invalid stored row falls back to the system value.
  • Framework: shared-prop providers may now be async. branding_head prefers the per-request theme, so the <title> and favicon match before hydration.
  • Anonymous asset routes:
    • The tenant comes from request.state.tenant_id (the subdomain resolver), otherwise the system.
    • A tenant value is served with tenant_context, and a system value with platform files.
    • The file id never comes from the request.
    • Tenant images are sent Cache-Control: private, so a shared cache can't serve one tenant's logo to another.
  • Tenant uploads: /api/branding/tenant/{logo,logo-dark,favicon}, guarded by settings.tenant.edit, for the active tenant only. The files are tenant-owned, and the old file is cleaned up on replace. The organisation settings page shows an upload control for these keys.

Tests

  • 24 new branding tests, covering:
    • two tenants getting different props
    • fallback to the system theme
    • invalidation within the worker and across the wire
    • multi_tenant off is unchanged
    • logo upload and serve
    • an anonymous subdomain visitor gets that tenant's logo
    • a file from another tenant or the platform is refused
  • Framework tests for async providers and for branding_head.
  • make test-py: 3470 passed. make test-js: 466 passed. make lint and make doctor are clean.

Follow-ups

  • Image cleanup happens only through /api/branding/tenant/*, not through the generic settings route.
  • No tenant editing of footer links or the banner.
  • A system-branding hot-swap still needs a restart on other workers. That limitation existed before this change.

https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV

- Tenants override app_name, primary_color, design_pack, footer_text and
  the three images as tenant_overridable settings keys (TENANT scope);
  banner and footer links stay platform-only. Every tenant-scope write is
  checked: same validators as the system value (422), and an image id must
  be a live file owned by the tenant being written (404).
- branding.tenant_branding resolves per request: system object as is when
  multi_tenant is off or no tenant is bound (no lookup), else the tenant's
  overrides merged on top, in a tenant-keyed 30s TTL cache that forgets on
  settings.values invalidation notices (all tenants on a system change).
- Shared props provider is async and leaves the tenant's theme on
  request.state.branding for the pre-hydration <head>; the framework now
  awaits async shared-prop providers.
- Anonymous asset routes resolve the tenant (subdomain or active org) and
  serve a tenant-set image only as that tenant's own file, system values
  as platform files; tenant images are Cache-Control private and immutable
  only when ?v= names the file served.
- POST/DELETE /api/branding/tenant/{logo,logo-dark,favicon} upload tenant-
  owned files (no platform=True) and reap the replaced one in the tenant's
  scope; the organisation settings page offers uploads for file-id keys
  (SettingDefinition.upload_url).

Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7d4731b
Status: ✅  Deploy successful!
Preview URL: https://ec6d1658.simple-module-python.pages.dev
Branch Preview URL: https://tenancy-11-branding.simple-module-python.pages.dev

View logs

# Conflicts:
#	docs/modules/file_storage.md
…review of #373)

(a)+(b) Image keys are refused (422) on every generic settings route;
    images are set/cleared only via /api/branding/tenant/{asset}, which
    validates the bytes. The asset route also refuses to serve a file
    outside the image allow-list (hand-edited rows).
(b) Replaced images (tenant and system) are reaped after commit via
    register_on_commit (branding.reaper), in their own session, and only
    when no other image field of the same owner references them - a
    rollback no longer strands the setting on deleted bytes.
(c) Asset Cache-Control: public+immutable only for a tenant-less request
    whose ?v= names the served file; tenant requests are private (Vary on
    Cookie and the tenant header); stale/missing ?v= is private, no-cache.
(d) The shared-props provider skips /api/, /static/ and non-HTML
    requests; per-tenant single-flight for concurrent cache misses, and a
    forget() detaches in-flight reads.
(e) Definitions send description_key (branding.tenant_settings.*), which
    TenantSettingRow translates with the English description as fallback.

Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV
# Conflicts:
#	modules/settings/settings/contracts/registry.py
…che, system saves publish, image keys clear_via (ship review)

- a tenant logo without a dark logo no longer shows the platform dark logo
- an empty-string override inherits the platform value (clear = delete)
- the tenant cache, in-flight reads and epoch live on app.state.branding
- the system theme save passes the invalidation bus so other workers forget
- image keys declare clear_via so settings' generic deletes refuse them

Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV
# Conflicts:
#	modules/tenants/tenants/components/TenantSettingRow.tsx
# Conflicts:
#	modules/settings/settings/contracts/registry.py
@antosubash
antosubash added this pull request to stack #397 October 1, 2026 16:14
@antosubash
antosubash marked this pull request as ready for review October 1, 2026 16:17
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-10-01T16:23:30.917477Z d967b06 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@antosubash
antosubash merged commit 97e0ad1 into main Oct 1, 2026
17 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

branding: no table to add a mixin to — it's a single process-global settings singleton that must become per-tenant lookup

1 participant