Pular para o conteúdo principal

Arquitetura do Sistema

Versão: 1.0
Stack: Next.js 15 · TypeScript 5 · Prisma 5 · MySQL 8
Domínio: ljvelasearomas.com.br
Painel admin: admin.ljvelasearomas.com.br


Sumário

  1. Visão geral
  2. Diagrama de camadas
  3. Roteamento e grupos de rotas
  4. Autenticação e autorização
  5. Estado no cliente
  6. Camada de API
  7. Integrações externas
  8. Fluxo de compra
  9. Caching e performance
  10. Segurança
  11. Decisões de arquitetura (ADRs)

1. Visão geral

O sistema é uma loja de e-commerce de velas artesanais construída como uma aplicação Next.js 15 full-stack, onde o mesmo repositório entrega tanto a experiência de compra (loja pública) quanto o painel de gestão (admin). Cada camada tem responsabilidade bem definida:

CamadaTecnologiaResponsabilidade
Apresentação (loja)Next.js App Router, React 19, Tailwind CSS, shadcn/uiRenderiza páginas públicas com RSC + streaming; gerencia interatividade do carrinho e checkout.
Apresentação (admin)Next.js App Router, Recharts, Puck EditorPainel de gestão protegido por JWT: produtos, pedidos, estoque, cupons, webhooks, configurações.
Middleware de bordaNext.js Edge Runtime (middleware.ts)Intercepta cada requisição a rotas /admin/* e /api/admin/*, valida JWT e aplica reescrita de subdomínio.
API RoutesNext.js Route HandlersExpõe endpoints REST para loja, admin e integrações externas; valida payloads com Zod.
Serviços de domínioTypeScript (lib/)Encapsula lógica de negócio reutilizável: auth, frete, pagamentos, webhooks, e-mail, NF-e.
PersistênciaPrisma 5 ORMAbstrai acesso ao banco; define o schema canônico; executa migrations.
Banco de dadosMySQL 8 (produção) / SQLite (testes)Armazena todos os dados transacionais e de configuração.
Estado no clienteZustand 5 com persistMantém carrinho, wishlist e estado de UI do admin no localStorage; sincroniza com a API.
ObservabilidadeHyperDX (OTel), Microsoft Clarity, GA4, Meta PixelRastreia erros, performance, heatmaps e eventos de conversão.

2. Diagrama de camadas


3. Roteamento e grupos de rotas

O Next.js App Router organiza as rotas em route groups (diretórios entre parênteses) que não afetam o URL final, mas permitem layouts separados para loja e admin.

Route Groups

Route GroupDiretórioLayoutPropósito
(store)app/(store)/Layout público (header, footer, CartDrawer)Todas as páginas visíveis ao consumidor final
(admin)app/(admin)/admin/Layout admin (sidebar colapsável, topbar)Painel de gestão protegido por JWT + role=ADMIN

Rotas da Loja — (store)

RotaArquivoTipoDescrição
/app/(store)/page.tsxRSCHomepage renderizada com dados do Puck Editor
/produtosapp/(store)/produtos/page.tsxRSC + SuspenseProduct Listing Page (PLP) com filtros e busca
/produtos/[slug]app/(store)/produtos/[slug]/page.tsxRSC (estática)Product Detail Page (PDP) com generateStaticParams
/categoria/[slug]app/(store)/categoria/[slug]/page.tsxRSC + SuspensePLP filtrada por categoria
/checkoutapp/(store)/checkout/page.tsxRCCCheckout 3 etapas: endereço → pagamento → revisão
/contaapp/(store)/conta/RSC + RCCÁrea do cliente: pedidos, endereços, favoritos
/auth/loginapp/(store)/auth/RCCLogin e cadastro de cliente
/buscaapp/(store)/busca/page.tsxRSCRedirect para /produtos?search=<q>

Rotas do Admin — (admin)

RotaPropósito
/adminDashboard com gráficos (Recharts) e KPIs
/admin/produtosCRUD de produtos + galeria de imagens + variantes
/admin/pedidosGestão de pedidos, atualização de status, emissão de NF-e
/admin/clientesLista e detalhes de clientes
/admin/cuponsCRUD de cupons de desconto
/admin/estoqueGestão de estoque, movimentações
/admin/webhooksCRUD de endpoints outbound + histórico de entregas
/admin/variantesTipos e opções de variantes (ex: tamanho, aroma)
/admin/editorPuck visual editor para a homepage
/admin/editor-sobrePuck visual editor para a página /sobre
/admin/fretePainel de integrações de frete e status dos providers
/admin/api-keysGeração e revogação de API Keys externas
/admin/newsletterLista de assinantes da newsletter
/admin/configuracoesSenha, analytics, configurações gerais do site

Rotas de API

PrefixoAutenticaçãoPropósito
/api/auth/*PúblicaLogin, logout, registro, verificação de sessão (/me)
/api/products/*PúblicaListagem de produtos e reviews
/api/categories/*PúblicaListagem de categorias ativas
/api/orders/*Cookie JWT (CUSTOMER)Criar e listar pedidos do cliente logado
/api/payments/*Cookie JWTIniciar pagamento MercadoPago / Stripe + webhooks de retorno
/api/shipping/*PúblicaCalcular frete por CEP e peso
/api/coupons/*PúblicaValidar código de cupom
/api/user/*Cookie JWT (CUSTOMER)Gerenciar endereços, wishlist, notificações
/api/cart/*Cookie JWTCart server-side (alternativo ao Zustand local)
/api/admin/*Cookie JWT (ADMIN)CRUD completo de todos os recursos
/api/v1/*Bearer API KeyAPI externa para integrações de terceiros
/api/webhooks/stockBearer API KeyInbound webhook de atualização de estoque

4. Autenticação e autorização

4.1 Visão geral do modelo

O sistema adota dois mecanismos de autenticação independentes:

  1. JWT em cookie HttpOnly — para sessões de usuários humanos (clientes e admins)
  2. API Keys (Bearer Token) — para integrações machine-to-machine (sistemas externos, webhooks)

4.2 Fluxo de autenticação de usuário

CLIENTE NEXT.JS DB
│ │ │
│ POST /api/auth/login │ │
│ { email, password } │ │
│ ──────────────────────────► │ │
│ │ 1. Rate limit check │
│ │ (10 req / 15 min / IP) │
│ │ │
│ │ 2. prisma.user.findUnique │
│ │ ────────────────────────────►│
│ │ ◄────────────────────────────│
│ │ { hash, role, ... } │
│ │ │
│ │ 3. bcryptjs.compare() │
│ │ (hash vs password) │
│ │ │
│ │ 4. SignJWT (HS256, 7 dias) │
│ │ payload: { userId, │
│ │ email, role } │
│ │ │
│ Set-Cookie: token=<jwt> │ │
│ HttpOnly; Secure; SameSite=Lax │
│ ◄────────────────────────── │ │
│ │ │
│ Requisição a /admin/* │ │
│ ──────────────────────────► │ │
│ │ ┌─────────────────────────┐ │
│ │ │ EDGE MIDDLEWARE │ │
│ │ │ jwtVerify(token, secret)│ │
│ │ │ payload.role === 'ADMIN'│ │
│ │ └──────────────────────────┘ │
│ │ │
│ [ADMIN] → NextResponse.next() │
│ [CUSTOMER] → 403 / redirect / │
│ [sem token] → 401 / redirect /auth/login │
│ ◄────────────────────────── │ │

4.3 Detalhes do JWT

AtributoValor
AlgoritmoHS256
SegredoJWT_SECRET (env var), codificado via TextEncoder
Expiração7 dias (setExpirationTime("7d"))
CookietokenHttpOnly, Secure (produção), SameSite=Lax
Payload{ userId, email, role, iat, exp }
Bibliotecajose v5 (SignJWT, jwtVerify)

Funções em lib/auth.ts:

FunçãoDescrição
signToken(payload)Cria e assina um novo JWT
verifyToken(token)Verifica assinatura e expiração; retorna payload ou null
getSession()Lê cookie token e retorna sessão decodificada
requireAuth()Chama getSession(); lança erro se não autenticado
requireAdmin()Chama requireAuth(); lança erro se role !== 'ADMIN'

4.4 Middleware Edge Runtime

O arquivo middleware.ts roda no Edge Runtime e é executado antes de cada request que corresponda ao matcher configurado:

matcher: [
"/admin/:path*",
"/api/admin/:path*",
"/((?!_next/static|_next/image|favicon|.*\\.(?:ico|png|svg|jpg|webp)).*)"
]

Lógica de decisão:

Request entra no middleware

├── Domínio principal + rota NÃO é /admin ou /api/admin
│ └── Fast-exit: NextResponse.next() ← zero overhead

├── Subdomínio admin (admin.ljvelasearomas.com.br)
│ ├── Reescreve pathname: /* → /admin/*
│ ├── Verifica token JWT
│ │ ├── válido + role=ADMIN → NextResponse.rewrite()
│ │ ├── válido + role≠ADMIN → redirect para /
│ │ └── inválido/ausente → redirect para /auth/login

└── Domínio principal + rota /admin/* ou /api/admin/*
├── Verifica token JWT
│ ├── válido + role=ADMIN → NextResponse.next()
│ ├── válido + role≠ADMIN → 403 (API) ou redirect /
│ └── inválido/ausente → 401 (API) ou redirect /auth/login

4.5 API Keys (integrações externas)

Rotas /api/v1/* e /api/webhooks/stock são autenticadas por API Key, não por cookie.

Formato: lvk_live_<base64url(24 bytes aleatórios)>
Exemplo: lvk_live_aBcDeFgHiJkLmNoPqRsTuVwXyZ

Ciclo de vida:

Admin cria API Key (/admin/api-keys)

├── generateApiKey() → { rawKey, keyHash, keyPrefix }
│ rawKey = "lvk_live_<32 chars base64url>"
│ keyHash = SHA-256(rawKey) ← salvo no banco
│ keyPrefix = primeiros 16 chars ← exibido na UI

├── rawKey exibido UMA única vez na tela
└── ApiKey salvo no banco: { keyHash, keyPrefix, scopes, active }

Request externo chega em /api/v1/*

├── verifyApiKey(req, "products:read")
│ ├── Extrai Bearer token do header Authorization
│ ├── Valida prefixo "lvk_"
│ ├── SHA-256(token) → busca no banco por keyHash
│ ├── Verifica active=true e expiresAt
│ ├── Verifica se escopo requerido está nos escopos da key
│ └── Atualiza lastUsedAt (fire-and-forget)

└── Retorna { ok: true, keyId } ou { ok: false, response: 401/403 }

Escopos disponíveis:

EscopoDescrição
products:readListar e consultar produtos
products:writeCriar e editar produtos
stock:writeAtualizar estoque de produtos
orders:readListar e consultar pedidos
orders:writeAtualizar status de pedidos

5. Estado no cliente

O estado do lado do cliente é gerenciado por três stores Zustand, todas com persistência seletiva via zustand/middleware/persist.

StoreArquivoChave localStorageO que persisteOnde é usado
useCartStorestore/cart.tsljvemasearomas-cartitems, shipping, coupon (estado isOpen não persiste para evitar mismatch de hidratação SSR)CartDrawer, ProductCard, página de checkout, cálculo de frete
useWishlistStorestore/wishlist.tsljvemasearomas-wishlistids (array de UUIDs de produtos)Ícone de coração em ProductCard, página /conta (favoritos)
useAdminUiStorestore/admin-ui-store.ts(definido no store)Estado colapsado/expandido da sidebarLayout do painel admin

Detalhes de useCartStore

O carrinho é a store mais complexa. Seus valores derivados (total, itemCount, shippingCost, orderTotal) são funções computadas — não estado salvo — e recalculam em tempo real:

  • total() — soma price × quantity de todos os itens
  • shippingCost() — retorna 0 se subtotal >= FREE_SHIPPING_THRESHOLD (R$200) ou se nenhuma opção de frete foi selecionada; caso contrário, retorna o preço da opção selecionada
  • orderTotal()total() + shippingCost() - coupon.discount, nunca negativo

A chave de um item no carrinho é productId::variantId (ou apenas productId quando não há variante), garantindo que variantes diferentes do mesmo produto sejam tratadas como itens distintos.

Detalhes de useWishlistStore

A wishlist aplica atualização otimista: a store é atualizada imediatamente na UI antes da resposta da API. Em caso de erro de rede ou 401 (não autenticado), a atualização é revertida e um toast de erro é exibido. O método hydrate() deve ser chamado uma vez no mount do layout autenticado para sincronizar os dados do servidor.


6. Camada de API

6.1 Segmentação por audiência

app/api/

├── auth/ ← Pública — autenticação de usuários
│ ├── login/ POST: valida credenciais, emite JWT
│ ├── logout/ POST: apaga cookie `token`
│ ├── me/ GET: retorna sessão atual (ou 401)
│ └── register/ POST: cria conta de cliente

├── products/ ← Pública — catálogo + reviews
├── categories/ ← Pública — categorias ativas
├── shipping/ ← Pública — cálculo de frete por CEP
├── coupons/ ← Pública — validação de cupom

├── orders/ ← Cookie JWT (role=CUSTOMER ou ADMIN)
├── payments/ ← Cookie JWT — integração MercadoPago / Stripe
├── user/ ← Cookie JWT — endereços, wishlist, perfil
├── cart/ ← Cookie JWT — cart server-side

├── admin/ ← Cookie JWT (role=ADMIN obrigatório)
│ ├── products/ CRUD completo + gestão de imagens e variantes
│ ├── orders/ Listagem + atualização de status + NF-e
│ ├── customers/ Listagem e detalhes de clientes
│ ├── coupons/ CRUD de cupons
│ ├── stock/ Ajustes de estoque + histórico
│ ├── webhooks/ CRUD de endpoints + histórico de entregas
│ ├── api-keys/ Geração e revogação de API Keys
│ ├── newsletter/ Lista de assinantes
│ └── settings/ Configurações gerais (SiteSettings)

├── v1/ ← Bearer API Key — API externa
│ ├── products/ GET: listagem paginada
│ ├── stock/ PATCH: atualização de estoque
│ └── orders/ GET/PATCH: pedidos

└── webhooks/
└── stock/ ← Bearer API Key — inbound webhook de estoque

6.2 Autenticação por camada

CamadaMecanismoVerificaçãoArquivo
Pública (/api/auth, /api/products, etc.)Nenhuma
Cliente autenticado (/api/orders, /api/user)Cookie token (JWT)getSession() / requireAuth()lib/auth.ts
Admin (/api/admin/*)Cookie token (JWT) + role=ADMINEdge middleware → requireAdmin()middleware.ts, lib/auth.ts
API externa (/api/v1/*)Authorization: Bearer lvk_live_*verifyApiKey(req, scope)lib/api-auth.ts
Webhook inbound (/api/webhooks/stock)Authorization: Bearer lvk_live_*verifyApiKey(req, "stock:write")lib/api-auth.ts

6.3 Validação de preços server-side

Ao receber POST /api/orders, o servidor não confia nos preços enviados pelo cliente. Os preços são re-consultados no banco de dados via Prisma e recalculados, garantindo que manipulações no payload do carrinho (via DevTools ou interceptação de rede) não resultem em pedidos com valores adulterados.


7. Integrações externas

Tabela de integrações

IntegraçãoPropósitoFallbackArquivo lib/Env vars
MercadoPagoPagamentos: PIX (QR reutilizável, polling de status) + Cartão (Brick)Stripemercadopago.tsMERCADOPAGO_ACCESS_TOKEN
StripePagamentos com cartão (SDK oficial)MercadoPago— (SDK direto)STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
Melhor EnvioCotação de frete (agrega Correios + outros carriers)Próximo providershipping-providers/melhor-envio.tsMELHOR_ENVIO_TOKEN
CorreiosCotação de frete direta (token cacheado em memória)Superfreteshipping-providers/correios.tsCORREIOS_USERNAME, CORREIOS_PASSWORD
SuperfreteCotação de frete (terceiro provider)Tarifas estáticas do bancoshipping-providers/superfrete.tsSUPERFRETE_TOKEN
NodemailerE-mails transacionais (pedido confirmado, senha, etc.)email.tsSMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
NF-e ServiceEmissão de nota fiscal eletrônicanfe.ts(vars do serviço NF-e)
HyperDXAPM via OpenTelemetry (traces, logs, erros)instrumentation.tsHYPERDX_API_KEY
Microsoft ClarityHeatmaps e gravação de sessõescomponente client-sideNEXT_PUBLIC_CLARITY_ID
Google Analytics 4Analytics + evento Purchase, ViewItemcomponente client-sideNEXT_PUBLIC_GA_ID
Meta PixelRemarketing (ViewItem, AddToCart, Purchase)componente client-sideNEXT_PUBLIC_META_PIXEL_ID

Cadeia de fallback do frete

GET /api/shipping?cep=...


getShippingQuotes(params) ← lib/shipping-providers/index.ts

├── 1. MelhorEnvioProvider.isAvailable()?
│ Sim → getQuotes() com AbortController timeout=8s
│ ├── quotes.length > 0 → retorna ✓
│ └── falha / vazia → próximo

├── 2. CorreiosProvider.isAvailable()?
│ Sim → getQuotes() (token em memória, renovado se expirado)
│ ├── quotes.length > 0 → retorna ✓
│ └── falha / vazia → próximo

├── 3. SuperfreteProvider.isAvailable()?
│ Sim → getQuotes() com AbortController timeout=8s
│ ├── quotes.length > 0 → retorna ✓
│ └── falha / vazia → próximo

└── 4. Tarifas estáticas do banco de dados
→ response com { source: 'static' }

Cada cotação retorna source: 'api' | 'static' para indicar ao cliente a origem do dado.

Webhooks outbound

A loja dispara eventos para sistemas externos via lib/webhook-dispatcher.ts:

dispatchWebhook(event, data)

├── Busca todos os WebhookEndpoints com active=true
├── Filtra pelos que assinam o evento específico

└── Para cada endpoint (Promise.allSettled — fire-and-forget):

├── Serializa payload: { event, timestamp, data }
├── Assina: HMAC-SHA256(secret, body) → "sha256=<hex>"
├── POST para endpoint.url
│ Headers:
│ Content-Type: application/json
│ X-Webhook-Event: <event>
│ X-Webhook-Signature: sha256=<hex>
│ X-Webhook-Timestamp: <ISO8601>
│ User-Agent: LJVelasAromas-Webhook/1.0
│ Timeout: 10 segundos (AbortController)

└── Registra WebhookDelivery no banco
{ endpointId, event, payload, responseCode,
responseBody, success, durationMs }

Eventos emitidos:

EventoDisparado quando
stock.updatedEstoque de um produto é alterado
stock.lowEstoque atinge o limiar baixo (LOW_STOCK_THRESHOLD=5)
order.placedNovo pedido confirmado
order.status_changedStatus de um pedido é atualizado

8. Fluxo de compra

Happy path completo (cliente não logado → compra confirmada)

USUÁRIO LOJA SERVIÇOS
│ │ │
│ Acessa /produtos/[slug]│ │
│ ──────────────────────► │ RSC: busca produto (cache) │
│ ◄────────────────────── │ PDP renderizada │
│ │ │
│ Clica "Adicionar" │ │
│ ──────────────────────► │ useCartStore.addItem() │
│ ◄────────────────────── │ CartDrawer abre (Framer Motion│
│ │ localStorage atualizado) │
│ │ │
│ Vai para /checkout │ │
│ ──────────────────────► │ [se não logado → │
│ ◄────────────────────── │ redirect /auth/login] │
│ │ │
│ Login / Cadastro │ │
│ ──────────────────────► │ POST /api/auth/login │
│ │ bcryptjs.compare() │
│ │ signToken() → cookie token │
│ ◄────────────────────── │ │
│ │ │
│ ── ETAPA 1: Endereço ── │
│ Informa CEP │ │
│ ──────────────────────► │ GET /api/shipping?cep=... │
│ │ ──────────────────────────────►│
│ │ getShippingQuotes() │
│ │ (ME → Correios → Superfrete │
│ │ → estático) │
│ │ ◄──────────────────────────────│
│ ◄────────────────────── │ { options, source } │
│ Seleciona frete │ useCartStore.selectShipping() │
│ │ │
│ ── ETAPA 2: Pagamento ─ │
│ [Opcional] Insere cupom │ │
│ ──────────────────────► │ POST /api/coupons/validate │
│ ◄────────────────────── │ { discount, valid } │
│ │ │
│ Escolhe PIX │ │
│ ──────────────────────► │ POST /api/payments/mercadopago│
│ │ MercadoPago SDK (PIX) │
│ │ ──────────────────────────────►│ MercadoPago
│ │ ◄──────────────────────────────│ { qr_code }
│ ◄────────────────────── │ QR Code renderizado │
│ │ │
│ Paga no app do banco │ │
│ │ Webhook MP → /api/payments/ │
│ │ webhook?type=payment │
│ │ Status: approved │
│ │ │
│ ── ETAPA 3: Revisão / Confirmação ── │
│ ──────────────────────► │ POST /api/orders │
│ │ ├── requireAuth() │
│ │ ├── Re-valida preços (Prisma) │
│ │ ├── Cria Order + OrderItems │
│ │ ├── Decrementa estoque │
│ │ ├── Invalida cupom │
│ │ ├── dispatchWebhook( │
│ │ │ "order.placed", {...}) │
│ │ └── email.ts → confirmação │
│ ◄────────────────────── │ { orderId, orderNumber } │
│ │ │
│ Redirect /conta/pedidos │ │
│ useCartStore.clearCart()│ │

9. Caching e performance

Estratégias de caching

EstratégiaOnde é aplicadaPor quê
unstable_cache (Next.js)RSCs que executam queries pesadas (produtos, categorias, SiteSettings)Evita hits no banco a cada requisição; invalida por revalidateTag
generateStaticParamsPDPs (/produtos/[slug])Pré-renderiza páginas de produto no build; CDN serve estático
Suspense streaming/produtos, /categoria/[slug]Envia o shell HTML imediatamente; dados chegam em chunks paralelos
Cache-Control: immutable/_next/static/*, /fonts/*Assets com hash no nome nunca mudam; cache de 1 ano no browser
Cache-Control: stale-while-revalidate/public/*.{png,jpg,webp}Imagens servidas do cache enquanto revalidam em background
Token Correios em memórialib/shipping-providers/correios.tsEvita re-autenticação a cada cotação; renovado quando expirar

Otimizações de imagem

Configuradas em next.config.ts:

formats: ["image/webp"] // sem AVIF (CPU intensivo em shared hosting)
deviceSizes: [640, 828, 1080, 1920] // 4 breakpoints (menos variantes geradas)
imageSizes: [64, 128, 256, 384]

Otimizações de bundle

ConfiguraçãoArquivoImpacto
optimizePackageImportsnext.config.tsTree-shaking de lucide-react, framer-motion, recharts — reduz bundle significativamente
compress: truenext.config.tsCompressão gzip das respostas HTTP
poweredByHeader: falsenext.config.tsRemove header desnecessário em cada resposta

Invalidação de cache

Após publicação no Puck Editor (homepage ou /sobre), o servidor executa revalidateTag('site-settings'), forçando o Next.js a re-gerar as páginas afetadas na próxima requisição.


10. Segurança

10.1 Headers HTTP

Todos os headers de segurança são injetados via next.config.ts (seção headers()):

HeaderValorProteção
X-Content-Type-OptionsnosniffImpede MIME-type sniffing
X-Frame-OptionsDENYBloqueia embedding em iframes (clickjacking)
Strict-Transport-Securitymax-age=31536000; includeSubDomainsForça HTTPS por 1 ano, incluindo subdomínios
Referrer-Policystrict-origin-when-cross-originLimita informações de referrer em cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=()Desativa acesso a dispositivos sensíveis
X-Powered-By(removido)Oculta tecnologia utilizada (poweredByHeader: false)

10.2 Rate Limiting

Implementado em lib/rate-limit.ts com algoritmo sliding window in-memory:

RotaLimiteJanelaChave
POST /api/auth/login10 tentativas15 minutoslogin:<IP>

Comportamento: Ao exceder o limite, retorna HTTP 429 com o header Retry-After indicando quantos segundos aguardar. O campo retryAfter é formatado em texto legível em português pela função formatRetryAfter().

Limitação conhecida: O store é in-memory (Map global), portanto não persiste entre reinícios do processo e não é compartilhado entre instâncias. Ver ADR-005 para justificativa.

10.3 Proteção de segredos

SegredoArmazenamentoAcesso
JWT_SECRETVariável de ambiente (.env)Apenas server-side (lib/auth.ts, middleware.ts)
API Keys (raw)Exibido apenas uma vez na UI; não persistidoHash SHA-256 salvo no banco
Senhas de usuáriosHash bcrypt (bcryptjs)Hash salvo em users.password
Secrets de webhookGerado na criação do endpointSalvo em webhook_endpoints.secret
Tokens de terceiros (MP, Stripe, frete)Variáveis de ambienteApenas server-side
Chaves públicas (GA, Pixel, Clarity)NEXT_PUBLIC_* — enviadas ao browser intencionalmenteClient-side

10.4 Validação de entrada

  • Todos os payloads de API Routes são validados com Zod antes de qualquer operação
  • Preços de pedidos são re-consultados no banco em POST /api/orders — nunca confiados no payload do cliente
  • Uploads de imagem passam por lib/upload.ts (handler multipart) com validação de tipo e tamanho
  • Variáveis de ambiente obrigatórias são validadas na inicialização via lib/env.ts — o servidor não sobe com configuração incompleta

10.5 Autenticação de webhooks inbound

O endpoint /api/webhooks/stock valida a API Key via verifyApiKey(req, "stock:write") antes de processar qualquer payload. Sistemas externos devem possuir uma API Key com escopo stock:write ativo para utilizar este endpoint.


11. Decisões de arquitetura (ADRs)

ADR-001: App Router vs Pages Router

Contexto: O Next.js 13+ introduziu o App Router como nova arquitetura de roteamento baseada em React Server Components. O Pages Router é o modelo anterior, amplamente conhecido e estável.

Decisão: Adotar o App Router.

Justificativas:

  • React Server Components (RSC): permitem buscar dados diretamente no servidor sem waterfall, reduzindo o JavaScript enviado ao cliente e melhorando o Time to First Byte (TTFB)
  • Streaming com Suspense: /produtos e /categoria/[slug] se beneficiam do envio incremental do HTML — o shell da página é enviado imediatamente enquanto os dados carregam
  • Colocação de dados: componentes de servidor podem chamar o banco diretamente (via Prisma) sem precisar de um endpoint API intermediário, simplificando a arquitetura
  • Layouts aninhados: (store) e (admin) compartilham layouts independentes sem duplicação de código
  • Suporte oficial a futuras features: O App Router é o caminho de evolução do framework

Consequências: A curva de aprendizado é maior (distinção RSC/RCC, use client, restrições de serialização). A gestão de estado no cliente exige Zustand em vez de simples Context API de RSC.


ADR-002: Prisma + MySQL vs alternativas

Contexto: O projeto precisa de um banco de dados relacional e um ORM TypeScript-first. As alternativas consideradas incluem Drizzle ORM, TypeORM, Knex, e PostgreSQL como banco.

Decisão: Prisma 5 como ORM com MySQL 8 em produção e SQLite em testes.

Justificativas:

MySQL:

  • O deploy é feito em Hostinger shared hosting, que fornece MySQL 8 por padrão. PostgreSQL não está disponível neste plano
  • O Prisma suporta MySQL com paridade de features necessária para o projeto

Prisma:

  • Schema declarativo em prisma/schema.prisma como fonte única de verdade
  • Geração automática de tipos TypeScript — eliminação de runtime errors por incompatibilidade de tipos
  • Migrations versionadas com prisma migrate
  • Prisma Studio para inspeção visual durante o desenvolvimento

SQLite em testes:

  • Elimina a necessidade de um banco MySQL dedicado no ambiente de CI e testes locais
  • O Jest pode criar um banco em memória (file::memory:) por suite de teste, garantindo isolamento total
  • prisma db push aplica o schema no SQLite sem migrations formais — suficiente para testes

Consequências: SQLite não suporta todas as features do MySQL (ex: FULLTEXT search), exigindo atenção ao escrever testes que exercitem queries MySQL-específicas. O shadowDatabaseUrl é necessário para prisma migrate dev no MySQL.


ADR-003: Zustand vs Context API

Contexto: O carrinho de compras e a wishlist precisam ser acessíveis em qualquer parte da árvore de componentes, persistidos no localStorage, e sincronizados com a API.

Decisão: Zustand 5 para estado global do cliente.

Justificativas:

  • Performance: Zustand usa subscriptions seletivas — componentes re-renderizam apenas quando o slice que leem muda. A Context API re-renderiza toda a sub-árvore consumidora a cada mudança
  • Persistência nativa: O middleware persist do Zustand serializa/deserializa o estado no localStorage automaticamente, com suporte a partialize para excluir campos (ex: isOpen do carrinho não é persistido para evitar mismatch de hidratação SSR)
  • Fora da árvore React: Zustand pode ser chamado fora de componentes (ex: no handler de um Route Handler ou em lib/ functions) com getState() / setState()
  • Minimal boilerplate: Sem Providers necessários, sem useReducer + dispatch + action types

Consequências: O estado Zustand não é acessível em RSCs (que rodam no servidor). Esta é uma limitação intencional e correta — estado de carrinho é inerentemente client-side.


Contexto: O token de autenticação precisa ser enviado automaticamente em cada requisição protegida e ser inacessível a scripts maliciosos.

Decisão: JWT armazenado em cookie HttpOnly.

Justificativas:

CritérioCookie HttpOnlylocalStorage
Acesso por JavaScriptNão (HttpOnly)Sim
Vulnerabilidade XSSToken inacessívelToken exposto
Envio automáticoSim (pelo browser)Manual (header Authorization)
CSRFMitigado por SameSite=LaxImune (não enviado automaticamente)
Edge MiddlewareAcessível via req.cookiesInacessível no Edge

SameSite=Lax bloqueia o envio do cookie em requisições cross-site iniciadas por terceiros (ex: <img src>, formulários externos), cobrindo os casos de CSRF mais comuns. O Edge Middleware lê o cookie diretamente do NextRequest, permitindo proteção de rotas sem round-trip ao servidor de aplicação.

Consequências: Requer atenção ao configurar allowedOrigins nas Server Actions para evitar rejeição de requisições legítimas de subdomínios.


ADR-005: Rate Limiting in-memory vs Redis

Contexto: O endpoint de login precisa de proteção contra ataques de força bruta. As opções são: implementação in-memory (Map global no processo Node.js) ou store distribuído como Redis.

Decisão: Rate limiting in-memory (lib/rate-limit.ts), aceito para o estágio atual de deploy.

Justificativas:

  • O deploy atual é single-instance em Hostinger shared hosting — não há múltiplos processos Node.js paralelos consumindo a mesma fila de requisições
  • Redis não está disponível no plano de hospedagem atual sem custo adicional
  • A implementação in-memory atende ao requisito imediato (proteção contra bots de login em instância única) com zero dependências externas
  • A Map global sobrevive a hot-reloads em desenvolvimento (via globalThis.__rateLimitStore) mas é resetada a cada deploy, o que é aceitável para o perfil de uso atual

Limitações aceitas:

  • O store não persiste entre reinícios do processo — um atacante que coincida com um deploy perde o contador
  • Não escala horizontalmente — se o projeto migrar para múltiplas instâncias (ex: Vercel, ECS), será necessário substituir por Redis (ex: Upstash Redis com @upstash/ratelimit)

Caminho de migração: A interface rateLimit(key, options): RateLimitResult é estável. A migração para Redis exige apenas a substituição da implementação interna de lib/rate-limit.ts, sem alterações nos call sites.


Documentação gerada com base no código-fonte do repositório. Para dúvidas ou atualizações, abra uma issue ou edite este arquivo via PR.