feat(marca): favicon proprio, barra do navegador sem hex duplicado, e-mails de auth com a marca

FAVICON. /favicon.ico devolvia 404 — para nos e para o revendedor. A aba nao
tinha marca nenhuma. Agora app/icon.tsx gera em runtime com next/og (zero
dependencia nova, force-dynamic), e letraDoIcone pula emoji, aceita acento e
devolve null em vez de cair na NOSSA inicial. /^\/icon$/ entra em PUBLIC_PATHS.

Sem apple-icon: o plano pedia a regex, mas allowlist para rota inexistente e
config morta.

BARRA DO NAVEGADOR — o defeito nao era o que a medicao anterior dizia. themeColor
NAO e defeito de whitelabel: cssDaMarca emite so --color-brand e a familia do
accent, NUNCA --color-bg, entao #faf9f6/#161510 sao a cor certa para qualquer
marca, e generateViewport() lendo o banco seria uma leitura por request para
devolver sempre o mesmo valor. O defeito real era DUPLICACAO: os dois hexes
viviam em app/layout.tsx, regua-do-produto.ts e globals.css sem nada
sincronizando. Agora sai da regua.

Provado por comportamento, nao por leitura: com app_name='Vendas Turbo' e
accent_hex='#f2c94c' gravados, o icone virou V sobre #6e5c28 (o accent DERIVADO,
nao a semente crua) e o titulo mudou — E o theme-color NAO mudou. E a correcao
acima, medida na tela.

E-MAILS DE AUTH. Os templates tinham a marca literal e NENHUM script os subia:
o primeiro e-mail que o cliente do revendedor recebe dizia o nome do nosso
produto. Agora hostgator-setup-kit/marca-emails.sh sobe pela Management API,
chamado pelo install.sh (sempre) e pelo update.sh (so com token). Custo para o
comprador: ZERO passo novo — com token sobe e confere; sem token, imprime o
passo manual e sai 0.

A MEDICAO QUE DESTRAVOU A DECISAO: PATCH /v1/projects/{ref}/config/auth com
mailer_templates_* e aceito e PERSISTE sem SMTP customizado. Rig num projeto
descartavel, com estado capturado antes e restaurado depois, releitura por GET
byte a byte.

E o achado que so aparece quando se mede: PROJETO PAUSADO responde 400 "Project
is paused." — modo de falha real que um script confiando no 2xx reportaria como
sucesso. Por isso o script RELE o que gravou antes de dizer que deu certo.

--render-em DIR existe porque sem ela eu estaria INTRODUZINDO um defeito:
docs/deploy-selfhost manda o GoTrue self-hosted apontar para supabase/templates/,
que agora carrega placeholder — o cliente receberia __APP_NAME__ literal.

RISCO DO TEMPLATE PADRAO, medido no mecanismo: o verificador PKCE herda as
cookieOptions da sessao, isto e sameSite:"strict", entao clique de webmail e
cross-site e o cookie nao viaja. A primeira versao da tela dizia "abra no mesmo
navegador" e foi REFEITA: cliente de e-mail nativo abre sem iniciador e o cookie
PODE ir — a tela aponta a configuracao como conserto em vez de prometer um
contorno que talvez nao funcione.

A PROSA DA 0155 corrigida: a rede de rollback pelo .env cobre NOME e LOGO; para
COR ela e vazia POR CONSTRUCAO, porque APP_ACCENT_HEX nasceu neste epico e
nenhuma versao anterior pinta accent. So comentario — a migration nao muda.

Sabotagem, previsto vs medido: 1/1, 2/2, 1/1, 2/2, (3+2)/(3+2), 1/1, 1/1, 2/2.
Oito sabotagens, zero divergencia. Restauracao por cp do backup (nunca git
checkout, que levaria trabalho nao commitado), com diff -q confirmando.

