Skip to content

Commit 7dff219

Browse files
authored
docs: PRD 055 - Tenant branding display name propagation (#1953)
* docs: add PRD 055 for tenant branding display name propagation Defines the approach for showing tenant org names instead of "Meridian" on login pages and in the header post-login. Covers backend context propagation, JWT claim additions, a public tenant-info endpoint, and frontend consumption. * docs: address review feedback on PRD 055 - Add staleness policy for JWT display name claim (accept stale until token refresh, tenant renames are rare) - Add abuse protections for public /api/tenant-info endpoint (rate limiting, cache headers, uniform 404 responses, minimal response surface) * docs: add PRD 055 to README index * docs: address review feedback and add PRD to README index - Clarify /api/tenant-info resolves from subdomain (Host header), not query params - prevents tenant enumeration - Add cache consideration note for slugCache extension - Add PRD 055 to README index under Not Started and Operations * docs: clarify deprovisioned tenant behavior and slug formatting - Suspended tenants return display name, deprovisioned return 404 - Define formatted slug transformation explicitly --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 4e65696 commit 7dff219

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

docs/prd/055-tenant-branding.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Tenant Branding: Display Name Propagation
2+
3+
## Problem Statement
4+
5+
When a tenant user visits their subdomain (e.g.,
6+
`volterra-energy.demo.meridianhub.cloud/login`), the UI shows
7+
"Meridian" everywhere - on the login page, in the header after
8+
login, and in the page title. There is no indication of which
9+
tenant organization the user is accessing.
10+
11+
This matters because:
12+
13+
1. **Trust** - Users landing on their org's subdomain expect to
14+
see their org name. Seeing "Meridian" is confusing and erodes
15+
confidence that they're in the right place.
16+
2. **Multi-tenant clarity** - Platform admins switching between
17+
tenants need clear visual feedback about which tenant context
18+
they're operating in.
19+
3. **Demo credibility** - During demos, showing the prospect's
20+
org name instead of "Meridian" makes the product feel real.
21+
22+
## Technical Context
23+
24+
### What Exists
25+
26+
| Component | Current State |
27+
|-----------|--------------|
28+
| Tenant `DisplayName` field | Stored in DB, exposed in proto, set during tenant creation |
29+
| Tenant slug | Extracted from subdomain by `TenantResolverMiddleware`, injected into context |
30+
| JWT claims | Include `x-tenant-slug` and `x-tenant-id`, but NOT display name |
31+
| Frontend tenant context | Has `tenantSlug` and `currentTenant` (platform admins only) |
32+
| Header component | Hardcoded "Meridian" text |
33+
| Login page | Hardcoded "Meridian Operations Console" title |
34+
| `<title>` tag | Hardcoded "Meridian Operations Console" in index.html |
35+
36+
### Architecture
37+
38+
The tenant resolver (`shared/platform/gateway/tenant_resolver.go`)
39+
already loads the full `domain.Tenant` entity (including `DisplayName`)
40+
when resolving slug to tenant ID. It discards the display name after
41+
resolution, only injecting `tenantID` and `slug` into the request
42+
context.
43+
44+
JWT minting happens in two places:
45+
- `services/api-gateway/auth_handler.go` - password-based login
46+
- `services/api-gateway/auth_sso_handler.go` - SSO/OIDC callback
47+
48+
Both have access to the tenant context at the point where claims
49+
are constructed.
50+
51+
The login page is pre-authentication - no JWT exists yet. The only
52+
tenant signal available is the subdomain slug. To show the real
53+
display name pre-login, we need a public (unauthenticated) endpoint.
54+
55+
## Solution
56+
57+
### 1. Propagate display name through tenant context (backend)
58+
59+
Extend the tenant context package to carry `DisplayName` alongside
60+
`TenantID` and `Slug`:
61+
62+
- Add `WithDisplayName()` / `DisplayNameFromContext()` to
63+
`shared/platform/tenant/context.go`
64+
- Update `TenantResolverMiddleware` to inject display name into
65+
context after resolving the tenant entity
66+
- This makes display name available to all downstream handlers
67+
68+
**Cache consideration:** The current `slugCache` only stores
69+
`TenantID`. On cache hits (common path), the full entity is not
70+
loaded. The resolver should extend the cache to store display name
71+
alongside tenant ID, or fall back to a DB query when display name
72+
is needed. Either approach is acceptable - the display name lookup
73+
is low-frequency (once per login, once per page load) and the
74+
tenant table is small.
75+
76+
### 2. Add display name to JWT claims (backend)
77+
78+
In both auth handlers (`auth_handler.go` and `auth_sso_handler.go`),
79+
read the display name from context and add it as a JWT claim
80+
(`x-tenant-display-name`). This propagates the real name to the
81+
frontend for the entire session without any additional API calls.
82+
83+
**Staleness policy:** The display name in the JWT may become stale
84+
if a tenant is renamed mid-session. This is acceptable - tenant
85+
renames are rare administrative operations. The frontend will pick
86+
up the updated name on next token refresh (session expiry or
87+
explicit re-login). No token invalidation or forced refresh is
88+
needed for this claim.
89+
90+
### 3. Public tenant info endpoint (backend)
91+
92+
Add `GET /api/tenant-info` to the gateway as a public endpoint
93+
(no auth required, like `/api/auth/providers`). The endpoint:
94+
95+
- Uses the tenant resolver to identify the tenant from the subdomain
96+
- Returns `{ slug, displayName }` as JSON
97+
- Returns 404 if no valid tenant subdomain is present
98+
- This serves the login page where no JWT exists yet
99+
100+
**Abuse protections:**
101+
- The endpoint resolves tenant from the request's subdomain (Host
102+
header), not from a query parameter. There is no lookup-by-slug
103+
input - a caller must already be on the tenant's subdomain to
104+
get a response. This prevents enumeration: you cannot discover
105+
tenants you don't already have the subdomain for.
106+
- Rate limited per IP (reuse the gateway's existing rate limiter)
107+
- Positive responses cached via `Cache-Control: public, s-maxage=300`
108+
to reduce repeated lookups
109+
- Error responses return a uniform 404 with identical body and
110+
timing regardless of whether the subdomain is invalid, unknown,
111+
or absent - the existing tenant resolver already does this
112+
- Only slug and display name are returned - no tenant IDs, status,
113+
or internal metadata are exposed
114+
- Suspended tenants still return their display name (users need to
115+
know where they are). Deprovisioned tenants return 404.
116+
117+
### 4. Frontend: consume tenant display name (frontend)
118+
119+
**Login page:**
120+
- Call `/api/tenant-info` on mount when on a tenant subdomain
121+
- Show the tenant's display name as the page title
122+
- Fall back to formatted slug if the endpoint is unavailable
123+
- Update document title to match
124+
125+
**Header:**
126+
- For tenant users: read `x-tenant-display-name` from JWT claims
127+
- For platform admins: use `currentTenant.name` (already available
128+
from tenant selector)
129+
- Fall back to formatted slug (title-case, hyphens to spaces:
130+
`volterra-energy` becomes `Volterra Energy`), then to "Meridian"
131+
132+
**Document title:**
133+
- Set `document.title` dynamically based on tenant context
134+
- Pattern: `"{Tenant Name} - Operations Console"` on tenant
135+
subdomains, `"Meridian Operations Console"` on bare domain
136+
137+
## Non-Goals
138+
139+
- Tenant logos or custom color themes (future work, separate PRD)
140+
- Custom favicon per tenant
141+
- Tenant-specific email templates
142+
- Whitelabeling (removing Meridian branding entirely)
143+
144+
## Success Criteria
145+
146+
1. Visiting `volterra-energy.demo.meridianhub.cloud/login` shows
147+
"Volterra Energy" as the heading (not "Meridian")
148+
2. After login, the header shows "Volterra Energy" (not "Meridian")
149+
3. Browser tab shows "Volterra Energy - Operations Console"
150+
4. Platform admins see the selected tenant's name in the header,
151+
or "Meridian" when no tenant is selected
152+
5. On bare domain (`demo.meridianhub.cloud`), "Meridian" branding
153+
is preserved
154+
155+
## Complexity Estimate
156+
157+
**8 story points total** across 3-4 PRs:
158+
159+
| PR | Points | Description |
160+
|----|--------|-------------|
161+
| Backend context + JWT | 3 | Tenant context display name, JWT claim propagation |
162+
| Public endpoint | 2 | `/api/tenant-info` handler + tests |
163+
| Frontend consumption | 3 | Login page, header, document title, auth context changes |
164+
165+
Backend PRs can merge independently. Frontend PR depends on both
166+
backend PRs being deployed.

docs/prd/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ stateDiagram-v2
9797
| [Email Infrastructure MVP](052-email-platform.md) | Outbox, worker, Resend integration, invoice/dunning email delivery |
9898
| [Auth Email Flows](053-auth-email-flows.md) | Email verification, password reset, user invitations (depends on 052) |
9999
| [Billing UI](054-billing-ui.md) | Billing dashboard, invoice detail, email delivery status (depends on 052) |
100+
| [Tenant Branding](055-tenant-branding.md) | Display name propagation to login page, header, and document title |
100101

101102
### Task Master PRDs (`.taskmaster/docs/`)
102103

@@ -286,6 +287,8 @@ material.
286287
provider-agnostic Sender interface, per-tenant metering
287288
- [Billing UI](054-billing-ui.md) -
288289
Billing dashboard, invoice detail pages, email delivery status visibility
290+
- [Tenant Branding](055-tenant-branding.md) -
291+
Display name propagation to login page, header, and document title
289292

290293
### Identity & Access Control
291294

0 commit comments

Comments
 (0)