Pular para o conteúdo

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 / PathConteúdoBranding
clinicgestor.comLanding/marketing (futuro F2)Plataforma
clinicgestor.com/consultoresMarketplace público SEOPlataforma
clinicgestor.com/consultores/<slug>Perfil público do consultor + pacotesConsultor
app.clinicgestor.comApp principal (login universal)Plataforma
app.clinicgestor.com/consultor/<slug>/loginLogin estilizado pelo consultorConsultor
app.clinicgestor.com/clinica/<slug>/loginLogin estilizado pela clínicaClínica
app.clinicgestor.com/dashboardDashboard clínicaConsultoria dela
app.clinicgestor.com/consultancy/dashboardDashboard consultorConsultor
app.clinicgestor.com/platform/dashboardDashboard platform_adminPlataforma

Resolver:

apps/web/src/lib/branding-resolver.ts
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:

HostnameConteúdoBranding
<consultor-slug>.clinicgestor.comSubdomínio do consultorConsultor
<clinica-slug>.clinicgestor.comSubdomínio da clínicaClínica

Implementação:

  • Netlify Pro ($19/mês) — wildcard SSL *.clinicgestor.com automá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-subdomain roda quando consultor/clínica ativa subdomain_active = true:

    1. Adiciona alias <slug>.clinicgestor.com no Netlify via API
    2. Aguarda SSL provisioning
    3. Insere em tenant_hostname_resolver
    4. Status: active
  • 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:

HostnameConteúdoBranding
<consultor>.com.br (custom)Domínio próprio do consultorConsultor
<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:

    1. Gera token de validação (TXT DNS)
    2. Consultor configura TXT no DNS dele apontando pro token
    3. Plataforma valida via dig ou similar
    4. Após validação, consultor configura CNAME apontando pra cname.clinicgestor.app (ou hostname público da plataforma)
    5. Netlify aceita alias (até 100 grátis no Pro; depois Enterprise)
    6. SSL provisionado automaticamente
    7. Insere em tenant_hostname_resolver com hostname_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.br em vez de noreply@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)

apps/web/src/lib/branding-resolver.ts
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.md
  • 11-configuracoes-externas/dns-strategy.md
  • 11-configuracoes-externas/netlify.md

Referências

  • ADR 0003 (design system multi-tenant)
  • ADR 0001 (multi-consultancy)
  • AGENTS.md §5.8