typecheck 0 · lint 0 · lint:channels 0 · test:unit 389 files / 4426 passed ·
test:shell 265 ✓ / 0 ✗ · build 0 com /icon dinamico.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCiffR3ugjQbxsfceQE2GB
This commit is contained in:
Rafael Melgaço
2026-08-14 00:56:37 -03:00
co-authored by Claude Opus 5
parent a6acb9f1ca
commit 71616d783e
21 changed files with 1370 additions and 40 deletions
+11
View File
@@ -34,6 +34,17 @@ export default async function LoginPage({
cadastro.
</div>
)}
{error === "template_padrao" && (
<div
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
Este link veio do modelo de e-mail padrão do Supabase, que não fecha o
acesso nesta instalação — pedir outro link não resolve. Quem administra
o sistema precisa configurar os e-mails de acesso (
<code>marca-emails.sh</code>, no kit de instalação).
</div>
)}
{error === "provisionamento" && (
<div
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
+43 -7
View File
@@ -13,16 +13,40 @@ import { env } from "@/lib/env";
* redefinição de senha. Dois formatos de link chegam aqui, dependendo de como
* o projeto Supabase está configurado:
*
* - `token_hash` + `type`: template de e-mail customizado (supabase/templates/)
* linkando direto pro app — exige SMTP customizado configurado no painel
* (sem isso o Supabase não deixa editar o corpo do e-mail).
* - `code` (PKCE): template PADRÃO do Supabase (nenhum SMTP customizado
* configurado — caso mais comum em instalação fresca). O e-mail linka pro
* - `token_hash` + `type`: template de e-mail customizado (supabase/templates/,
* subidos por `hostgator-setup-kit/marca-emails.sh`) linkando direto pro app.
* NÃO exige SMTP customizado — a versão anterior deste comentário afirmava
* que sim ("sem isso o Supabase não deixa editar o corpo do e-mail") e isso
* foi MEDIDO como falso em 2026-08-14: `GET /v1/projects/{ref}/config/auth`
* do projeto de produção devolve `smtp_host: null` COM os templates
* customizados gravados, e um `PATCH` de `mailer_templates_*` num projeto
* sem SMTP responde 200 e persiste byte a byte (conferido relendo com GET).
* O que exige SMTP próprio é o VOLUME de envio, não o corpo do e-mail.
* - `code` (PKCE): template PADRÃO do Supabase (o de quem nunca configurou os
* templates — caso mais comum em instalação fresca). O e-mail linka pro
* `/auth/v1/verify` do próprio GoTrue, que valida e SÓ ENTÃO redireciona pra
* cá com o code; não inclui `type`, por isso requestPasswordReset.ts e
* signUp.ts anexam `?type=` no redirectTo/emailRedirectTo — é o único jeito
* desse dado sobreviver ao hop pelo GoTrue nesse formato.
*
* ⚠️ O formato `code` NÃO FECHA nesta instalação, e o motivo é estrutural.
* `@supabase/ssr` força `flowType: "pkce"` (createServerClient.js:33) e grava
* o verificador num cookie (`<storageKey>-code-verifier`, cookies.js:18) com
* as MESMAS `cookieOptions` da sessão (cookies.js:227,232) — isto é, com o
* `sameSite: "strict"` de `lib/supabase/server.ts:35`. Clique de link vindo
* de webmail é navegação CROSS-SITE: o navegador não manda cookie Strict, o
* verificador não chega, e `exchangeCodeForSession` falha. O formato
* `token_hash` não depende de cookie nenhum.
*
* (O que NÃO está medido: um cliente de e-mail nativo abre o link sem
* iniciador, e nesse caso o navegador PODE mandar o cookie Strict. Por isso a
* mensagem da tela aponta a configuração como conserto, e não promete que
* "abrir noutro lugar" funciona.)
*
* É por isso que a recusa dos dois ramos não pode ter a mesma mensagem:
* "link inválido ou expirado" manda o operador caçar TTL e relógio quando o
* problema é que os templates nunca foram configurados.
*
* - type=signup → provisiona o tenant (org + membership admin) e entra no
* onboarding. Provisionamento é idempotente (link clicado 2x).
* - type=recovery → sessão de recovery estabelecida; segue para /login/reset
@@ -48,6 +72,11 @@ export async function GET(request: NextRequest) {
return redirectTo("/login?error=link_invalido");
}
// Qual dos dois formatos chegou é um FATO observável, não inferência: o
// `code` só existe no link que o template PADRÃO do Supabase monta. Guardar
// isso antes da chamada é o que permite explicar a recusa depois.
const viaTokenHash = Boolean(tokenHash && type);
const supabase = await createClient();
const { data, error } =
tokenHash && type
@@ -57,10 +86,17 @@ export async function GET(request: NextRequest) {
if (error || !data.user) {
await audit({
action: "auth.email_link_rejected",
metadata: { type, reason: error?.message ?? "no_user" },
// `formato` é o campo que faltava: sem ele os dois modos de falha
// chegavam ao audit log indistinguíveis, e a triagem de "o link não
// funciona" começava do zero toda vez.
metadata: { type, formato: viaTokenHash ? "token_hash" : "code", reason: error?.message ?? "no_user" },
requestId,
});
return redirectTo("/login?error=link_invalido");
// Dois códigos porque são duas causas e dois consertos. `link_invalido`
// continua sendo "peça outro link". `template_padrao` diz o que a tela
// antes escondia: o link veio do modelo padrão, pedir outro não adianta, e
// o conserto é configurar os templates (hostgator-setup-kit/marca-emails.sh).
return redirectTo(viaTokenHash ? "/login?error=link_invalido" : "/login?error=template_padrao");
}
if (type === "recovery") {
+103
View File
@@ -0,0 +1,103 @@
import { ImageResponse } from "next/og";
import { letraDoIcone } from "@/lib/branding/icone";
import { marcaDaSaida } from "@/lib/branding/saida";
/**
* O ícone da aba, DESENHADO em runtime com a marca da instalação.
*
* ─── O que existia antes: nada ──────────────────────────────────────────────
*
* Zero `app/icon.*`, zero `app/favicon.ico`, zero `public/favicon*` (medido:
* `public/` tem dois arquivos, `.gitkeep` e `llms.txt`). O navegador pedia
* `/favicon.ico` por conta própria e recebia 404 — em produção, 19.435 bytes,
* porque o 404 é a `app/not-found.tsx` INTEIRA servida para um pedido de
* ícone. Na prática: aba sem marca nenhuma, para nós e para todo revendedor.
*
* ─── Por que GERADO, e não um arquivo em `public/` ──────────────────────────
*
* `Dockerfile:75-79` copia `public/` para a imagem final, e a imagem é UMA SÓ
* para todas as marcas — a mesma tag do GHCR que cada clone puxa. Um
* `favicon.ico` estático resolveria o 404 e entregaria a NOSSA marca na aba de
* todo revendedor, que é o mesmo modo de falha que `lib/branding.ts:12-16`
* documenta para `NEXT_PUBLIC_*`: verde em dev, verde no CI, verde na Vercel, e
* errado exatamente na VPS de quem a feature existe para servir.
*
* ─── Cor + inicial, NUNCA o `logo_url` ──────────────────────────────────────
*
* `platform_branding.logo_url` é `text` livre, sem CHECK de host
* (`supabase/baseline.sql:11832-11848`). Buscá-la aqui seria uma requisição de
* saída disparada pelo `<head>` de TODA página, com a URL vinda de um campo que
* o operador digita — SSRF com gatilho em cada page load. Derivar o ícone de
* cor + inicial não toca a rede: o accent vem do mesmo resolvedor que pinta os
* e-mails (`marcaDaSaida`) e a fonte (`Geist-Regular.ttf`) vem embutida no
* `@vercel/og` que o Next já traz — nenhuma dependência nova, nenhum download.
*
* ─── `force-dynamic` não é zelo ─────────────────────────────────────────────
*
* O loader de metadata NÃO injeta `force-static` na variante gerada por código
* (`next-metadata-route-loader.js`, `getSingleImageRouteCode`), mas também não
* a torna dinâmica sozinha — sem esta linha o `next build` congelaria o ícone
* dentro da imagem pré-buildada, com a marca de quem buildou. E o defeito seria
* invisível em dev, em teste e na Vercel: só apareceria na VPS do revendedor,
* que é o único lugar onde a marca é outra. O loader re-exporta todo named
* export do arquivo do usuário (`:43`), então declarar aqui basta.
*
* ─── Custo ──────────────────────────────────────────────────────────────────
*
* Um `ImageResponse` por requisição a `/icon`. O `Cache-Control` abaixo é o que
* mantém isso em uma renderização por minuto por navegador em vez de uma por
* navegação. A leitura da marca é a MESMA que o `generateMetadata` do layout já
* faz, memoizada por 30s (`lib/branding/instalacao.ts:209`) — nenhuma consulta
* a mais no banco.
*
* ⚠️ `/icon` precisa estar em `PUBLIC_PATHS` (`lib/auth/public-paths.ts`): o
* matcher do `proxy.ts:128` só dispensa caminho COM extensão, e `/icon` não tem
* — sem a entrada, o ícone responde 307 para `/login` a quem ainda não entrou,
* que é exatamente a primeira tela que um comprador vê.
*/
export const dynamic = "force-dynamic";
/** 64 e não 32: a aba pede 16-32 CSS px, e em tela retina isso são 32-64 reais. */
export const size = { width: 64, height: 64 };
export const contentType = "image/png";
export default async function Icon() {
const marca = await marcaDaSaida(null);
const letra = letraDoIcone(marca.nome);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: marca.accent,
color: marca.accentFg,
// 62% da altura: a caixa maiúscula do Geist ocupa ~72% do em, então
// a letra fica com respiro sem virar um selo minúsculo no meio.
fontSize: Math.round(size.height * 0.62),
// O ladrilho é quadrado e cheio: o navegador já arredonda o favicon
// no chrome dele, e arredondar aqui também produz canto duplo.
borderRadius: 0,
}}
>
{letra ?? ""}
</div>
),
{
...size,
headers: {
// 60s é deliberado, e o par com o TTL da marca: o operador que troca a
// cor em `/admin/marca` vê a aba acompanhar dentro de um minuto. Um
// `immutable` de um ano tornaria a tela de marca uma promessa que o
// ícone não cumpre; `no-store` faria o satori rodar a cada navegação.
"cache-control": "public, max-age=60, stale-while-revalidate=600",
},
},
);
}
+20 -6
View File
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Atkinson_Hyperlegible, IBM_Plex_Mono } from "next/font/google";
import { headers } from "next/headers";
import { Toaster } from "sonner";
import { coresDaBarraDoNavegador } from "@/lib/branding/barra-do-navegador";
import { cssDaMarca } from "@/lib/branding/css";
import {
marcaDaInstalacao,
@@ -96,14 +97,23 @@ export async function generateMetadata(): Promise<Metadata> {
"multi-tenant",
],
robots: { index: false, follow: false },
// Sem esta linha o navegador pede `/favicon.ico`, que não existe: medido em
// produção, o 404 é a `app/not-found.tsx` INTEIRA (19.435 bytes de HTML)
// servida para um pedido de ícone, em toda navegação sem cache. Declarar
// `/icon` faz o pedido ir para `app/icon.tsx`, que desenha a marca da
// instalação em runtime — ver o cabeçalho daquele arquivo para por que ele
// não pode ser um arquivo estático em `public/`.
icons: { icon: "/icon" },
};
}
/**
* A cor da barra do navegador sai da RÉGUA, não de dois hexes redigitados aqui.
* O porquê — inclusive por que isto NÃO deve virar `generateViewport()` lendo o
* banco — está no cabeçalho de `lib/branding/barra-do-navegador.ts`.
*/
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#faf9f6" },
{ media: "(prefers-color-scheme: dark)", color: "#161510" },
],
themeColor: coresDaBarraDoNavegador(REGUA_DO_PRODUTO),
};
// Inline FOUC-prevention. Conteúdo é string literal estática (zero input do usuário),
@@ -125,8 +135,12 @@ const motivosRegistrados = new Set<string>();
* trocar. Server Component de propósito: a leitura é do BANCO e do `.env` em
* runtime (a imagem self-host é pré-buildada; ver `lib/branding.ts`), e enviar
* isso pelo cliente reintroduziria justamente o flash. Desde a migration 0155 a
* fonte primária é `platform_branding`; o `.env` continua embaixo como semente e
* rede de segurança de rollback (ver `lib/branding/instalacao.ts`).
* fonte primária é `platform_branding`; o `.env` continua embaixo como semente —
* e, para NOME e LOGO, como rede de rollback (ver `lib/branding/instalacao.ts`).
* Para COR não existe rede: `APP_ACCENT_HEX` nasceu no mesmo épico que a tabela,
* então nenhuma versão velha o bastante para desconhecê-la pinta accent, e o
* `install.sh` não grava a chave (medido: 0 ocorrências, contra 7 de APP_NAME).
* A correção está no cabeçalho da migration 0155.
*
* Os motivos NÃO morrem aqui: enquanto a tela de marca não existe (fase
* seguinte, `/app/settings/tenant/branding`), o log estruturado é por onde o
+42 -3
View File
@@ -107,7 +107,20 @@ O app tem signup self-service (`/signup`) e recuperação de senha
(`/login/forgot`). Os dois dependem do e-mail transacional do Supabase Auth
chegando com link para `https://SEU_DOMINIO/auth/confirm`.
**Supabase hospedado (recomendado):** no Dashboard →
**O caminho automático (recomendado):** com um Personal Access Token exportado,
```bash
export SUPABASE_ACCESS_TOKEN=sbp_... # supabase.com/dashboard/account/tokens
bash hostgator-setup-kit/marca-emails.sh
```
Ele sobe assunto e corpo dos dois e-mails **com a marca da sua instalação**
(`APP_NAME` do `.env`), configura `Site URL` e `Redirect URLs`, e **relê o que
gravou** para provar que a API aceitou. O `install.sh` já o chama sozinho
quando o token está no ambiente. Sem o token, ele imprime o passo manual e sai
sem quebrar nada.
**O caminho manual:** no Dashboard →
1. **Authentication → Sign In / Up**: habilite *Allow new users to sign up* e
mantenha *Confirm email* ligado.
@@ -118,13 +131,26 @@ chegando com link para `https://SEU_DOMINIO/auth/confirm`.
```html
<!-- Confirm signup -->
<a href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=signup">Confirmar e-mail</a>
<a href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}">Confirmar e-mail</a>
<!-- Reset password -->
<a href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=recovery">Redefinir senha</a>
<a href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}">Redefinir senha</a>
```
⚠️ **`&`, nunca `?`.** O app já manda `.RedirectTo` com o `?type=` embutido
(`app/actions/auth/signUp.ts` e `requestPasswordReset.ts`), então um `?`
aqui produz `...?type=signup?token_hash=...`: o navegador para de reconhecer
`token_hash` como parâmetro (ele vira parte do valor de `type`) e
`/auth/confirm` manda o usuário para `/login?error=link_invalido` — com o
link correto. Esta seção ensinava a forma com `?` até 2026-08-14, e o
projeto Supabase de produção estava com ela gravada: quem seguiu a receita
reproduziu o defeito.
4. **SMTP próprio** (Authentication → SMTP): o sender embutido do Supabase tem
limite baixo (~2 e-mails/h) — configure Resend/SES/etc. para produção.
Isto é sobre VOLUME de envio: editar o corpo do e-mail **não** exige SMTP
próprio (medido em 2026-08-14: `PATCH /v1/projects/{ref}/config/auth` com
`mailer_templates_*` responde 200 e persiste num projeto com
`smtp_host: null`).
**GoTrue self-hosted:** equivalente por env:
`GOTRUE_DISABLE_SIGNUP=false`, `GOTRUE_MAILER_AUTOCONFIRM=false`,
@@ -134,6 +160,19 @@ chegando com link para `https://SEU_DOMINIO/auth/confirm`.
`GOTRUE_MAILER_TEMPLATES_{CONFIRMATION,RECOVERY}` apontando para os templates
de `supabase/templates/` (mesmo link `token_hash` acima).
⚠️ **Não aponte para os arquivos do repositório direto.** Eles são MODELOS: o
nome da marca e a cor do botão são `__APP_NAME__` / `__ACCENT__`, e o cliente
receberia isso literalmente. Renderize antes e aponte para o resultado:
```bash
bash hostgator-setup-kit/marca-emails.sh --render-em /opt/deskcomm/emails
# GOTRUE_MAILER_TEMPLATES_CONFIRMATION=/opt/deskcomm/emails/confirmation.html
# GOTRUE_MAILER_TEMPLATES_RECOVERY=/opt/deskcomm/emails/recovery.html
```
Num Supabase próprio não existe Management API, então este é o único caminho —
e é preciso repetir o comando quando a marca mudar.
## 4. Conectar o WhatsApp
1. Acesse `https://SEU_DOMINIO/app` e crie sua conta/organização.
+14
View File
@@ -1551,6 +1551,20 @@ else
c_ylw "⚠ supabase/baseline.sql não encontrado — pulei (aplique o schema manualmente)."
fi
# ── 7.5 E-mails de acesso (criar conta / recuperar senha) ───────────────────
# O e-mail de confirmação de conta é o PRIMEIRO artefato que qualquer usuário
# recebe. Sem este passo ele chega no modelo padrão do Supabase — em inglês,
# "Confirm Your Signup", sem marca nenhuma — numa instalação em que tudo o mais
# já está com a marca de quem hospeda.
#
# Chamado SEMPRE, com ou sem token: sem `SUPABASE_ACCESS_TOKEN` o script imprime
# o passo manual do painel e sai 0. É informação que vale mais aqui, no fim da
# instalação, do que num documento que ninguém vai abrir.
#
# `|| true` como cinto de segurança: o script já promete nunca sair diferente de
# 0, e mesmo assim a instalação não pode morrer por causa do e-mail.
bash "$KIT_DIR/marca-emails.sh" --projeto "$PROJECT_DIR" || true
# ── 8. Bootstrap do 1º dono (cria no Auth + promove via psql) ───────────────
step "Criando o primeiro admin (${OWNER_EMAIL})"
# 1) Cria o usuário no Supabase Auth. Se já existe, a API responde 422 — ignoramos
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env bash
#
# Sobe os e-mails de ACESSO (criar conta e recuperar senha) com a marca da
# instalação — assunto, corpo, cor do botão — e configura Site URL / Redirect
# URLs, que são pré-requisito do link funcionar.
#
# bash marca-emails.sh # lê ../.env e sobe
# bash marca-emails.sh --render-em /tmp/x # só renderiza, não sobe nada
#
# ── POR QUE ISTO É UM SCRIPT, E NÃO UMA FUNÇÃO DO APP ───────────────────────
#
# Estes dois e-mails são renderizados pelo GoTrue, um TERCEIRO PROCESSO que
# roda na infraestrutura do Supabase. Nenhum TypeScript nosso executa ali: não
# existe `marcaDaSaida()` a chamar, não existe resolvedor a plugar. O único
# canal é texto empurrado por API. Por isso o artefato entregável não é o HTML
# — é o mecanismo que o sobe.
#
# E ele importa mais que os outros e-mails: o de confirmação de conta é o
# PRIMEIRO artefato que qualquer usuário de um revendedor recebe. Sem este
# script, ele chega dizendo "Confirm Your Signup" em inglês, no modelo padrão
# do Supabase, sem marca nenhuma.
#
# ── O QUE FOI MEDIDO (2026-08-14) ──────────────────────────────────────────
#
# O comentário de `app/auth/confirm/route.ts` afirmava que editar o corpo do
# e-mail exige SMTP customizado. Medido contra a Management API real:
#
# PATCH /v1/projects/{ref}/config/auth {"mailer_templates_confirmation_content": …}
# → HTTP 200 num projeto com `smtp_host: null`
# → GET seguinte devolve o conteúdo BYTE A BYTE
#
# Ou seja: o corpo do e-mail é editável sem SMTP próprio. O que o SMTP próprio
# resolve é o VOLUME (o remetente embutido do Supabase tem limite baixo).
#
# Também medido: projeto pausado responde `400 {"message":"Project is paused."}`.
# Por isso o passo de RELEITURA abaixo não é zelo — é a diferença entre "subiu"
# e "a API respondeu alguma coisa". Um script que confia no 2xx reporta sucesso
# num projeto pausado.
#
# ── NUNCA DERRUBA QUEM O CHAMA ─────────────────────────────────────────────
#
# `install.sh` e `update.sh` chamam este script. Falta de token, Supabase
# próprio (sem Management API), projeto pausado, rede fora: tudo imprime o que
# fazer e sai 0. Uma instalação não pode morrer porque o e-mail de boas-vindas
# ficou em inglês.
#
# Dependências: as mesmas do kit (bash + curl + awk). Sem jq, sem python.
set -uo pipefail
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PROJ_DIR="$(cd "$KIT_DIR/.." && pwd)"
API="https://api.supabase.com/v1"
# shellcheck source=_common.sh
. "$KIT_DIR/_common.sh"
# Vazio até o fim do laço: assim `--projeto` e `--env` não dependem da ordem
# em que forem passados.
ENV_FILE=""
RENDER_EM=""
while [ $# -gt 0 ]; do
case "$1" in
--render-em) RENDER_EM="${2:-}"; shift 2 || shift;;
--env) ENV_FILE="${2:-}"; shift 2 || shift;;
# O `install.sh` pode ter sido chamado de FORA do repositório (ele clona e
# entra), e aí `KIT_DIR/..` não é a raiz do projeto — nem o `.env` nem
# `supabase/templates/` estariam lá. Quem sabe onde o projeto está é quem
# chama; por isso a origem é um parâmetro, não uma adivinhação.
--projeto) PROJ_DIR="${2:-}"; shift 2 || shift;;
-h|--help) sed -n '2,8p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0;;
*) c_ylw "opção desconhecida: $1"; exit 0;;
esac
done
[ -n "$ENV_FILE" ] || ENV_FILE="$PROJ_DIR/.env"
# Sai bem-sucedido depois de ensinar o passo manual. É o contrato deste script:
# ele informa, não interrompe.
instrua_e_saia() {
printf '\n' >&2
c_ylw "⚠ $1"
printf '\n' >&2
c_dim " Para fazer à mão, no painel do Supabase:"
c_dim " 1. Authentication → URL Configuration"
c_dim " Site URL: ${APP_URL:-https://SEU_DOMINIO}"
c_dim " Redirect URLs: ${APP_URL:-https://SEU_DOMINIO}/auth/confirm"
c_dim " 2. Authentication → Email Templates → Confirm signup / Reset password"
c_dim " <a href=\"{{ .RedirectTo }}&token_hash={{ .TokenHash }}\">Confirmar</a>"
c_dim " ⚠ o separador é & — um ? aqui quebra o link (vira ?type=x?token_hash=y)"
printf '\n' >&2
exit 0
}
# ── 1. A marca, lida do .env da instalação ─────────────────────────────────
[ -f "$ENV_FILE" ] && load_env "$ENV_FILE"
APP_NOME="${APP_NAME:-}"
[ -n "${APP_NOME//[[:space:]]/}" ] || APP_NOME="DeskcommCRM"
APP_URL="${NEXT_PUBLIC_APP_URL:-}"
# `#506d48` é o accent do tema claro do produto (grau 600 da rampa em
# lib/branding/regua-do-produto.ts) — o mesmo piso que `lib/branding/saida.ts`
# usa nos e-mails. Não é uma cor inventada aqui.
#
# ⚠️ LIMITE DECLARADO: a cor sai do `.env`, e o `install.sh` não grava
# APP_ACCENT_HEX (medido: `grep -c APP_ACCENT_HEX install.sh` → 0, contra 7 de
# APP_NAME). Na prática, quem trocar a cor pela tela `/admin/marca` NÃO vê os
# e-mails de acesso acompanharem: eles são texto estático dentro do GoTrue e só
# mudam quando este script roda de novo.
#
# Esta defasagem é INERENTE ao mecanismo, não a esta escolha de fonte: o GoTrue
# guarda o texto do e-mail, não uma referência ao nosso resolvedor. Trocar o
# `.env` pelo banco como origem mudaria QUAL valor velho fica gravado, não o
# fato de ele ficar. Quem mexer aqui esperando "ler do banco resolve" precisa
# saber disso antes de escrever a primeira linha.
ACCENT="${APP_ACCENT_HEX:-}"
case "$ACCENT" in
\#[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) ;;
*) ACCENT="#506d48";;
esac
# A frente do botão: preto ou branco, pela luminância relativa da cor de fundo.
#
# Isto DUPLICA a decisão de `melhorFrenteSobre` (lib/branding/contraste.ts) —
# de propósito, porque bash não importa TypeScript e a alternativa seria
# `#ffffff` fixo. E `#ffffff` fixo é o defeito concreto: uma marca amarela
# colada pelo revendedor produz texto branco sobre amarelo, ilegível, no
# PRIMEIRO e-mail que o cliente dele recebe. As duas implementações decidem a
# mesma coisa binária; divergir só é possível num intervalo estreito em torno
# do limiar, e nele as duas escolhas são legíveis.
frente_sobre() {
local hex="${1#\#}"
# `index()` numa tabela, e NÃO `strtonum("0x..")`: strtonum é extensão do
# gawk. O Ubuntu de VPS traz mawk como `awk` por padrão e o macOS traz o awk
# do BSD — em nenhum dos dois strtonum existe, e o erro ("calling undefined
# function") derrubaria a função no cliente e não aqui.
awk -v h="$hex" '
function hexv(c) { return index("0123456789abcdef", tolower(c)) - 1 }
function par(s) { return hexv(substr(s,1,1))*16 + hexv(substr(s,2,1)) }
BEGIN{
r=par(substr(h,1,2)); g=par(substr(h,3,2)); b=par(substr(h,5,2));
# Luminância perceptual (Rec. 601). Suficiente para escolher entre preto e
# branco; não é o cálculo WCAG completo, e o comentário acima diz por quê.
y=(0.299*r + 0.587*g + 0.114*b)/255;
print (y > 0.6 ? "#171f15" : "#ffffff");
}'
}
ACCENT_FG="$(frente_sobre "$ACCENT")"
# ── 2. Renderiza os modelos ────────────────────────────────────────────────
MARCADOR="marca-emails $(date +%s)"
# Substituição LITERAL, não `sed s|…|…|`. O nome da marca é texto que o
# operador digita: "Vendas & Cia" faria o `&` do lado direito de um `sed` valer
# como "o trecho casado" e o e-mail sairia dizendo "Vendas __APP_NAME__ Cia".
# `|`, `\` e `/` no nome têm famílias de estrago parecidas. `index`/`substr` não
# interpretam caractere nenhum.
trocar() { # trocar <chave> <valor> (texto em stdin, resultado em stdout)
awk -v k="$1" -v v="$2" '{
out=""; s=$0;
while ((p=index(s,k))>0) { out = out substr(s,1,p-1) v; s = substr(s,p+length(k)) }
print out s
}'
}
# O nome da marca entra em TEXTO HTML. Sem escape, uma marca chamada
# `Loja <b>Top</b>` injeta markup no corpo do e-mail, e `&` cru já é HTML
# inválido. Só o corpo é escapado — o ASSUNTO é cabeçalho de e-mail, texto
# puro, e `&amp;` ali apareceria literalmente na caixa de entrada.
#
# Feito com `trocar` (index/substr do awk) e NÃO com `${s//&/&amp;}`: o bash
# 5.2 passou a expandir `&` no lado direito de `${x//p/r}` para o trecho
# CASADO, como o sed sempre fez. Medido aqui: `Loja <b>Top</b> & Cia` saía
# `Loja <lt;b>gt;Top...` — o `&` de `&lt;` virava o próprio `<`. Escapar com
# `\&` consertaria no 5.2 e quebraria no bash 4 de uma VPS antiga, que é
# exatamente o público do kit.
escapar_html() {
printf '%s' "$1" \
| trocar '&' '&amp;' \
| trocar '<' '&lt;' \
| trocar '>' '&gt;' \
| trocar '"' '&quot;'
}
# Tira o bloco de comentário de manutenção do topo do modelo — ele fala de
# placeholder, de `--render-em` e do porquê do `&`, tudo endereçado a quem edita
# o repositório. Empurrar isso para dentro do e-mail de todo cliente do
# revendedor seria vazar nota interna (e o próprio texto do comentário sairia
# com os placeholders já substituídos, o que confunde ainda mais).
sem_nota_interna() {
awk 'BEGIN{ pulando=0 }
NR==1 && $0 ~ /^[[:space:]]*<!--/ { pulando=1 }
pulando==1 { if ($0 ~ /-->/) { pulando=0 }; next }
{ print }'
}
renderizar() { # renderizar <arquivo>
local arq="$1"
[ -f "$arq" ] || return 1
# O marcador é a PROVA de releitura (passo 6): ASCII puro e sem `<`, `>` nem
# `&` no texto que será procurado, porque o JSON que a API devolve escapa
# esses três (`<`, `>`, `&`) e um grep pelo original falharia
# num conteúdo que subiu perfeitamente.
printf '<!-- %s -->\n' "$MARCADOR"
sem_nota_interna < "$arq" \
| trocar "__APP_NAME__" "$(escapar_html "$APP_NOME")" \
| trocar "__ACCENT_FG__" "$ACCENT_FG" \
| trocar "__ACCENT__" "$ACCENT"
}
HTML_CONFIRM="$(renderizar "$PROJ_DIR/supabase/templates/confirmation.html")" || instrua_e_saia "não achei supabase/templates/ — rode de dentro do repositório."
HTML_RECOVERY="$(renderizar "$PROJ_DIR/supabase/templates/recovery.html")" || instrua_e_saia "não achei supabase/templates/ — rode de dentro do repositório."
if [ -n "$RENDER_EM" ]; then
# O caminho de quem roda GoTrue self-hosted: lá não existe Management API, e
# `GOTRUE_MAILER_TEMPLATES_*` aponta para ARQUIVO. Apontar para o modelo cru
# entregaria `__APP_NAME__` ao cliente final.
mkdir -p "$RENDER_EM" || instrua_e_saia "não consegui escrever em $RENDER_EM"
printf '%s\n' "$HTML_CONFIRM" > "$RENDER_EM/confirmation.html"
printf '%s\n' "$HTML_RECOVERY" > "$RENDER_EM/recovery.html"
c_grn "✓ modelos renderizados em $RENDER_EM (marca: $APP_NOME, accent: $ACCENT)"
c_dim " GoTrue self-hosted: aponte GOTRUE_MAILER_TEMPLATES_CONFIRMATION/RECOVERY para eles."
exit 0
fi
# ── 3. Dá para subir? ──────────────────────────────────────────────────────
[ -n "${SUPABASE_ACCESS_TOKEN:-}" ] || instrua_e_saia \
"sem SUPABASE_ACCESS_TOKEN — não dá para configurar os e-mails de acesso sozinho.
Pegue um token em https://supabase.com/dashboard/account/tokens, rode
\`export SUPABASE_ACCESS_TOKEN=sbp_...\` e chame este script de novo."
# O ref do projeto sai da URL: https://<ref>.supabase.co. Supabase PRÓPRIO
# (self-hosted) não tem Management API nenhuma — e nesse caso o passo é por env
# do GoTrue, não por API.
REF=""
case "${NEXT_PUBLIC_SUPABASE_URL:-}" in
https://*.supabase.co*) REF="${NEXT_PUBLIC_SUPABASE_URL#https://}"; REF="${REF%%.supabase.co*}";;
esac
[ -n "$REF" ] || instrua_e_saia \
"NEXT_PUBLIC_SUPABASE_URL não é um projeto da nuvem do Supabase (${NEXT_PUBLIC_SUPABASE_URL:-vazio}).
Num Supabase próprio, use GOTRUE_MAILER_TEMPLATES_* apontando para os
arquivos de \`marca-emails.sh --render-em <dir>\`."
api() { # api <método> <caminho> [corpo]
local method="$1" path="$2" body="${3:-}"
if [ -n "$body" ]; then
curl -sS -X "$method" "$API$path" \
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
-H "Content-Type: application/json" -d "$body"
else
curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
fi
}
# Mesmo extrator plano do supabase-provision.sh:123, e pelo mesmo motivo — o
# kit promete depender só de bash+curl. O `|| true` mantém a mensagem de erro
# ALCANÇÁVEL: campo ausente faria o grep sair 1 e, com pipefail, derrubaria a
# atribuição antes da linha que explica o problema.
json_str() { grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" <<<"$1" | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true; }
# Escapa uma string para dentro de um JSON. Não escapa acento nem `&`: JSON
# aceita UTF-8 cru, e o que precisa sair são barra, aspas, tab e quebra de linha.
json_escape() {
awk 'BEGIN{ORS=""}
{ gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); gsub(/\t/,"\\t"); gsub(/\r/,"");
printf "%s%s", (NR>1 ? "\\n" : ""), $0 }' <<<"$1"
}
step "Configurando os e-mails de acesso (marca: $APP_NOME)"
atual="$(api GET "/projects/$REF/config/auth")"
case "$atual" in
*'"message"'*) instrua_e_saia "a API do Supabase recusou a leitura: $(json_str "$atual" message)";;
esac
# ── 4. Site URL e Redirect URLs: acrescentar, nunca substituir ─────────────
#
# Sobrescrever a lista apagaria domínios que o operador acrescentou (staging,
# app antigo, deep link do celular) e o sintoma seria "o login parou de voltar"
# — longe daqui e sem relação óbvia com "eu mexi nos e-mails".
SITE_ATUAL="$(json_str "$atual" site_url)"
ALLOW_ATUAL="$(json_str "$atual" uri_allow_list)"
SITE_NOVO="$SITE_ATUAL"
if [ -n "$APP_URL" ]; then
# Troca só quando está vazio ou no default de projeto novo do Supabase.
# Um Site URL que o operador escolheu fica como está — com aviso.
case "$SITE_ATUAL" in
""|"http://localhost:3000"|"http://localhost:3000/") SITE_NOVO="$APP_URL";;
*)
if [ "${SITE_ATUAL%/}" != "${APP_URL%/}" ]; then
c_ylw " • Site URL já está em $SITE_ATUAL (deixei como está; o app usa $APP_URL)"
fi;;
esac
fi
ALLOW_NOVO="$ALLOW_ATUAL"
if [ -n "$APP_URL" ]; then
for entrada in "${APP_URL%/}/auth/confirm" "${APP_URL%/}/**"; do
case ",$ALLOW_NOVO," in
*",$entrada,"*) ;;
*) [ -n "$ALLOW_NOVO" ] && ALLOW_NOVO="$ALLOW_NOVO,$entrada" || ALLOW_NOVO="$entrada";;
esac
done
fi
# ── 5. Sobe ────────────────────────────────────────────────────────────────
corpo="{
\"mailer_subjects_confirmation\": \"$(json_escape "Confirme seu e-mail — $APP_NOME")\",
\"mailer_subjects_recovery\": \"$(json_escape "Redefinir senha — $APP_NOME")\",
\"mailer_templates_confirmation_content\": \"$(json_escape "$HTML_CONFIRM")\",
\"mailer_templates_recovery_content\": \"$(json_escape "$HTML_RECOVERY")\",
\"site_url\": \"$(json_escape "$SITE_NOVO")\",
\"uri_allow_list\": \"$(json_escape "$ALLOW_NOVO")\"
}"
resposta="$(api PATCH "/projects/$REF/config/auth" "$corpo")"
# ── 6. RELEITURA — a única prova que vale ──────────────────────────────────
#
# Não confie no 2xx. Medido: projeto pausado devolve 400 com
# {"message":"Project is paused."} e um script que só olhasse o código de saída
# do curl reportaria sucesso. E o modo de falha pior é o oposto — API que
# ACEITA e IGNORA — que nenhum código de status denuncia.
depois="$(api GET "/projects/$REF/config/auth")"
if grep -qF "$MARCADOR" <<<"$depois"; then
c_grn "✓ e-mails de acesso configurados e CONFERIDOS (reli o que gravei)"
c_dim " assunto: Confirme seu e-mail — $APP_NOME"
c_dim " botão: $ACCENT sobre texto $ACCENT_FG"
[ "$SITE_NOVO" = "$SITE_ATUAL" ] || c_dim " site url: $SITE_NOVO"
[ "$ALLOW_NOVO" = "$ALLOW_ATUAL" ] || c_dim " redirect: $ALLOW_NOVO"
exit 0
fi
motivo="$(json_str "$resposta" message)"
[ -n "$motivo" ] || motivo="$(json_str "$depois" message)"
instrua_e_saia "os e-mails de acesso NÃO foram configurados${motivo:+ — $motivo}.
Reli a configuração depois de gravar e o conteúdo que mandei não estava lá.
A instalação continua funcionando; só os e-mails ficam no modelo padrão."
+118
View File
@@ -804,6 +804,118 @@ case "$senha" in
*) printf ' ✓ só alfanuméricos (não parte a connection string)\n';;
esac
echo "e-mails de acesso: marca-emails.sh"
# POR QUE ESTE BLOCO EXISTE: o e-mail de confirmação de conta é o PRIMEIRO
# artefato que um cliente do revendedor recebe, e ele é montado por um processo
# de TERCEIRO (o GoTrue). Nenhum teste do app o alcança — o único jeito de
# vigiar isso é exercitar o script que empurra o texto.
#
# Os casos abaixo cobrem tudo o que dá para provar SEM rede: renderização,
# escape, e as três saídas que não podem derrubar a instalação. O caminho com
# rede (PATCH + releitura) foi medido à mão contra a Management API em
# 2026-08-14 e está declarado no cabeçalho do script.
ME_TMP="$(mktemp -d)"
# (1) Sem token, sai 0 e ENSINA o passo manual. Este é o caso comum: quem cria
# o projeto no painel e cola as 4 credenciais nunca teve token nenhum.
saida_me="$(SUPABASE_ACCESS_TOKEN= bash ./marca-emails.sh --env /dev/null 2>&1)"; rc_me=$?
if [ $rc_me -ne 0 ]; then
printf ' ✗ sem token o script saiu %s — ele NÃO pode derrubar o install.sh\n' "$rc_me"; fail=1
else
printf ' ✓ sem token: sai 0 (a instalação continua)\n'
fi
if printf '%s' "$saida_me" | grep -q 'SUPABASE_ACCESS_TOKEN'; then
printf ' ✓ sem token: diz qual é a chave que falta\n'
else
printf ' ✗ sem token: a mensagem não nomeia SUPABASE_ACCESS_TOKEN\n'; fail=1
fi
# O passo manual tem de ensinar `&`. Foi um `?` nesta mesma receita (na doc de
# deploy) que gravou o link quebrado no projeto de produção.
if printf '%s' "$saida_me" | grep -q '{{ .RedirectTo }}&token_hash'; then
printf ' ✓ sem token: o passo manual ensina o separador & (nunca ?)\n'
else
printf ' ✗ sem token: o passo manual não mostra o link com &\n'; fail=1
fi
# (2) Supabase PRÓPRIO (self-hosted) não tem Management API: com token e tudo,
# o certo é ensinar o caminho do GoTrue, não tentar um PATCH que não existe.
saida_me="$(SUPABASE_ACCESS_TOKEN=sbp_de_teste NEXT_PUBLIC_SUPABASE_URL=https://supabase.meucliente.com.br \
bash ./marca-emails.sh --env /dev/null 2>&1)"; rc_me=$?
if [ $rc_me -eq 0 ] && printf '%s' "$saida_me" | grep -q 'GOTRUE_MAILER_TEMPLATES'; then
printf ' ✓ Supabase próprio: sai 0 e manda para o caminho do GoTrue\n'
else
printf ' ✗ Supabase próprio: rc=%s, mensagem sem GOTRUE_MAILER_TEMPLATES\n' "$rc_me"; fail=1
fi
# (3) VACUIDADE: os modelos no disco precisam TER o placeholder, senão o caso
# (4) compararia a ausência de marca com a ausência de marca e passaria.
for modelo in ../supabase/templates/confirmation.html ../supabase/templates/recovery.html; do
if grep -q '__APP_NAME__' "$modelo"; then
printf ' ✓ %s tem __APP_NAME__ para substituir\n' "$(basename "$modelo")"
else
printf ' ✗ %s NÃO tem __APP_NAME__ — a substituição abaixo não prova nada\n' "$(basename "$modelo")"; fail=1
fi
done
# (4) Renderização com um nome HOSTIL. `&` no lado direito de um `sed` (ou de um
# `${x//p/r}` no bash 5.2) vale como "o trecho casado" — medido: `Loja
# <b>Top</b> & Cia` saía `Loja <lt;b>gt;Top…`. E `<b>` cru injetaria markup
# no corpo do e-mail de todo cliente do revendedor.
SUPABASE_ACCESS_TOKEN= APP_NAME='Loja <b>Top</b> & Cia' APP_ACCENT_HEX='#f2c94c' \
bash ./marca-emails.sh --env /dev/null --render-em "$ME_TMP/render" >/dev/null 2>&1
rend="$ME_TMP/render/confirmation.html"
if [ ! -f "$rend" ]; then
printf ' ✗ --render-em não escreveu confirmation.html\n'; fail=1
else
if grep -q '__APP_NAME__\|__ACCENT__\|__ACCENT_FG__' "$rend"; then
printf ' ✗ sobrou placeholder no HTML renderizado: %s\n' "$(grep -o '__[A-Z_]*__' "$rend" | sort -u | tr '\n' ' ')"; fail=1
else
printf ' ✓ nenhum placeholder sobrou no HTML renderizado\n'
fi
if grep -qF 'Loja &lt;b&gt;Top&lt;/b&gt; &amp; Cia' "$rend"; then
printf ' ✓ o nome da marca sai escapado (nada de markup injetado no e-mail)\n'
else
printf ' ✗ o nome da marca NÃO saiu escapado: %s\n' "$(grep -o 'Sua conta no [^.]*' "$rend" | head -1)"; fail=1
fi
# Amarelo é claro: texto branco em cima seria ilegível. É o caso exato que o
# `#ffffff` fixo produzia — e é a marca que um revendedor cola sem avisar.
if grep -q 'color: #171f15' "$rend"; then
printf ' ✓ accent claro (#f2c94c) escolhe texto ESCURO\n'
else
printf ' ✗ accent claro não escolheu texto escuro: %s\n' "$(grep -o 'background: #f2c94c[^"]*' "$rend" | head -1)"; fail=1
fi
# A nota de manutenção do topo do modelo fala de placeholder e de --render-em:
# é endereçada a quem edita o repositório, não ao cliente final.
if grep -q 'MODELO — os' "$rend"; then
printf ' ✗ a nota interna do modelo foi junto para dentro do e-mail\n'; fail=1
else
printf ' ✓ a nota interna do modelo fica fora do e-mail\n'
fi
fi
# (5) O par claro/escuro: accent escuro tem de escolher BRANCO. Sem este caso,
# um `frente_sobre` que devolvesse sempre "#171f15" passaria no caso (4).
SUPABASE_ACCESS_TOKEN= APP_NAME='Marca Escura' APP_ACCENT_HEX='#0b3d2e' \
bash ./marca-emails.sh --env /dev/null --render-em "$ME_TMP/escuro" >/dev/null 2>&1
if grep -q 'color: #ffffff' "$ME_TMP/escuro/confirmation.html" 2>/dev/null; then
printf ' ✓ accent escuro (#0b3d2e) escolhe texto BRANCO\n'
else
printf ' ✗ accent escuro não escolheu texto branco\n'; fail=1
fi
# (6) Hex inválido no .env não pode virar CSS quebrado no e-mail: cai no accent
# do produto, que é o mesmo piso de lib/branding/saida.ts.
SUPABASE_ACCESS_TOKEN= APP_NAME='Marca Torta' APP_ACCENT_HEX='verde-limão' \
bash ./marca-emails.sh --env /dev/null --render-em "$ME_TMP/torto" >/dev/null 2>&1
if grep -q 'background: #506d48; background: #506d48' "$ME_TMP/torto/confirmation.html" 2>/dev/null; then
printf ' ✓ APP_ACCENT_HEX inválido cai no accent do produto\n'
else
printf ' ✗ APP_ACCENT_HEX inválido virou CSS inválido: %s\n' \
"$(grep -o 'background: [^;]*;' "$ME_TMP/torto/confirmation.html" 2>/dev/null | head -2 | tr '\n' ' ')"; fail=1
fi
rm -rf "$ME_TMP"
echo "proxy reverso: quem está com as portas 80/443"
# A versão anterior só sabia procurar Traefik. Qualquer outro proxy — inclusive o
# Caddy de OUTRO DeskcommCRM na mesma VPS — caía no ramo "portas livres", e a
@@ -1139,6 +1251,10 @@ STUB
# esconderia a trava que o dublê de crontab acima existe para não ter (era
# `cat >/dev/null` incondicional, e num tty a suíte nunca terminava). Com
# respostas, lê de um arquivo — é o único jeito de exercitar uma PERGUNTA.
# `SUPABASE_ACCESS_TOKEN=` no `env`: o install.sh chama o marca-emails.sh, e um
# token EXPORTADO no shell de quem roda a suíte entraria no cenário sem ninguém
# pedir — o teste passaria a depender da máquina, e faria chamada de rede a
# partir de um .env de mentira. O cenário declara o próprio ambiente.
rodar() {
local script="$1" flags="$2"
printf '%s\n%s\n' "$BASE_ENV" "${3-}" > "$VPS_PROJ/.env"
@@ -1146,9 +1262,11 @@ rodar() {
if [ $# -ge 4 ]; then
printf '%s' "$4" > "$VPS_RAIZ/respostas.txt"
(cd "$VPS_PROJ" && env PATH="$VPS_RAIZ/bin:$PATH" DOCKER_LOG="$VPS_LOG" CRONTAB_SANDBOX="$CRONTAB_SANDBOX" \
SUPABASE_ACCESS_TOKEN= \
bash "$VPS_RAIZ/$script" $flags <"$VPS_RAIZ/respostas.txt" 2>&1 || true) | sed -E 's/\x1b\[[0-9;]*m//g'
else
(cd "$VPS_PROJ" && env PATH="$VPS_RAIZ/bin:$PATH" DOCKER_LOG="$VPS_LOG" CRONTAB_SANDBOX="$CRONTAB_SANDBOX" \
SUPABASE_ACCESS_TOKEN= \
bash "$VPS_RAIZ/$script" $flags 2>&1 || true) | sed -E 's/\x1b\[[0-9;]*m//g'
fi
}
+18 -1
View File
@@ -9,7 +9,11 @@
# à que já está aqui (é o jeito explícito de voltar no tempo)
# --skip-backup pula o backup automático (não recomendado)
# --to <tag> instala essa tag em vez da mais recente publicada
source "$(dirname "$0")/_common.sh"
# Absoluto e resolvido ANTES do `enter_project`, que faz `cd`: depois dele um
# `dirname "$0"` relativo apontaria para o lugar errado, e o único sintoma seria
# um script do kit "não encontrado" no meio da atualização.
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
source "$KIT_DIR/_common.sh"
enter_project
FORCE=""; SKIP_BACKUP=""; TARGET_TAG=""
@@ -149,6 +153,19 @@ else
fi
[ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" banco
# ── 4.5 E-mails de acesso, para quem já estava instalado ────────────────────
# Só COM o token no ambiente, e por isso duas coisas:
#
# - é assim que um clone ANTIGO recebe os e-mails com a marca dele. O
# `install.sh` dele nunca chamou este passo (ele não existia), e nenhuma
# atualização toca em config de auth por conta própria;
# - sem o token, o script imprimiria o passo manual — útil UMA vez, na
# instalação, e ruído em toda atualização a partir daí. Atualização que
# resmunga toda vez ensina a ignorar a saída dela.
if [ -n "${SUPABASE_ACCESS_TOKEN:-}" ]; then
bash "$KIT_DIR/marca-emails.sh" --projeto "$PROJECT_DIR" || true
fi
# ── 5. App novo ──────────────────────────────────────────────────────────────
step "Baixando a versão nova do app e reiniciando"
# Imagem da TAG publicada (não "latest" solto): garante que o código (checkout
+7
View File
@@ -22,6 +22,13 @@ export const PUBLIC_PATHS: RegExp[] = [
/^\/api\/mcp(\/.*)?$/,
/^\/_next\//,
/^\/favicon\.ico$/,
// O ícone da aba (`app/icon.tsx`), que o `<head>` de TODA página pede —
// inclusive o do `/login`, antes de existir sessão. Precisa de entrada
// própria porque o matcher do `proxy.ts:128` só dispensa caminho COM
// extensão: `/favicon.ico` passa por ele, `/icon` não. Medido em produção
// antes desta linha: `GET /icon` → 307 para `/login?next=%2Ficon`, enquanto
// `/icon.png` (inexistente) devolvia 404 — a diferença é só a extensão.
/^\/icon$/,
/^\/team\/accept-invite\/.+$/,
/^\/account-suspended$/,
];
+59
View File
@@ -0,0 +1,59 @@
/**
* A cor da barra do navegador (`<meta name="theme-color">`) — LIDA da régua.
*
* ─── Por que NÃO é dinâmico, e por que trocar por `generateViewport()` erra ──
*
* A marca do operador não move o fundo da página. O serializador
* (`lib/branding/css.ts`) emite `--color-brand` e a família do accent
* (`--color-accent-{50..950}`, `-fg`, `-hover`, `-soft`) e mais nada — medido:
* `grep -c color-bg lib/branding/css.ts` devolve 1, e essa única ocorrência
* está dentro de um comentário sobre contraste. Logo `#faf9f6` / `#161510` são
* a cor CERTA da barra para qualquer marca, e um `generateViewport()` lendo o
* banco custaria uma leitura por requisição para devolver sempre a mesma
* constante. Isto está escrito aqui porque a leitura apressada ("a barra tem a
* cor do produto em todo clone!") é verdadeira e não é defeito, e sem o motivo
* registrado alguém a "conserta" de novo.
*
* ─── O defeito que este módulo conserta é DUPLICAÇÃO ────────────────────────
*
* Os dois hexes estavam escritos em três lugares — `app/layout.tsx`,
* `lib/branding/regua-do-produto.ts` e `app/globals.css` — sem nada os mantendo
* em sincronia. No dia em que o fundo do produto mudasse, a barra do navegador
* ficaria com a cor velha e nenhum gate reprovaria. Agora há uma fonte, e a
* régua é gerada do `globals.css` por `branding-regua-do-produto.test.ts`.
*
* ─── Nunca lança ────────────────────────────────────────────────────────────
*
* Isto roda no `viewport` do layout raiz: uma exceção aqui é 500 em TODA tela.
* Tema sem `--color-bg` na régua simplesmente não emite entrada, e o navegador
* usa o padrão dele — degrada em vez de derrubar. Quem reprova nesse caso é
* `tests/unit/branding-barra-do-navegador.test.ts`, no build, e não o usuário.
*/
import type { Regua, TemaDaRegua } from "./contraste";
/** O formato que `Viewport["themeColor"]` do Next aceita. */
export type CorDaBarra = { readonly media: string; readonly color: string };
/** A chave da superfície de fundo dentro de `TemaDaRegua.base`. */
const TOKEN_DE_FUNDO = "--color-bg";
function corDeFundo(tema: TemaDaRegua): string | undefined {
return tema.base.find((t) => t.chave === TOKEN_DE_FUNDO)?.hex;
}
/**
* As duas entradas de `theme-color`, na ordem claro → escuro.
*
* `flatMap` e não `map`: é o que descarta o tema sem cor sem precisar de `!`
* num campo que o tipo do Next declara obrigatório — o cast que a doutrina
* proíbe, e que aqui esconderia justamente a régua quebrada.
*/
export function coresDaBarraDoNavegador(regua: Regua): CorDaBarra[] {
return (
[
["(prefers-color-scheme: light)", corDeFundo(regua.claro)],
["(prefers-color-scheme: dark)", corDeFundo(regua.escuro)],
] as const
).flatMap(([media, color]) => (color ? [{ media, color }] : []));
}
+43
View File
@@ -0,0 +1,43 @@
/**
* A letra que vai no ícone da aba — e o caso em que NÃO vai letra nenhuma.
*
* Mora fora de `app/icon.tsx` de propósito. O loader de metadata do Next
* re-exporta TODO named export do arquivo do usuário para o módulo de rota
* gerado (`next-metadata-route-loader.js:43`, exceto `default`,
* `generateSitemaps` e `dynamicParams`), então uma função auxiliar exportada
* de lá viraria export de rota — e export desconhecido em módulo de rota é
* justamente o que o Next reprova no build. Aqui ela é uma função pura,
* testável sem subir renderizador de imagem nenhum.
*
* ─── Por que não dá para reusar `resolveBranding().initial` direto ──────────
*
* `resolveBranding` (`lib/branding.ts:47-50`) usa spread em vez de `[0]` para
* não partir code point — e por isso devolve o EMOJI quando a marca começa com
* um ("🚀 Foguete" → "🚀"). Na sidebar isso é o comportamento certo: o browser
* tem as fontes de emoji. No ícone não: quem desenha é o satori (`next/og`),
* que só carrega a fonte embutida `Geist-Regular.ttf` e renderiza QUALQUER
* glifo ausente como tofu (▯). Buscar uma fonte de emoji resolveria — e faria
* o `<head>` de toda página depender de uma requisição de rede, que é
* exatamente o que o ícone gerado existe para evitar.
*
* Então a regra é: a primeira LETRA OU DÍGITO do nome. Não havendo nenhuma
* (marca só de emoji, ou só de pontuação), devolve `null` — e quem chama
* desenha o ladrilho do accent SEM letra. Cair na inicial do produto seria
* pior que não ter letra: a aba do revendedor mostraria a NOSSA letra, que é o
* defeito de white-label que este épico inteiro existe para fechar.
*/
/**
* A primeira letra ou dígito de `nome`, em caixa alta. `null` quando o nome não
* tem nenhum — aí o ícone é só a cor da marca.
*
* `\p{L}` e `\p{N}` com a flag `u`, não `[A-Za-z0-9]`: "Ótimo CRM" tem de
* render "Ó", e "Ácaro" tem de render "Á". Restringir ao ASCII descartaria a
* primeira letra de boa parte dos nomes em português e devolveria a segunda.
*/
export function letraDoIcone(nome: string): string | null {
for (const caractere of nome.trim()) {
if (/\p{L}|\p{N}/u.test(caractere)) return caractere.toUpperCase();
}
return null;
}
+1 -1
View File
@@ -11848,7 +11848,7 @@ create table if not exists public.platform_branding (
);
comment on table public.platform_branding is
'Marca da INSTALAÇÃO (login, e-mail, 500) — linha única id=1. Semeada do .env na primeira leitura; o .env continua sendo a rede de segurança de rollback. Lida/escrita só server-side (service_role). Ver lib/branding/instalacao.ts.';
'Marca da INSTALAÇÃO (login, e-mail, 500) — linha única id=1. Semeada do .env na primeira leitura; para NOME e LOGO o .env continua sendo a rede de segurança de rollback (o agent.sh reverte a imagem, não o banco). Para COR não há rede: APP_ACCENT_HEX nasceu junto com esta tabela e o install.sh não o grava — nenhuma versão que desconheça platform_branding pinta accent. Lida/escrita só server-side (service_role). Ver lib/branding/instalacao.ts.';
comment on column public.platform_branding.seeded_from_env is
'true = os valores vieram do .env e ninguém os editou pela tela. A escrita humana zera isto, e é o que impede a semeadura de reescrever o que uma pessoa apagou de propósito.';
@@ -19,6 +19,27 @@
-- `.env` intacto, a marca degrada para o valor da instalação e a tela continua
-- sendo a do cliente.
--
-- ⚠️ CORREÇÃO DE 2026-08-14 — a rede cobre NOME E LOGO, não a cor.
--
-- O parágrafo acima foi escrito como se valesse para as três colunas. Vale para
-- duas. `install.sh` grava `APP_NAME` e `APP_LOGO_URL` no `.env` e NÃO grava
-- `APP_ACCENT_HEX` (medido: `grep -c APP_ACCENT_HEX hostgator-setup-kit/install.sh`
-- → 0, contra 7 de `APP_NAME`) — a chave só existe em `.env.example`, e o `.env`
-- é escrito com truncamento, então quem a puser à mão a perde no install
-- seguinte.
--
-- E, mais fundo que isso: para COR a rede é vazia POR CONSTRUÇÃO, não por
-- descuido. `APP_ACCENT_HEX` nasceu neste mesmo épico, junto com esta tabela.
-- Não existe versão do app velha o bastante para desconhecer `platform_branding`
-- e ainda assim pintar accent a partir do `.env` — quem não conhece a tabela
-- também não conhece a variável. Gravar a cor no `.env` seria semente e proteção
-- contra o truncamento do install, não rollback.
--
-- O `comment on table` do `supabase/baseline.sql` já carrega a redação
-- corrigida. Aqui embaixo ele fica como foi aplicado: migration já aplicada não
-- se reescreve, corrige-se por forward-fix — e nesta linha a diferença é de
-- redação, não de comportamento.
--
-- ─── Singleton, e por que ele é uma linha e não uma coluna em `organizations` ─
--
-- DIRC responde **D — Duplicar? Não; vive aqui mesmo**. Esta é a marca da
+23 -8
View File
@@ -1,18 +1,33 @@
<!--
MODELO — os `__PLACEHOLDER__` são substituídos por hostgator-setup-kit/marca-emails.sh,
que lê APP_NAME e APP_ACCENT_HEX do .env da instalação e sobe o resultado pela
Management API do Supabase (PATCH /v1/projects/{ref}/config/auth).
Quem roda GoTrue self-hosted (GOTRUE_MAILER_TEMPLATES_CONFIRMATION) NÃO pode
apontar para ESTE arquivo: ele carrega placeholder. Renderize primeiro —
`bash hostgator-setup-kit/marca-emails.sh --render-em /caminho/` — e aponte
para o arquivo gerado.
As cores do botão vêm em PAR (`background: <fallback>; background: __ACCENT__;`)
de propósito: declaração com valor inválido é DESCARTADA pelo CSS, então o
arquivo cru — o que o Supabase local usa em dev e no CI — renderiza com o
accent do produto em vez de um botão transparente. É a única forma de o
modelo ser legível antes de ser substituído.
`&`, nunca `?`: ver o comentário equivalente em recovery.html. `.RedirectTo`
já chega com `?type=signup` embutido (app/actions/auth/signUp.ts).
-->
<html>
<body style="font-family: sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
<body style="font-family: sans-serif; color: #1c1a16; background: #faf9f6; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2 style="font-size: 18px;">Confirme seu e-mail</h2>
<p>Sua conta no DeskcommCRM está quase pronta. Clique no botão abaixo para confirmar seu e-mail e ativar sua conta:</p>
<!--
`&`, nunca `?`: ver comentário equivalente em recovery.html. `.RedirectTo`
já chega com `?type=signup` embutido (app/actions/auth/signUp.ts).
-->
<p>Sua conta no __APP_NAME__ está quase pronta. Clique no botão abaixo para confirmar seu e-mail e ativar sua conta:</p>
<p style="margin: 24px 0;">
<a
href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}"
style="background: #4a5d52; color: #ffffff; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
style="background: #506d48; background: __ACCENT__; color: #ffffff; color: __ACCENT_FG__; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
>Confirmar e-mail</a
>
</p>
<p style="font-size: 12px; color: #666;">Se você não criou esta conta, ignore este e-mail.</p>
<p style="font-size: 12px; color: #7d786c;">Se você não criou esta conta, ignore este e-mail.</p>
</body>
</html>
+18 -14
View File
@@ -1,24 +1,28 @@
<!--
MODELO — os `__PLACEHOLDER__` são substituídos por hostgator-setup-kit/marca-emails.sh.
Ver o cabeçalho de confirmation.html para o par de cores, para o caminho do
GoTrue self-hosted (`--render-em`) e para quem faz a substituição.
`&`, nunca `?`: `.RedirectTo` já chega com `?type=recovery` embutido
(app/actions/auth/requestPasswordReset.ts) — é o que sobrevive ao hop pelo
GoTrue quando o link chega via PKCE (`code`) em vez de token_hash.
Usar `?` aqui duplica o separador (`...?type=recovery?token_hash=...`),
o parser de URL do browser para de reconhecer `token_hash` como
parâmetro (vira parte do valor de `type`), e /auth/confirm redireciona
pra `/login?error=link_invalido` mesmo com o link correto — medido nas
specs password-recovery.spec.ts e signup-journey.spec.ts.
-->
<html>
<body style="font-family: sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
<body style="font-family: sans-serif; color: #1c1a16; background: #faf9f6; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2 style="font-size: 18px;">Redefinir senha</h2>
<p>Recebemos um pedido para redefinir a senha da sua conta no DeskcommCRM. Clique no botão abaixo para escolher uma nova senha:</p>
<!--
`&`, nunca `?`: `.RedirectTo` já chega com `?type=recovery` embutido
(app/actions/auth/requestPasswordReset.ts) — é o que sobrevive ao hop
pelo GoTrue quando o link chega via PKCE (`code`) em vez de token_hash.
Usar `?` aqui duplica o separador (`...?type=recovery?token_hash=...`),
o parser de URL do browser para de reconhecer `token_hash` como
parâmetro (vira parte do valor de `type`), e /auth/confirm redireciona
pra `/login?error=link_invalido` mesmo com o link correto — medido nas
specs password-recovery.spec.ts e signup-journey.spec.ts.
-->
<p>Recebemos um pedido para redefinir a senha da sua conta no __APP_NAME__. Clique no botão abaixo para escolher uma nova senha:</p>
<p style="margin: 24px 0;">
<a
href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}"
style="background: #4a5d52; color: #ffffff; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
style="background: #506d48; background: __ACCENT__; color: #ffffff; color: __ACCENT_FG__; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
>Redefinir senha</a
>
</p>
<p style="font-size: 12px; color: #666;">Se você não pediu a redefinição, ignore este e-mail — sua senha atual continua valendo.</p>
<p style="font-size: 12px; color: #7d786c;">Se você não pediu a redefinição, ignore este e-mail — sua senha atual continua valendo.</p>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
import { test, expect } from "@playwright/test";
/**
* A aba do navegador — o único pedaço da marca que aparece em TODA tela,
* inclusive antes de existir sessão, e o único que nenhum teste vigiava.
*
* Antes desta spec: `grep -rn 'toHaveTitle|page.title()' tests/e2e/` devolvia
* vazio e nenhum teste citava favicon ou `generateMetadata`. Quem mexesse no
* `template` do título ou no `<head>` do layout não era avisado por gate
* nenhum — num repositório que já pagou a lição de que rótulo visível é
* contrato.
*
* Tudo aqui roda DESLOGADO de propósito. É o estado do comprador que acabou de
* instalar e abriu a primeira tela: se o ícone responder 307 para o login (era
* o comportamento antes de `/icon` entrar em PUBLIC_PATHS), a marca dele não
* aparece exatamente na primeira impressão. E, por não precisar de login, esta
* spec não consome do teto de 60 logins/IP/300s que a suíte compartilha.
*/
test.describe("o ícone e o título carregam a marca da instalação", () => {
test("GET /icon responde imagem para quem não entrou", async ({ request }) => {
const res = await request.get("/icon", { maxRedirects: 0 });
// 307 aqui significa que o proxy interceptou: o matcher de `proxy.ts` só
// dispensa caminho COM extensão, e `/icon` não tem nenhuma.
expect(
res.status(),
"esperava a imagem; 307 significa que /icon caiu no redirect para /login",
).toBe(200);
expect(res.headers()["content-type"]).toMatch(/^image\//);
// O corpo tem de ser um PNG DE VERDADE. Sem esta asserção, um handler que
// devolvesse 200 com corpo vazio (ou com o HTML de erro do Next) passaria.
const corpo = await res.body();
expect(corpo.byteLength).toBeGreaterThan(100);
expect([...corpo.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]);
});
test("o <head> do login declara o ícone — é o que mata o pedido a /favicon.ico", async ({
page,
}) => {
await page.goto("/login");
const href = await page
.locator('link[rel~="icon"]')
.first()
.getAttribute("href");
expect(href, "nenhum <link rel=icon> no <head>").toBeTruthy();
// `/icon` com ou sem query de cache-busting do Next.
expect(new URL(href ?? "", "http://x").pathname).toBe("/icon");
});
test("o título da aba herda a marca resolvida, e é a MESMA que a tela mostra", async ({
page,
}) => {
await page.goto("/login");
const titulo = await page.title();
// `template: "%s · ${name}"` no layout raiz: a página filha declara só
// "Entrar" e herda o sufixo. Um layout que perdesse o template deixaria o
// título como "Entrar" pelado, e ninguém notaria.
const casou = /^Entrar · (.+)$/.exec(titulo);
expect(casou, `título fora do formato "Entrar · <marca>": ${titulo}`).not.toBeNull();
const marcaNoTitulo = casou?.[1] ?? "";
expect(marcaNoTitulo.length).toBeGreaterThan(0);
// Cruza DUAS resoluções independentes: o título vem de `generateMetadata`
// (que lê `platform_branding` no banco) e o texto sob o "Entrar" vem de
// `branding()` (que lê o `.env`). Divergirem é defeito de verdade — foi
// por não cruzar isso que "trocar o nome pela tela e a aba não acompanhar"
// passou despercebido antes.
await expect(page.getByText(marcaNoTitulo, { exact: true }).first()).toBeVisible();
});
});
@@ -0,0 +1,118 @@
/**
* A RECUSA DE UM LINK DE E-MAIL PRECISA DIZER QUAL DAS DUAS COISAS QUEBROU.
*
* `/auth/confirm` aceita dois formatos de link e, até 2026-08-14, mandava os
* dois para a MESMA tela: `/login?error=link_invalido` → "Link inválido ou
* expirado. Peça um novo em Recuperar senha". Isso é conselho certo para um
* caso e conselho ERRADO para o outro:
*
* - `token_hash` (nossos templates): o link realmente expirou ou já foi usado.
* Pedir outro resolve.
* - `code` (template PADRÃO do Supabase): o link chega por PKCE, o verificador
* vive num cookie `sameSite: "strict"` (`lib/supabase/server.ts:35`, aplicado
* a TODO cookie do cliente — `@supabase/ssr/cookies.js:227,232`), e clique
* vindo de webmail é navegação cross-site: o cookie não viaja. Pedir outro
* link NÃO resolve — cada novo link falha igual. O conserto é configurar os
* templates (`hostgator-setup-kit/marca-emails.sh`).
*
* O sintoma enganoso mandava o operador caçar TTL e relógio do servidor. Este
* teste prende a distinção nos DOIS sentidos: sem o segundo caso, alguém
* "simplifica" os dois ramos de volta para uma mensagem só; sem o primeiro,
* alguém troca tudo por `template_padrao` e passa a acusar de configuração o
* link que de fato expirou.
*/
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { audit } from "@/lib/audit";
import { createClient } from "@/lib/supabase/server";
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => undefined) }));
vi.mock("@/lib/auth/provision", () => ({ ensureTenantForUser: vi.fn(async () => undefined) }));
vi.mock("@/lib/env", () => ({ env: { NEXT_PUBLIC_APP_URL: "https://crm.exemplo.com.br" } }));
import { GET } from "@/app/auth/confirm/route";
type Resultado = { data: { user: unknown }; error: { message: string } | null };
const RECUSA: Resultado = { data: { user: null }, error: { message: "Token has expired" } };
function supabaseQue(resposta: Resultado) {
const verifyOtp = vi.fn(async () => resposta);
const exchangeCodeForSession = vi.fn(async () => resposta);
vi.mocked(createClient).mockResolvedValue({
auth: { verifyOtp, exchangeCodeForSession },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
return { verifyOtp, exchangeCodeForSession };
}
const chamar = (query: string) =>
GET(new NextRequest(`https://crm.exemplo.com.br/auth/confirm${query}`));
/** O `Location` do redirect, sem o host. */
async function destino(query: string): Promise<string> {
const res = await chamar(query);
return new URL(res.headers.get("location") ?? "").search;
}
describe("/auth/confirm nomeia a causa da recusa", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("token_hash que falhou continua sendo `link_invalido` — pedir outro resolve", () => {
supabaseQue(RECUSA);
return expect(destino("?token_hash=abc&type=recovery")).resolves.toBe("?error=link_invalido");
});
it("code que falhou vira `template_padrao` — pedir outro NÃO resolve", async () => {
supabaseQue(RECUSA);
expect(await destino("?code=pkce_abc")).toBe("?error=template_padrao");
});
it("link sem token nenhum continua `link_invalido` e não chama o Supabase", async () => {
const { verifyOtp, exchangeCodeForSession } = supabaseQue(RECUSA);
expect(await destino("")).toBe("?error=link_invalido");
expect(verifyOtp).not.toHaveBeenCalled();
expect(exchangeCodeForSession).not.toHaveBeenCalled();
});
it("o audit registra QUAL formato falhou — sem isso a triagem começa do zero", async () => {
supabaseQue(RECUSA);
await chamar("?code=pkce_abc");
expect(vi.mocked(audit)).toHaveBeenCalledWith(
expect.objectContaining({
action: "auth.email_link_rejected",
metadata: expect.objectContaining({ formato: "code" }),
}),
);
vi.clearAllMocks();
supabaseQue(RECUSA);
await chamar("?token_hash=abc&type=signup");
expect(vi.mocked(audit)).toHaveBeenCalledWith(
expect.objectContaining({
action: "auth.email_link_rejected",
metadata: expect.objectContaining({ formato: "token_hash" }),
}),
);
});
it("a tela do login tem texto para o código novo — senão a recusa fica muda", () => {
// Sem este caso, `?error=template_padrao` chegaria a uma página que só
// conhece `link_invalido` e `provisionamento`: nenhum aviso renderiza, e o
// usuário vê a tela de login limpa, como se nada tivesse acontecido.
// É o modo de falha mais silencioso possível — pior que a mensagem errada.
const fonte = fs.readFileSync(
path.join(process.cwd(), "app/(public)/login/page.tsx"),
"utf8",
);
expect(fonte).toContain('error === "template_padrao"');
expect(fonte).toContain("marca-emails.sh");
});
});
@@ -0,0 +1,83 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
coresDaBarraDoNavegador,
type CorDaBarra,
} from "@/lib/branding/barra-do-navegador";
import type { Regua, TemaDaRegua } from "@/lib/branding/contraste";
import { REGUA_DO_PRODUTO } from "@/lib/branding/regua-do-produto";
const RAIZ = process.cwd();
/**
* A barra do navegador é a única cor do produto que aparece ANTES de o HTML
* pintar — e a que nenhuma tela mostra, então ninguém a olha. Estes casos
* existem por dois motivos distintos:
*
* 1. os dois hexes viviam em TRÊS arquivos sem nada os sincronizando. Este
* teste é o que passa a reprovar a quarta cópia;
* 2. a leitura errada ("a barra fica com a cor do produto em todo clone — é
* defeito de white-label!") é convidativa e FALSA: `cssDaMarca` nunca emite
* `--color-bg`. Sem um caso que registre isso, alguém troca `viewport` por
* `generateViewport()` e paga uma leitura de banco por requisição para
* devolver sempre a mesma constante.
*/
describe("cor da barra do navegador", () => {
const bg = (tema: TemaDaRegua) =>
tema.base.find((t) => t.chave === "--color-bg")?.hex;
it("as duas entradas saem da régua, não de literal digitado", () => {
const cores = coresDaBarraDoNavegador(REGUA_DO_PRODUTO);
expect(cores).toEqual<CorDaBarra[]>([
{
media: "(prefers-color-scheme: light)",
// Sem `!`: se a régua perder o token, `toEqual` compara com `undefined`
// e o caso reprova — que é o desfecho certo.
color: bg(REGUA_DO_PRODUTO.claro) as string,
},
{
media: "(prefers-color-scheme: dark)",
color: bg(REGUA_DO_PRODUTO.escuro) as string,
},
]);
});
it("a régua de hoje tem os dois fundos — senão o caso acima seria vazio", () => {
// Guarda de vacuidade: sem ela, uma régua sem `--color-bg` faria o teste
// acima comparar `[]` com `[]` e passar como se estivesse vigiando.
expect(bg(REGUA_DO_PRODUTO.claro)).toMatch(/^#[0-9a-f]{6}$/);
expect(bg(REGUA_DO_PRODUTO.escuro)).toMatch(/^#[0-9a-f]{6}$/);
expect(bg(REGUA_DO_PRODUTO.claro)).not.toBe(bg(REGUA_DO_PRODUTO.escuro));
});
it("tema sem `--color-bg` some da lista em vez de derrubar toda tela", () => {
// `viewport` roda no layout raiz: lançar aqui é 500 em TODA página, para
// todo mundo, por causa de um token de cor. Degradar é a escolha certa —
// e é o caso acima que impede que a degradação passe despercebida.
const semFundo: Regua = {
...REGUA_DO_PRODUTO,
escuro: { ...REGUA_DO_PRODUTO.escuro, base: [] },
};
const cores = coresDaBarraDoNavegador(semFundo);
expect(cores).toHaveLength(1);
expect(cores[0]?.media).toBe("(prefers-color-scheme: light)");
});
it("o layout não redigita os hexes de fundo — a quarta cópia reprova aqui", () => {
// O teste acima continuaria verde se alguém escrevesse
// `themeColor: [{ media: "…", color: "#faf9f6" }]` de volta no layout: o
// valor seria o mesmo. O que fecha esse buraco é olhar o TEXTO do arquivo.
const layout = fs.readFileSync(path.join(RAIZ, "app/layout.tsx"), "utf8");
for (const tema of [REGUA_DO_PRODUTO.claro, REGUA_DO_PRODUTO.escuro]) {
const hex = bg(tema);
expect(hex).toBeTruthy();
expect(
layout.includes(hex as string),
`app/layout.tsx voltou a escrever ${hex} à mão. A cor da barra tem UMA ` +
`fonte: REGUA_DO_PRODUTO, via coresDaBarraDoNavegador().`,
).toBe(false);
}
});
});
+91
View File
@@ -0,0 +1,91 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isPublicPath, PUBLIC_PATHS } from "@/lib/auth/public-paths";
import { letraDoIcone } from "@/lib/branding/icone";
const RAIZ = process.cwd();
describe("letra do ícone da aba", () => {
it("usa a primeira letra do nome, em caixa alta", () => {
expect(letraDoIcone("Vendas Turbo")).toBe("V");
expect(letraDoIcone(" acme crm ")).toBe("A");
});
it("aceita acento — restringir ao ASCII devolveria a segunda letra", () => {
// "Ótimo CRM" com `[A-Za-z]` renderizaria "T". Em português isso não é
// borda: é uma fatia grande dos nomes possíveis.
expect(letraDoIcone("Ótimo CRM")).toBe("Ó");
expect(letraDoIcone("Ácaro")).toBe("Á");
});
it("pula o emoji e pega a primeira letra de verdade", () => {
// `resolveBranding().initial` devolveria "🚀" — certo na sidebar, onde o
// browser tem fonte de emoji; errado no ícone, que o satori desenha com a
// única fonte embutida e renderizaria como tofu (▯).
expect(letraDoIcone("🚀 Foguete")).toBe("F");
expect(letraDoIcone("★ Estrela")).toBe("E");
});
it("devolve null quando não há letra nenhuma — e NÃO a inicial do produto", () => {
// Cair em "D" aqui poria a NOSSA letra na aba do revendedor, que é
// exatamente o vazamento de marca que o épico fecha. Sem letra, o ícone é
// só o ladrilho da cor dele.
expect(letraDoIcone("🚀")).toBeNull();
expect(letraDoIcone(" ")).toBeNull();
expect(letraDoIcone("···")).toBeNull();
});
it("dígito conta como letra — marca que começa com número tem ícone", () => {
expect(letraDoIcone("360 Vendas")).toBe("3");
});
});
describe("o ícone carrega para quem NÃO entrou", () => {
it("/icon é caminho público", () => {
// Sem esta entrada o `<head>` do /login pede /icon, o proxy responde 307
// para /login?next=%2Ficon, e a aba fica sem marca justamente na primeira
// tela que um comprador vê. O matcher do proxy só dispensa caminho COM
// extensão — medido: /icon.png devolvia 404 (passou direto) e /icon, 307.
expect(isPublicPath("/icon")).toBe(true);
});
it("a entrada é ancorada — não abre /icon-secreto nem /admin/icon", () => {
// Regex de caminho público sem âncora é como allowlist vira buraco.
expect(isPublicPath("/iconografia")).toBe(false);
expect(isPublicPath("/admin/icon")).toBe(false);
expect(isPublicPath("/icon/../app")).toBe(false);
});
it("a rota existe no disco com o nome que a regex espera", () => {
// Guarda de vacuidade: a asserção acima passaria com a regex presente e o
// arquivo ausente — allowlist para uma rota que não existe é config morta,
// e o favicon continuaria em 404 com o teste verde.
expect(fs.existsSync(path.join(RAIZ, "app/icon.tsx"))).toBe(true);
// Na RAIZ de `app/`, nunca dentro de route group: `getMetadataRouteSuffix`
// acrescenta um hash djb2 de 6 caracteres quando algum segmento pai é
// group ou parallel route, e `app/(public)/icon.tsx` seria servido em
// `/icon-<hash>` — a regex acima erraria em silêncio.
expect(PUBLIC_PATHS.some((re) => re.source === String.raw`^\/icon$`)).toBe(true);
});
it("o ícone é gerado em runtime, nunca congelado no build", () => {
// Sem `force-dynamic` o `next build` resolve o ícone UMA vez e a marca de
// quem buildou vai dentro da imagem — que é uma só para todos os clones.
// O defeito é invisível em dev, em teste e na Vercel: só aparece na VPS do
// revendedor. Por isso a asserção é sobre o TEXTO: é uma linha que some num
// refactor sem nada mais quebrar.
const icone = fs.readFileSync(path.join(RAIZ, "app/icon.tsx"), "utf8");
expect(icone).toMatch(/export const dynamic\s*=\s*"force-dynamic"/);
// E a marca tem de vir do resolvedor, não de literal.
expect(icone).toMatch(/marcaDaSaida\(null\)/);
});
it("o layout declara o ícone — é o que mata o pedido a /favicon.ico", () => {
// O 404 de /favicon.ico não é barato: em produção ele devolve a
// `app/not-found.tsx` inteira (19.435 bytes) para um pedido de ícone.
const layout = fs.readFileSync(path.join(RAIZ, "app/layout.tsx"), "utf8");
expect(layout).toMatch(/icons:\s*\{\s*icon:\s*"\/icon"\s*\}/);
});
});
+125
View File
@@ -444,3 +444,128 @@ describe("catraca de marca hardcoded", () => {
expect(semFase, `DIVIDA sem fase, ou fase declarada onde não é dívida:\n ${semFase.join("\n ")}`).toEqual([]);
});
});
/**
* A SEGUNDA varredura: os arquivos que o GoTrue lê, e que a primeira nunca viu.
*
* A catraca de cima varre `.ts`/`.tsx` de `app|components|hooks|lib|workers` —
* o código que embarca na imagem. Ela é cega para `supabase/templates/*.html` e
* `supabase/config.toml`, e essa cegueira tinha consequência medida: dava para
* zerar a lista de dívidas, ver a suíte inteira verde, e o cliente do
* revendedor continuar recebendo "Confirme seu e-mail — DeskcommCRM" no
* PRIMEIRO e-mail que ele abre na vida.
*
* Estes arquivos não são renderizados por nenhum TypeScript nosso: quem os
* renderiza é o GoTrue, um processo de terceiro. Não há resolvedor a chamar —
* o texto é empurrado por API pelo `hostgator-setup-kit/marca-emails.sh`, que
* substitui os `__PLACEHOLDER__`. Por isso a guarda aqui é diferente em
* NATUREZA da de cima: lá ela cobra `branding()`; aqui ela cobra placeholder.
*
* ⚠️ A REGRA DE COMENTÁRIO NÃO SE REAPROVEITA. `marcasNoTexto` ignora linha que
* ABRE com `//`, `*` ou `/*`. Comentário de HTML é `<!-- … -->`, atravessa
* várias linhas e as linhas do meio não abrem com nada. Copiar a função sem
* ajustar faria a marca DENTRO de um comentário contar como vazamento e a marca
* de verdade, na mesma linha de um `-->`, passar.
*/
describe("catraca de marca no que o GoTrue renderiza", () => {
/** Comentário de HTML é um TRECHO, não um prefixo de linha. */
function semComentariosHtml(fonte: string): string {
return fonte.replace(/<!--[\s\S]*?-->/g, "");
}
/**
* TOML: só linha que ABRE com `#`. Um `#` no meio da linha não é tratado como
* comentário DE PROPÓSITO — `#506d48` dentro de uma string seria decapitado, e
* uma marca depois dele sumiria. Contar demais aqui custa uma linha de
* allowlist; contar de menos custa a marca vazando com o gate verde.
*/
function semComentariosToml(fonte: string): string {
return fonte
.split("\n")
.filter((linha) => !linha.trimStart().startsWith("#"))
.join("\n");
}
const ALVOS: { arquivo: string; limpar: (f: string) => string }[] = [
{ arquivo: "supabase/templates/confirmation.html", limpar: semComentariosHtml },
{ arquivo: "supabase/templates/recovery.html", limpar: semComentariosHtml },
{ arquivo: "supabase/config.toml", limpar: semComentariosToml },
];
const CONGELADO_SUPABASE: Record<string, EntradaDeMarca> = {
"supabase/config.toml": {
categoria: "DEV",
motivo:
"config do Supabase LOCAL (o `supabase start` de dev e do CI). NÃO embarca na imagem e NÃO alcança clone nenhum: um self-hoster usa um projeto na nuvem do Supabase, cuja config de auth vem do marca-emails.sh, ou um GoTrue próprio, que lê env. `project_id` ainda nomeia os contêineres locais (supabase_auth_deskcomm-crm) e os assuntos são o que a suíte local envia",
marcas: ["deskcomm-crm", "deskcommcrm", "deskcommcrm"],
},
};
const encontradoAqui = new Map<string, string[]>();
for (const { arquivo, limpar } of ALVOS) {
const marcas = marcasNoTexto(limpar(fs.readFileSync(path.join(RAIZ, arquivo), "utf8")));
if (marcas.length > 0) encontradoAqui.set(arquivo, marcas);
}
it("os arquivos varridos existem e os modelos têm placeholder", () => {
// Vacuidade em duas pontas. Se os arquivos sumissem de lugar, a varredura
// devolveria vazio e os casos abaixo passariam vigiando o nada; e se os
// modelos perdessem o `__APP_NAME__`, "sem marca" passaria a significar
// "sem nome nenhum no e-mail", que é outro defeito com o mesmo sintoma.
for (const { arquivo } of ALVOS) {
expect(fs.existsSync(path.join(RAIZ, arquivo)), `${arquivo} sumiu`).toBe(true);
}
for (const modelo of ["supabase/templates/confirmation.html", "supabase/templates/recovery.html"]) {
const texto = fs.readFileSync(path.join(RAIZ, modelo), "utf8");
expect(texto, `${modelo} não substitui a marca`).toContain("__APP_NAME__");
expect(texto, `${modelo} não substitui o accent`).toContain("__ACCENT__");
}
});
it("comentário de HTML não conta, e `-->` no meio da linha não engole o resto", () => {
expect(marcasNoTexto(semComentariosHtml("<!-- fala do DeskcommCRM -->"))).toEqual([]);
expect(marcasNoTexto(semComentariosHtml("<!--\n DeskcommCRM\n em várias linhas\n-->"))).toEqual([]);
// O caso que a regra de `//` erraria: marca REAL depois do fecho.
expect(marcasNoTexto(semComentariosHtml("<!-- nota --> Sua conta no DeskcommCRM"))).toEqual([
"deskcommcrm",
]);
// E a marca fora de comentário nenhum continua contando.
expect(marcasNoTexto(semComentariosHtml("<p>conta no DeskcommCRM</p>"))).toEqual(["deskcommcrm"]);
});
it("comentário de TOML não conta, mas `#` dentro de string não vira comentário", () => {
expect(marcasNoTexto(semComentariosToml("# Supabase CLI config — DeskcommCRM"))).toEqual([]);
expect(marcasNoTexto(semComentariosToml('cor = "#506d48" # DeskcommCRM'))).toEqual([
"deskcommcrm",
]);
expect(marcasNoTexto(semComentariosToml('subject = "Olá — DeskcommCRM"'))).toEqual(["deskcommcrm"]);
});
it("nenhum arquivo do GoTrue fixa a marca fora da lista", () => {
const novos = [...encontradoAqui.keys()].filter((f) => !(f in CONGELADO_SUPABASE));
expect(
novos,
`Marca hardcoded em arquivo que o GoTrue renderiza.\n` +
`Use o placeholder __APP_NAME__ (quem substitui é hostgator-setup-kit/marca-emails.sh):\n` +
novos.map((f) => ` ${f} ${JSON.stringify(encontradoAqui.get(f))}`).join("\n"),
).toEqual([]);
});
it("a lista do GoTrue não guarda arquivo que já não tem marca", () => {
const obsoletos = Object.keys(CONGELADO_SUPABASE).filter((f) => !encontradoAqui.has(f));
expect(obsoletos, `apague a linha destes de CONGELADO_SUPABASE:\n ${obsoletos.join("\n ")}`).toEqual([]);
});
it("arquivo congelado do GoTrue não mudou de conjunto sem a lista acompanhar", () => {
for (const [arquivo, entrada] of Object.entries(CONGELADO_SUPABASE)) {
expect(encontradoAqui.get(arquivo) ?? [], arquivo).toEqual([...entrada.marcas].sort());
}
});
it("os dois modelos de e-mail não têm marca nenhuma — é o estado que se defende", () => {
// Explícito, e não só implícito na ausência de linha na allowlist: é ESTE
// caso que falha quando alguém reescreve "no DeskcommCRM" num template.
expect(encontradoAqui.has("supabase/templates/confirmation.html")).toBe(false);
expect(encontradoAqui.has("supabase/templates/recovery.html")).toBe(false);
});
});