Pular para o conteúdo principal

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 pra anon antes; adicionados grant select (id, name)/grant select (content_id, topic_id) + policies *_select_anon que só referenciam blog_contents.id (deixando a RLS de blog_contents filtrar visibilidade/status/deleted_at automaticamente, evitando repetir a armadilha do deleted_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.tsx renderizava um corpo/citação mockados (MOCK_BODY/MOCK_QUOTE) pra qualquer post — trocado por BlogArticleContent (o renderer Tiptap read-only já existente no Blog da Intranet).
  • posts.data.ts (mock) e blog-featured-post.tsx (componente morto, só usado pelo mock) foram removidos.
  • fetchAbracafBlogPostSlugs usa o admin client, não o client normal: essa função também alimenta generateStaticParams de [slug]/page.tsx, que roda em build-time sem request/cookies — o client normal depende de cookies() e quebra nesse contexto. Mesmo padrão já usado em Concessionárias (unstable_cache també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.
Atualização — as três queries de /noticias usam admin client hoje

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 /noticias
  • fetchAbracafBlogPostBySlug(slug) — artigo de /noticias/[slug]
  • fetchAbracafBlogPostSlugs() — alimenta generateStaticParams de [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('*').

Isso é uma redução real de defesa em profundidade

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 build passa sem DYNAMIC_SERVER_USAGE nos dois casos (admin client e browser client).
  • Em runtime, as duas opções servem a página corretamente — /noticias lista 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 o anon já tendo grant de SELECT confirmado (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, sem window/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 em useState paralelo. 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 pra publicado).
  • Campos numéricos (editionNumber, pages): de type="number" pra type="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.999Z direto na string) — corrigido reaproveitando getBrtDayStart/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 superRefine cross-type do schema compartilhado ataca erro nesse path — adicionado fallback via form.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.sqlgrant usage on schema public to anon
  • apps/web/supabase/schemas/20-blog.sql — grants/policies anon de blog_topics/blog_content_topics; blog_contents_select_anon restrita a status = 'publicado'
  • apps/web/supabase/migrations/20260730184330_grant_anon_schema_usage.sql
  • apps/web/supabase/migrations/20260730190000_blog_topics_select_anon.sql
  • apps/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, formatShortDate extraído de blog.utils.ts)
  • apps/web/vitest.config.ts — alias ~/intranet (faltava o segmento [locale])