0012 — White-label Faseamento (path → subdomínio → custom domain)
Status
Accepted · 2026-04-26 · Autor: Alexandre + Claude.
Contexto
Multi-tenant moderno exige white-label pra consultor sentir “minha plataforma”. Mas custo cresce conforme nível:
- Path-based (
/consultor/<slug>): zero custo - Subdomínio (
<slug>.clinicgestor.com): wildcard SSL ($19/mês Netlify Pro) - Custom domain (
<slug>.com.br): SSL por domínio ($$ ou Cloudflare for SaaS)
Decisão: faseamento progressivo, não tudo de uma vez.
Decisão
1. F1 — Path-based (Sprint 4.5, sem custo extra)
Hostnames:
| Hostname / Path | Conteúdo | Branding |
|---|---|---|
clinicgestor.com | Landing/marketing (futuro F2) | Plataforma |
clinicgestor.com/consultores | Marketplace público SEO | Plataforma |
clinicgestor.com/consultores/<slug> | Perfil público do consultor + pacotes | Consultor |
app.clinicgestor.com | App principal (login universal) | Plataforma |
app.clinicgestor.com/consultor/<slug>/login | Login estilizado pelo consultor | Consultor |
app.clinicgestor.com/clinica/<slug>/login | Login estilizado pela clínica | Clínica |
app.clinicgestor.com/dashboard | Dashboard clínica | Consultoria dela |
app.clinicgestor.com/consultancy/dashboard | Dashboard consultor | Consultor |
app.clinicgestor.com/platform/dashboard | Dashboard platform_admin | Plataforma |
Resolver:
async function resolveBranding(hostname: string, pathname: string) { // F1: Path-based const pathMatch = pathname.match(/^\/(consultor|clinica)\/([^/]+)/); if (pathMatch) { const tenantType = pathMatch[1]; // 'consultor' | 'clinica' const slug = pathMatch[2]; const tenant = await lookupTenantBySlug(tenantType, slug); if (tenant) return tenantBranding(tenant); }
// F1: Marketplace tem branding por consultor em /consultores/<slug> const marketplaceMatch = pathname.match(/^\/consultores\/([^/]+)/); if (marketplaceMatch) { const tenant = await lookupTenantBySlug('consultor', marketplaceMatch[1]); if (tenant) return tenantBranding(tenant); }
// F1: Dashboard de clínica logada usa branding da consultoria dela if (pathname.startsWith('/dashboard') || pathname.startsWith('/clinic')) { const consultancyId = await getCurrentClinicConsultancy(); if (consultancyId) return consultancyBranding(consultancyId); }
// F1: Default — branding plataforma return platformBranding;}Implementação: <BrandingProvider> em apps/web/src/main.tsx envolve toda app. Resolve no useEffect inicial e injeta CSS custom properties.
2. F1.5 — Subdomínios via Netlify Pro (Sprint 10)
Adiciona:
| Hostname | Conteúdo | Branding |
|---|---|---|
<consultor-slug>.clinicgestor.com | Subdomínio do consultor | Consultor |
<clinica-slug>.clinicgestor.com | Subdomínio da clínica | Clínica |
Implementação:
- Netlify Pro ($19/mês) — wildcard SSL
*.clinicgestor.comautomático - Cloudflare DNS: CNAME wildcard
*.clinicgestor.com→<site>.netlify.app - Tabela
tenant_hostname_resolver:
CREATE TABLE public.tenant_hostname_resolver ( hostname TEXT PRIMARY KEY, hostname_kind TEXT NOT NULL CHECK (hostname_kind IN ('subdomain', 'custom_domain')), tenant_type TEXT NOT NULL CHECK (tenant_type IN ('consultancy', 'clinic')), tenant_id UUID NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW());CREATE INDEX tenant_hostname_resolver_tenant_idx ON public.tenant_hostname_resolver (tenant_type, tenant_id);-
Edge function
provision-subdomainroda quando consultor/clínica ativasubdomain_active = true:- Adiciona alias
<slug>.clinicgestor.comno Netlify via API - Aguarda SSL provisioning
- Insere em
tenant_hostname_resolver - Status:
active
- Adiciona alias
-
Resolver atualizado (Branding):
async function resolveBranding(hostname: string, pathname: string) { // F1.5: Subdomínio const subdomainMatch = parseSubdomain(hostname); // 'karla' de 'karla.clinicgestor.com' if (subdomainMatch) { const tenant = await lookupHostnameInResolver(`${subdomainMatch}.clinicgestor.com`); if (tenant) return tenantBranding(tenant); }
// F1: Path-based (mantido como fallback) // ...}3. F2 — Custom domains (Sprint 13)
Adiciona:
| Hostname | Conteúdo | Branding |
|---|---|---|
<consultor>.com.br (custom) | Domínio próprio do consultor | Consultor |
<clinica>.com.br (custom) | Domínio próprio da clínica (Enterprise) | Clínica |
Implementação:
-
Consultor compra domínio próprio (Hostgator, Registro.br, etc)
-
Em
/consultancy/configuracoes/custom-domain, consultor adiciona hostname -
Edge function
validate-custom-domain:- Gera token de validação (TXT DNS)
- Consultor configura TXT no DNS dele apontando pro token
- Plataforma valida via
digou similar - Após validação, consultor configura CNAME apontando pra
cname.clinicgestor.app(ou hostname público da plataforma) - Netlify aceita alias (até 100 grátis no Pro; depois Enterprise)
- SSL provisionado automaticamente
- Insere em
tenant_hostname_resolvercomhostname_kind = 'custom_domain'
-
Limite Netlify: até 100 aliases no Pro. Quando aproximar do limite, decisão (decisão pendente em F2):
- Migrar pra Cloudflare for SaaS (free pra mais de 100 hostnames; depois pago em escala)
- Upgrade Netlify Enterprise (preço sob consulta)
- Continuar Pro até limite (e depois decidir)
-
Email DKIM/SPF por consultor (F2.5+): emails saem de
noreply@<consultor>.com.brem vez denoreply@clinicgestor.com. Resend permite múltiplos domínios verificados; cada consultor configura DKIM/SPF/DMARC no DNS dele. Custo adicional por domínio Resend.
4. BrandingProvider (implementação canônica)
import { useState, useEffect, createContext, useContext } from 'react';
interface Branding { tenantType: 'platform' | 'consultancy' | 'clinic'; tenantId: string | null; primaryColor: string; accentColor: string; logoUrl: string;}
const BrandingContext = createContext<Branding>(/* platform default */);
export function BrandingProvider({ children }: { children: React.ReactNode }) { const [branding, setBranding] = useState<Branding>(platformDefault);
useEffect(() => { resolveBranding(window.location.hostname, window.location.pathname).then(b => { setBranding(b); // Inject CSS custom properties document.documentElement.style.setProperty('--color-primary', hexToHsl(b.primaryColor)); document.documentElement.style.setProperty('--color-accent', hexToHsl(b.accentColor)); document.documentElement.style.setProperty('--logo-url', `url(${b.logoUrl})`); }); }, []);
return <BrandingContext.Provider value={branding}>{children}</BrandingContext.Provider>;}
export const useBranding = () => useContext(BrandingContext);5. Customização (regra ADR 0003)
- Logo (SVG ≤ 320×80 px ou PNG transparente, max 1MB)
- Primary color (hex)
- Accent color (hex)
- Fonte: fixa Inter — proibido customizar (regra inviolável §5.8.3)
Cores derivadas (success, danger, surface, text) calculadas algoritmicamente com WCAG AA garantia.
Consequências
Positivas
- F1 sem custo extra ($0)
- F1.5 com Netlify Pro ($19/mês — diferencial enorme por baixo custo)
- F2 escalável via Netlify aliases (até 100) ou Cloudflare for SaaS
- Branding em runtime sem rebuild
- Vendável: “Sua URL própria desde o dia 1” (path em F1; subdomínio em F1.5)
Negativas
- 3 fases técnicas: cada uma exige migração de schema (mas dormente em F1)
- Limite Netlify Pro: 100 aliases — atinge em F2 com escala
Riscos
- Netlify mudar pricing/limite (atualmente 100 alias no Pro)
- Mitigação: monitorar; ter Cloudflare for SaaS como plano B documentado
- DNS errado durante validação F2 expõe URL pra DDOS direto na plataforma
- Mitigação: Cloudflare como proxy (DDOS mitigation gratuita)
Alternativas consideradas
1. Custom domain desde F1
- Prós: white-label completo no MVP
- Contras: complexidade técnica + custo Cloudflare for SaaS pra começar; consultor com 1 cliente não justifica
- Rejeitado — F1 path-based é suficiente comercialmente.
2. Sem subdomínios (pular F1.5)
- Prós: simplicidade
- Contras: subdomínio é diferencial percebido enorme (“sou dono”); pular afeta atratividade pra consultor
- Rejeitado.
3. Cloudflare Workers ao invés de Netlify Pro
- Prós: free tier generoso
- Contras: complexidade adicional; Netlify Pro é solução nativa simples
- Rejeitado pra F1.5; reavaliar em F2 se escala demandar.
Atualizações de documentos
- AGENTS.md §inventário (white-label F1, F1.5, F2)
- BLUEPRINT.md §6 (Branding hierárquico)
- ADR 0003 (referência cruzada)
06-frontend/branding.md11-configuracoes-externas/dns-strategy.md11-configuracoes-externas/netlify.md
Referências
- ADR 0003 (design system multi-tenant)
- ADR 0001 (multi-consultancy)
- AGENTS.md §5.8