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
- Visão geral
- Diagrama de camadas
- Roteamento e grupos de rotas
- Autenticação e autorização
- Estado no cliente
- Camada de API
- Integrações externas
- Fluxo de compra
- Caching e performance
- Segurança
- 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:
| Camada | Tecnologia | Responsabilidade |
|---|---|---|
| Apresentação (loja) | Next.js App Router, React 19, Tailwind CSS, shadcn/ui | Renderiza páginas públicas com RSC + streaming; gerencia interatividade do carrinho e checkout. |
| Apresentação (admin) | Next.js App Router, Recharts, Puck Editor | Painel de gestão protegido por JWT: produtos, pedidos, estoque, cupons, webhooks, configurações. |
| Middleware de borda | Next.js Edge Runtime (middleware.ts) | Intercepta cada requisição a rotas /admin/* e /api/admin/*, valida JWT e aplica reescrita de subdomínio. |
| API Routes | Next.js Route Handlers | Expõe endpoints REST para loja, admin e integrações externas; valida payloads com Zod. |
| Serviços de domínio | TypeScript (lib/) | Encapsula lógica de negócio reutilizável: auth, frete, pagamentos, webhooks, e-mail, NF-e. |
| Persistência | Prisma 5 ORM | Abstrai acesso ao banco; define o schema canônico; executa migrations. |
| Banco de dados | MySQL 8 (produção) / SQLite (testes) | Armazena todos os dados transacionais e de configuração. |
| Estado no cliente | Zustand 5 com persist | Mantém carrinho, wishlist e estado de UI do admin no localStorage; sincroniza com a API. |
| Observabilidade | HyperDX (OTel), Microsoft Clarity, GA4, Meta Pixel | Rastreia 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 Group | Diretório | Layout | Propó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)
| Rota | Arquivo | Tipo | Descrição |
|---|---|---|---|
/ | app/(store)/page.tsx | RSC | Homepage renderizada com dados do Puck Editor |
/produtos | app/(store)/produtos/page.tsx | RSC + Suspense | Product Listing Page (PLP) com filtros e busca |
/produtos/[slug] | app/(store)/produtos/[slug]/page.tsx | RSC (estática) | Product Detail Page (PDP) com generateStaticParams |
/categoria/[slug] | app/(store)/categoria/[slug]/page.tsx | RSC + Suspense | PLP filtrada por categoria |
/checkout | app/(store)/checkout/page.tsx | RCC | Checkout 3 etapas: endereço → pagamento → revisão |
/conta | app/(store)/conta/ | RSC + RCC | Área do cliente: pedidos, endereços, favoritos |
/auth/login | app/(store)/auth/ | RCC | Login e cadastro de cliente |
/busca | app/(store)/busca/page.tsx | RSC | Redirect para /produtos?search=<q> |
Rotas do Admin — (admin)
| Rota | Propósito |
|---|---|
/admin | Dashboard com gráficos (Recharts) e KPIs |
/admin/produtos | CRUD de produtos + galeria de imagens + variantes |
/admin/pedidos | Gestão de pedidos, atualização de status, emissão de NF-e |
/admin/clientes | Lista e detalhes de clientes |
/admin/cupons | CRUD de cupons de desconto |
/admin/estoque | Gestão de estoque, movimentações |
/admin/webhooks | CRUD de endpoints outbound + histórico de entregas |
/admin/variantes | Tipos e opções de variantes (ex: tamanho, aroma) |
/admin/editor | Puck visual editor para a homepage |
/admin/editor-sobre | Puck visual editor para a página /sobre |
/admin/frete | Painel de integrações de frete e status dos providers |
/admin/api-keys | Geração e revogação de API Keys externas |
/admin/newsletter | Lista de assinantes da newsletter |
/admin/configuracoes | Senha, analytics, configurações gerais do site |
Rotas de API
| Prefixo | Autenticação | Propósito |
|---|---|---|
/api/auth/* | Pública | Login, logout, registro, verificação de sessão (/me) |
/api/products/* | Pública | Listagem de produtos e reviews |
/api/categories/* | Pública | Listagem de categorias ativas |
/api/orders/* | Cookie JWT (CUSTOMER) | Criar e listar pedidos do cliente logado |
/api/payments/* | Cookie JWT | Iniciar pagamento MercadoPago / Stripe + webhooks de retorno |
/api/shipping/* | Pública | Calcular frete por CEP e peso |
/api/coupons/* | Pública | Validar código de cupom |
/api/user/* | Cookie JWT (CUSTOMER) | Gerenciar endereços, wishlist, notificações |
/api/cart/* | Cookie JWT | Cart server-side (alternativo ao Zustand local) |
/api/admin/* | Cookie JWT (ADMIN) | CRUD completo de todos os recursos |
/api/v1/* | Bearer API Key | API externa para integrações de terceiros |
/api/webhooks/stock | Bearer API Key | Inbound 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:
- JWT em cookie HttpOnly — para sessões de usuários humanos (clientes e admins)
- 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
| Atributo | Valor |
|---|---|
| Algoritmo | HS256 |
| Segredo | JWT_SECRET (env var), codificado via TextEncoder |
| Expiração | 7 dias (setExpirationTime("7d")) |
| Cookie | token — HttpOnly, Secure (produção), SameSite=Lax |
| Payload | { userId, email, role, iat, exp } |
| Biblioteca | jose v5 (SignJWT, jwtVerify) |
Funções em lib/auth.ts:
| Função | Descriçã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:
| Escopo | Descrição |
|---|---|
products:read | Listar e consultar produtos |
products:write | Criar e editar produtos |
stock:write | Atualizar estoque de produtos |
orders:read | Listar e consultar pedidos |
orders:write | Atualizar 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.
| Store | Arquivo | Chave localStorage | O que persiste | Onde é usado |
|---|---|---|---|---|
useCartStore | store/cart.ts | ljvemasearomas-cart | items, shipping, coupon (estado isOpen não persiste para evitar mismatch de hidratação SSR) | CartDrawer, ProductCard, página de checkout, cálculo de frete |
useWishlistStore | store/wishlist.ts | ljvemasearomas-wishlist | ids (array de UUIDs de produtos) | Ícone de coração em ProductCard, página /conta (favoritos) |
useAdminUiStore | store/admin-ui-store.ts | (definido no store) | Estado colapsado/expandido da sidebar | Layout 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()— somaprice × quantityde todos os itensshippingCost()— retorna0sesubtotal >= FREE_SHIPPING_THRESHOLD(R$200) ou se nenhuma opção de frete foi selecionada; caso contrário, retorna o preço da opção selecionadaorderTotal()—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
| Camada | Mecanismo | Verificação | Arquivo |
|---|---|---|---|
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=ADMIN | Edge 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ção | Propósito | Fallback | Arquivo lib/ | Env vars |
|---|---|---|---|---|
| MercadoPago | Pagamentos: PIX (QR reutilizável, polling de status) + Cartão (Brick) | Stripe | mercadopago.ts | MERCADOPAGO_ACCESS_TOKEN |
| Stripe | Pagamentos com cartão (SDK oficial) | MercadoPago | — (SDK direto) | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
| Melhor Envio | Cotação de frete (agrega Correios + outros carriers) | Próximo provider | shipping-providers/melhor-envio.ts | MELHOR_ENVIO_TOKEN |
| Correios | Cotação de frete direta (token cacheado em memória) | Superfrete | shipping-providers/correios.ts | CORREIOS_USERNAME, CORREIOS_PASSWORD |
| Superfrete | Cotação de frete (terceiro provider) | Tarifas estáticas do banco | shipping-providers/superfrete.ts | SUPERFRETE_TOKEN |
| Nodemailer | E-mails transacionais (pedido confirmado, senha, etc.) | — | email.ts | SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS |
| NF-e Service | Emissão de nota fiscal eletrônica | — | nfe.ts | (vars do serviço NF-e) |
| HyperDX | APM via OpenTelemetry (traces, logs, erros) | — | instrumentation.ts | HYPERDX_API_KEY |
| Microsoft Clarity | Heatmaps e gravação de sessões | — | componente client-side | NEXT_PUBLIC_CLARITY_ID |
| Google Analytics 4 | Analytics + evento Purchase, ViewItem | — | componente client-side | NEXT_PUBLIC_GA_ID |
| Meta Pixel | Remarketing (ViewItem, AddToCart, Purchase) | — | componente client-side | NEXT_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:
| Evento | Disparado quando |
|---|---|
stock.updated | Estoque de um produto é alterado |
stock.low | Estoque atinge o limiar baixo (LOW_STOCK_THRESHOLD=5) |
order.placed | Novo pedido confirmado |
order.status_changed | Status 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égia | Onde é aplicada | Por quê |
|---|---|---|
unstable_cache (Next.js) | RSCs que executam queries pesadas (produtos, categorias, SiteSettings) | Evita hits no banco a cada requisição; invalida por revalidateTag |
generateStaticParams | PDPs (/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ória | lib/shipping-providers/correios.ts | Evita 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ção | Arquivo | Impacto |
|---|---|---|
optimizePackageImports | next.config.ts | Tree-shaking de lucide-react, framer-motion, recharts — reduz bundle significativamente |
compress: true | next.config.ts | Compressão gzip das respostas HTTP |
poweredByHeader: false | next.config.ts | Remove 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()):
| Header | Valor | Proteção |
|---|---|---|
X-Content-Type-Options | nosniff | Impede MIME-type sniffing |
X-Frame-Options | DENY | Bloqueia embedding em iframes (clickjacking) |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Força HTTPS por 1 ano, incluindo subdomínios |
Referrer-Policy | strict-origin-when-cross-origin | Limita informações de referrer em cross-origin |
Permissions-Policy | camera=(), 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:
| Rota | Limite | Janela | Chave |
|---|---|---|---|
POST /api/auth/login | 10 tentativas | 15 minutos | login:<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
| Segredo | Armazenamento | Acesso |
|---|---|---|
JWT_SECRET | Variável de ambiente (.env) | Apenas server-side (lib/auth.ts, middleware.ts) |
| API Keys (raw) | Exibido apenas uma vez na UI; não persistido | Hash SHA-256 salvo no banco |
| Senhas de usuários | Hash bcrypt (bcryptjs) | Hash salvo em users.password |
| Secrets de webhook | Gerado na criação do endpoint | Salvo em webhook_endpoints.secret |
| Tokens de terceiros (MP, Stripe, frete) | Variáveis de ambiente | Apenas server-side |
| Chaves públicas (GA, Pixel, Clarity) | NEXT_PUBLIC_* — enviadas ao browser intencionalmente | Client-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:
/produtose/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.prismacomo 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 pushaplica 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
persistdo Zustand serializa/deserializa o estado nolocalStorageautomaticamente, com suporte apartializepara excluir campos (ex:isOpendo 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) comgetState()/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.
ADR-004: JWT em cookie HttpOnly vs localStorage
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ério | Cookie HttpOnly | localStorage |
|---|---|---|
| Acesso por JavaScript | Não (HttpOnly) | Sim |
| Vulnerabilidade XSS | Token inacessível | Token exposto |
| Envio automático | Sim (pelo browser) | Manual (header Authorization) |
| CSRF | Mitigado por SameSite=Lax | Imune (não enviado automaticamente) |
| Edge Middleware | Acessível via req.cookies | Inacessí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
Mapglobal sobrevive a hot-reloads em desenvolvimento (viaglobalThis.__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.