Ajustes de RLS/anon, migração de mocks e UX do Revista UNA/Mercado/Notícias (TEC-545)
Conjunto de correções levantadas na revisão do TEC-545, cobrindo o módulo Revista UNA/Mercado da Intranet e as páginas públicas correspondentes (/revista-una, /mercado, /noticias) no site da Abracaf.
Bug de acesso anônimo em HML
As páginas públicas (/noticias, /mercado, /revista-una) retornavam vazio em HML mesmo com dados publicados, apesar das policies *_select_anon estarem corretas. Causa raiz: o bootstrap do MakerKit (00-privileges.sql) revoga tudo de anon e só reconcede GRANT USAGE ON SCHEMA public pra authenticated/service_role — sem essa grant no schema, nenhuma policy ou grant de coluna pra anon tem efeito, em nenhuma tabela. Corrigido com grant usage on schema public to anon;.
Um segundo problema, só aparente depois do primeiro corrigido: a coluna deleted_at de blog_contents/publications não está no grant select (...) de anon — referenciá-la em qualquer lugar da query (inclusive WHERE) quebra a query inteira com "permission denied", mesmo em linhas públicas, porque o Postgres exige privilégio de coluna em qualquer coluna referenciada. Resolvido removendo o filtro .is('deleted_at', null) do client nas três queries públicas (blog.queries.ts, revista.queries.ts, publicacoes.queries.ts) — a RLS (*_select_anon) já exige deleted_at is null na própria cláusula USING, então o resultado é o mesmo sem repetir o filtro.
ABRACAF_INTRANET_ACCOUNT_ID (usado pelas três queries pra escopar a conta cross-produto) passou de valor hardcoded pra variável de ambiente (config/abracaf.config.ts), com fallback pro UUID do seed local — precisa ser configurada em HML/produção.
Migração de /noticias pro Supabase
/noticias e /noticias/[slug] liam de um mock estático (posts.data.ts, 3 tags fixas: Mercado/Legislação/Economia). Migrados pra blog_contents real (a mesma tabela do Blog da Intranet) via fetchAbracafBlogPosts/fetchAbracafBlogPostBySlug em blog.queries.ts:
- Tópicos dinâmicos: a taxonomia fixa (
PostTag/TAG_COLORS) foi substituída por tópicos reais (blog_topics/blog_content_topics) — nenhuma das duas tabelas tinha grant/policy praanonantes; adicionadosgrant select (id, name)/grant select (content_id, topic_id)+ policies*_select_anonque só referenciamblog_contents.id(deixando a RLS deblog_contentsfiltrar visibilidade/status/deleted_atautomaticamente, evitando repetir a armadilha dodeleted_at). Cor do rótulo de tópico agora é determinística por hash (topic-color.ts), já que o conjunto de tópicos não é mais conhecido de antemão. - Conteúdo real:
blog-article.tsxrenderizava um corpo/citação mockados (MOCK_BODY/MOCK_QUOTE) pra qualquer post — trocado porBlogArticleContent(o renderer Tiptap read-only já existente no Blog da Intranet). posts.data.ts(mock) eblog-featured-post.tsx(componente morto, só usado pelo mock) foram removidos.fetchAbracafBlogPostSlugsusa o admin client, não o client normal: essa função também alimentagenerateStaticParamsde[slug]/page.tsx, que roda em build-time sem request/cookies — o client normal depende decookies()e quebra nesse contexto. Mesmo padrão já usado em Concessionárias (unstable_cachetambém não tem acesso a cookies). Os filtros explícitos (visibility/status/deleted_at) são os mesmos da RLS anon, então o bypass de RLS do admin client não expõe nada a mais.
Depois da migração inicial (só fetchAbracafBlogPostSlugs usava admin client), descobriu-se que fetchAbracafBlogPosts e fetchAbracafBlogPostBySlug também rodam em build-time — são chamadas de dentro do componente de [slug]/page.tsx, que o Next pré-renderiza por slug (generateStaticParams). Usar getSupabaseServerClient() (dependente de cookies()) nelas gerava DYNAMIC_SERVER_USAGE e 500 em build/produção (funcionava em dev, onde a renderização é sob demanda). Ver seção completa em Blog Extranets — admin client em rota pública abaixo.
Blog Extranets — admin client em rota pública
blog.queries.ts (.../abracaf/noticias/_lib/) tem hoje três funções, todas usando getSupabaseServerAdminClient():
fetchAbracafBlogPosts()— lista de/noticiasfetchAbracafBlogPostBySlug(slug)— artigo de/noticias/[slug]fetchAbracafBlogPostSlugs()— alimentagenerateStaticParamsde[slug]/page.tsx
Por que admin client, e não o client normal
getSupabaseServerClient() depende de cookies() (via @supabase/ssr). As três funções acima rodam em build-time: [slug]/page.tsx é pré-renderizada por slug (generateStaticParams), e o Next.js executa o componente da página nesse momento, sem request/cookies disponíveis. Chamar o client normal nesse contexto gera o erro DYNAMIC_SERVER_USAGE — que só aparece no pnpm build/produção, nunca em pnpm dev (lá a renderização é sob demanda, com cookies presentes). Foi exatamente esse o bug: a página funcionava em dev e devolvia 500 em build/produção.
Mesmo padrão já usado em Concessionárias (unstable_cache também não tem acesso a cookies()).
O que isso troca: RLS por filtro explícito no código
O admin client ignora RLS. Isso significa que o código, e não mais o banco, é quem decide o que é público. As três funções replicam manualmente a mesma regra da policy blog_contents_select_anon:
.eq('account_id', ABRACAF_INTRANET_ACCOUNT_ID)
.eq('visibility', 'publico')
.eq('status', 'publicado')
.is('deleted_at', null)
O select também lista colunas explícitas (PUBLIC_POST_SELECT), nunca select('*').
Não é uma falha explorável hoje — os filtros são fixos no código (só slug vem de fora, e entra num .eq() parametrizado, sem risco de injeção), e replicam a RLS ao pé da letra. Mas a rede de segurança do banco não existe mais aqui: se uma coluna sensível for adicionada em blog_contents no futuro e alguém trocar PUBLIC_POST_SELECT por select('*'), ou se a RLS for endurecida sem que alguém lembre de replicar a mudança nestas três funções, o vazamento acontece sem o banco barrar. É um contrato duplicado (RLS vs. filtro manual) que pode divergir com o tempo — a única defesa é lembrar de manter os dois sincronizados.
Alternativa testada: getSupabaseBrowserClient
getSupabaseBrowserClient() (@kit/supabase/browser-client) cria um client anônimo via createBrowserClient do @supabase/ssr — não chama cookies() do next/headers, e continua sujeito à RLS (roda como anon), o que devolveria o banco como rede de segurança em vez do admin client.
Testado nas três funções (troca temporária, build + pnpm start, revertido depois):
- O
pnpm buildpassa semDYNAMIC_SERVER_USAGEnos dois casos (admin client e browser client). - Em runtime, as duas opções servem a página corretamente —
/noticiaslista os posts reais e/noticias/[slug]renderiza o artigo (<h1>com o título real), tanto com admin client quanto com browser client. - Com o browser client apareceu, uma vez,
permission denied for table blog_contents(42501) no log da função usada pelo sitemap (fetchAbracafBlogPostSlugs), mesmo oanonjá tendo grant deSELECTconfirmado (funciona nas outras duas funções, com o mesmo client, no mesmo build). Não foi possível reproduzir de forma conclusiva nem isolar a causa (client criado fora de um browser real, semwindow/document, pode não enviar os headers de auth de forma consistente em todo contexto Node) — fica como suspeita, não como fato estabelecido.
Decisão: manter o admin client (já commitado, com os filtros explícitos documentados acima). Ele não apresentou a anomalia de permissão observada com o browser client, e o ganho do browser client (RLS como rede de segurança) não compensa introduzir uma falha intermitente e não totalmente explicada numa rota pública. Reavaliar se a investigação da geração estática (próxima seção) apontar um motivo técnico pra preferir um client sobre o outro.
Débito à parte — generateStaticParams não gera HTML estático de fato
Durante o teste acima, notado que .next/prerender-manifest.json não lista nenhuma rota de /noticias/[slug] como pré-renderizada estaticamente — com os dois clients, admin ou browser. O build mostra o marcador ● (SSG) pra rota, mas isso só indica que existe generateStaticParams no código, não que ele gerou HTML de fato; o manifesto (fonte da verdade de que rotas foram realmente pré-geradas) está vazio pra essa rota nos dois casos.
Na prática, isso não impede a página de funcionar — ela renderiza sob demanda a cada request (mais uma consulta ao Supabase por visita, sem servir HTML já pronto). Não é uma regressão desta sessão: reproduzido também com o admin client já commitado, então já era assim antes. Sem urgência (site institucional, tráfego baixo), mas vale investigar depois por que a geração estática não está de fato acontecendo.
Visibilidade de posts em edição
Decisão de produto: posts com status editando (publicados, mas com uma edição salva e ainda não publicada) não aparecem mais no site público — só status = 'publicado'. Um post some do site enquanto está sendo editado e volta a aparecer quando a edição é salva/publicada de novo. A policy blog_contents_select_anon mudou de status in ('publicado', 'editando') pra status = 'publicado'; blog_contents_select (authenticated, usado pela Intranet) não muda — quem gerencia o blog continua vendo editando normalmente.
UX da Intranet (Revista UNA/Mercado)
- Upload real com drag & drop: os modais de cadastro usavam um
input[type=file]simples com validação emuseStateparalelo. Reescritos com a família@kit/ui/attachment+ handlers reais de drag & drop; o arquivo agora vive dentro do schema Zod (PublicationCreateFormSchema, exigido só no modo criação), não em estado paralelo. - Campo "Data de publicação" removido: era editável manualmente; agora é preenchida automaticamente (
now()na criação, recarimbada a cada vez que o status muda prapublicado). - Campos numéricos (
editionNumber,pages): detype="number"pratype="text" inputMode="numeric"com máscara de dígitos, mesmo padrão do TEC-533 (Eventos). - Fuso horário no filtro de período: o filtro de data tratava BRT como UTC (
T23:59:59.999Zdireto na string) — corrigido reaproveitandogetBrtDayStart/getBrtDayEnd(já usados pelo Blog no TEC-544). - Estilo Figma: cabeçalho de tabela (
bg-abc-card-light-secondary), badge "rascunho" (outline), chips de filtro e botão "Limpar filtros" (pill vermelha preenchida). - Toast de fallback: Revista UNA não tem campo "categoria" visível, mas o
superRefinecross-type do schema compartilhado ataca erro nessepath— adicionado fallback viaform.handleSubmit(onValid, onInvalid)pra exibir o erro em toast quando não há campo renderizado pra ele.
Arquivos tocados
Banco de dados
apps/web/supabase/schemas/00-privileges.sql—grant usage on schema public to anonapps/web/supabase/schemas/20-blog.sql— grants/policies anon deblog_topics/blog_content_topics;blog_contents_select_anonrestrita astatus = 'publicado'apps/web/supabase/migrations/20260730184330_grant_anon_schema_usage.sqlapps/web/supabase/migrations/20260730190000_blog_topics_select_anon.sqlapps/web/supabase/migrations/20260730193000_blog_contents_select_anon_publicado_only.sql
Config
apps/web/config/abracaf.config.ts,apps/web/.env.example
Site público (Notícias)
.../abracaf/noticias/_lib/blog.queries.ts,topic-color.ts(novo).../abracaf/noticias/page.tsx,[slug]/page.tsx.../abracaf/noticias/_components/blog-content.tsx,blog-filters.tsx,blog-post-card.tsx,blog-featured-carousel.tsx,blog-sidebar.tsx,blog-article.tsx- Removidos:
_lib/posts.data.ts,_components/blog-featured-post.tsx
Site público (Revista UNA/Mercado)
.../abracaf/revista-una/_lib/revista.queries.ts,.../abracaf/mercado/_lib/publicacoes.queries.ts
Intranet (Revista UNA/Mercado)
apps/web/app/[locale]/intranet/_lib/schema/publications.schema.ts,publications.utils.ts,server/publications.service.ts,server/server-actions.ts,blog.utils.ts.../revista-una/_components/revista-una-form-modal.tsx,revista-una-table.tsx,revista-una-filter-chips.tsx.../mercado/_components/mercado-form-modal.tsx,mercado-table.tsx,mercado-filter-chips.tsx- Removidos:
revista-una-single-date-picker.tsx,mercado-single-date-picker.tsx
Compartilhado
apps/web/lib/format-date.ts(novo,formatShortDateextraído deblog.utils.ts)apps/web/vitest.config.ts— alias~/intranet(faltava o segmento[locale])