Duelo de Cartas: aplicação web e CI/CD com GitLab na AWS
Construa o Duelo de Cartas, uma aplicação web responsiva com HTML, CSS, JavaScript e Bootstrap, API Node.js com autenticação JWT e persistência no DynamoDB, publicada no Amazon S3 e no Elastic Beanstalk por um pipeline GitLab CI/CD com lint, testes unitários e de integração.
1. Visão geral
Neste codelab você desenvolve o Duelo de Cartas, uma aplicação web responsiva com HTML, CSS e JavaScript, uma API Node.js com autenticação, persistência no DynamoDB e implantação automatizada no Amazon S3 e no AWS Elastic Beanstalk com GitLab CI/CD, tudo no AWS Academy Learner Lab.

Entrada e painel da aplicação: os controles visuais serão ligados à API ao longo da implementação.
O projeto é construído em versões numeradas, de C01 a C13. Cada versão termina com uma verificação e um commit, e o pipeline do GitLab cresce junto com a aplicação.
O que você vai aprender
- Modelos de serviço (IaaS, PaaS, SaaS), regiões e zonas de disponibilidade
- Git, GitLab, pipelines, jobs e runners
- HTML, CSS e Bootstrap para montar telas responsivas
- HTTP, códigos de status e CORS
- Uma API com Node.js e Express, configurada por variáveis de ambiente
- Docker e DynamoDB Local para desenvolver sem a nuvem
- Modelagem no DynamoDB com chave simples e chave composta, leituras consistentes e transações
- Autenticação com bcrypt e JWT, com o segredo guardado no AWS Secrets Manager
- Lint, testes unitários e testes de integração dentro do pipeline
- Implantação da API no Elastic Beanstalk e do front no Amazon S3 pelo GitLab CI/CD
O que você vai precisar
- Acesso ao AWS Academy Learner Lab
- Uma conta no GitLab.com com runners disponíveis
- Git, Node.js 22 ou superior, um editor de código e Docker (Desktop ou Engine com Compose)
- Um terminal Bash ou compatível (no Windows, use o WSL2)
2. Computação em nuvem e AWS
Recursos de TI sob demanda
Computação em nuvem é o acesso a recursos de TI por uma rede, com provisionamento sob demanda e capacidade ajustável. Em vez de comprar um servidor para cada necessidade, uma aplicação pode contratar processamento, armazenamento e bancos de dados como serviços. Esses recursos continuam executando em equipamentos físicos, nos centros de dados do provedor; a diferença está na forma de disponibilizá-los, administrá-los e pagar por seu uso.
A Amazon Web Services (AWS) reúne esses serviços em uma plataforma. O Console AWS é a interface web de administração. A mesma infraestrutura também pode ser configurada por chamadas de API, bibliotecas de programação e ferramentas de linha de comando. Uma conta AWS delimita a propriedade dos recursos, as permissões e a cobrança. No Learner Lab, a conta e as condições de acesso são fornecidas pelo ambiente educacional.
Elasticidade é ajustar capacidade à demanda; escalabilidade é conseguir atender ao crescimento de carga. A configuração determina se o ajuste será manual ou automático. Cobrança por consumo não significa custo zero enquanto ninguém abre o site: uma instância ligada, armazenamento e outros recursos podem continuar gerando cobrança.
IaaS, PaaS e SaaS: quem administra cada parte
Os três modelos descrevem diferentes divisões de responsabilidade. Em IaaS (Infrastructure as a Service), são contratados recursos de infraestrutura: máquinas virtuais, rede e discos. A equipe instala e administra o sistema e sua aplicação. Em PaaS (Platform as a Service), uma plataforma prepara o ambiente de execução e oferece mecanismos de implantação; a equipe entrega o código e configura o serviço. Em SaaS (Software as a Service), o usuário acessa um software pronto, administrado pelo fornecedor.

Quanto maior a abstração, mais camadas operacionais ficam sob a administração do fornecedor.
| Modelo | Exemplo | Trabalho típico da equipe |
|---|---|---|
| IaaS | Amazon EC2 | Escolher uma VM, configurar rede, instalar o runtime e manter a aplicação e o sistema. |
| PaaS | AWS Elastic Beanstalk | Enviar o pacote da aplicação e configurar variáveis, capacidade e atualizações da plataforma. |
| SaaS | GitLab.com; Google Workspace | Administrar usuários, permissões, dados e configurações do software já disponível. |
O Elastic Beanstalk é um exemplo de experiência de PaaS construída sobre recursos como EC2. Ele automatiza tarefas, mas não elimina decisões sobre rede, versões ou capacidade. Já o DynamoDB é um banco gerenciado: a equipe modela dados e acessos sem instalar seu servidor de banco. Um mesmo projeto pode combinar serviços com diferentes níveis de abstração.
Regiões e zonas de disponibilidade
Uma região é uma área geográfica da infraestrutura AWS. Por exemplo, us-east-1 identifica o Norte da Virgínia e sa-east-1, São Paulo. Cada região contém várias zonas de disponibilidade, ou AZs (Availability Zones). Uma AZ reúne um ou mais centros de dados com energia, conectividade e outros componentes de infraestrutura redundantes; AZs distintas são fisicamente separadas e conectadas por redes de baixa latência.

Região é o recorte geográfico; AZ é um domínio de isolamento de falhas dentro dela. O desenho mostra duas AZs por região apenas para simplificar.
Uma subnet é uma subdivisão da rede virtual e pertence a uma única AZ. Uma instância EC2 é iniciada em uma dessas subnets. Distribuir instâncias por mais de uma AZ pode manter o atendimento diante da falha de uma zona, desde que a aplicação e o encaminhamento de tráfego tenham sido preparados para isso. Recursos em outra região não aparecem automaticamente como cópias dos recursos da primeira. Replicação entre regiões e recuperação de desastres exigem configuração própria.
| Recurso | Escopo relevante neste projeto |
|---|---|
| Instância EC2 | Fica em uma AZ; a subnet escolhida determina essa zona. |
| Ambiente Beanstalk | Pertence a uma região; as instâncias usam as subnets configuradas. |
| DynamoDB e Secrets Manager | Tabelas e segredos são criados na região selecionada. |
| Amazon S3 | Cada bucket é criado em uma região; o bucket de uso geral terá um nome único. |
| IAM | Administra identidades e permissões da conta; não é uma instância em uma AZ. |
3. AWS Academy Learner Lab e GitLab CI/CD
O que é o Learner Lab
O AWS Academy Learner Lab fornece uma conta AWS para experimentar serviços em um ambiente educacional. A sessão tem duração limitada, orçamento e restrições de permissões. O acesso ao Console é temporário; os serviços criados pertencem à conta do laboratório. Iniciar uma sessão e criar um recurso são ações distintas: uma sessão autoriza o acesso, enquanto um bucket, uma tabela ou um ambiente continuam sujeitos ao ciclo de vida e às políticas do laboratório.
Em Start Lab, a sessão é iniciada. Depois que o indicador de disponibilidade ficar verde, o botão AWS abre o Console. Em AWS Details, as credenciais temporárias permitem que ferramentas externas, como os jobs do GitLab, façam chamadas aos serviços. As três partes são AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY e AWS_SESSION_TOKEN.

A sessão concede acesso temporário; os recursos possuem seu próprio ciclo de vida.
Git, GitLab, pipeline, job e runner
Git é o sistema que registra versões dos arquivos. Um commit guarda um estado identificável do projeto; uma branch mantém uma linha de desenvolvimento. O GitLab hospeda o repositório remoto e reúne revisão de código, acompanhamento de mudanças e automação. git push envia os commits locais para esse repositório.
No CI/CD, um pipeline reúne tarefas automatizadas. Cada tarefa é um job; um stage agrupa jobs da mesma fase. O runner é o agente que executa essas tarefas. O arquivo .gitlab-ci.yml, na raiz do projeto, descreve imagens, comandos, dependências e regras de execução. Neste projeto, runners hospedados do GitLab.com executam os jobs.

GitLab organiza a execução; o runner executa os comandos do pipeline.
| Termo | Significado prático |
|---|---|
| CI: integração contínua | Integrar mudanças com frequência e executar verificações automáticas. |
| Continuous delivery | Manter uma versão aprovada pronta para implantação; a liberação pode ser manual. |
| Continuous deployment | Implantar automaticamente as mudanças aprovadas. O push na branch padrão acionará esse comportamento. |
4. Aplicação, arquitetura e ambiente
Duelo de Cartas
A aplicação compara duas cartas de um baralho de 52 cartas, uma para a pessoa autenticada e outra para a máquina. Ás vale 14; rei, dama e valete valem 13, 12 e 11. Os naipes não desempatam. Cada rodada utiliza duas cartas distintas; o backend registra o resultado e atualiza o placar. O projeto não envolve apostas, pagamentos ou recompensas.
A tela de entrada recebe e-mail e senha. Depois da autenticação, o painel mostra o placar, as cartas e o histórico, como na figura do passo anterior.
O front-end é a parte que executa no navegador: desenha as telas, lê os campos e envia pedidos. O back-end é o processo no servidor: valida o usuário, aplica as regras do jogo e controla os acessos aos dados. O banco de dados mantém usuários, rodadas e contadores entre as requisições. Uma resposta do back-end fornece dados para o front atualizar a tela; o navegador não acessará diretamente o DynamoDB.

Responsabilidades e caminho de uma rodada: ação na tela, regra na API, persistência no banco e resposta ao navegador.
O front-end usa HTML, CSS, JavaScript e Bootstrap. Seus arquivos ficam armazenados no Amazon S3, mas o JavaScript do front é executado no navegador. A API usa Node.js e Express em uma instância EC2 administrada pelo Elastic Beanstalk. O Nginx recebe as requisições e as encaminha ao processo Node.js. O DynamoDB mantém usuários e rodadas. O Secrets Manager guarda o segredo com que o backend assina e verifica os tokens de autenticação.

As setas indicam os destinos das chamadas. O navegador solicita arquivos ao S3 e chama a API; somente o backend acessa DynamoDB e Secrets Manager.
Do navegador aos serviços: dois momentos
Primeiro, o navegador carrega a interface. Ao abrir o endereço do site, ele solicita ao S3 o HTML e os arquivos de CSS e JavaScript referenciados pela página. O S3 entrega esses arquivos. O navegador interpreta a estrutura HTML, aplica as regras CSS e executa o JavaScript. É nesse momento que os controles da página ganham comportamento.
Depois, o JavaScript chama a API. Ao clicar em Entrar, o código do formulário usa fetch, a função do navegador que faz requisições pela rede. Na aplicação, o endereço da API ficará em API_URL; o código acrescentará o caminho /api/auth/login e enviará e-mail e senha no corpo de uma requisição POST. POST é um método HTTP para enviar dados a um recurso; os métodos e códigos de resposta serão detalhados antes da implementação das rotas.
As setas de uma figura de sequência devem ser lidas de cima para baixo. A figura seguinte mostra um login válido: a API consulta o usuário, verifica a senha e devolve um token, isto é, um comprovante digital que o front apresentará nas chamadas protegidas. JSON é o formato textual usado para representar os dados dessas mensagens. O JWT é o formato de token adotado no projeto; o login inicial ainda não exige esse token.

Carregamento da interface e envio do login são trocas distintas. As respostas retornam ao navegador; o S3 não encaminha o formulário à API.
Como site e API usam endereços de origens diferentes, o backend precisará permitir que a página leia suas respostas. A política do navegador e a configuração dessa permissão serão explicadas na seção de HTTP, antes das primeiras rotas.
O acesso ao Secrets Manager ocorre no backend. No código deste codelab, quando o segredo não vem da configuração local, a primeira operação que precisa dele o busca nesse serviço e guarda o valor em memória para reutilização. O segredo não é enviado ao navegador. Já o perfil de instância fornece as credenciais e permissões AWS usadas pelo backend para acessar os serviços. Essas credenciais autorizam o servidor perante a AWS; o token JWT identifica o usuário perante a API.
HTTP, origem e ambiente público de laboratório
Uma URL identifica um recurso. Sua origem é a combinação de protocolo, host e porta. Assim, http://localhost:8080 e http://localhost:3000 são origens diferentes. Uma API HTTP recebe uma requisição, interpreta método, endereço, cabeçalhos e corpo, e devolve status, cabeçalhos e dados; neste projeto, os dados são serializados como JSON.
Ferramentas e nomes do projeto
O desenvolvimento usa um terminal Bash ou compatível. No Windows, use um ambiente Linux via WSL2; no macOS, Terminal; no Linux, seu terminal usual. Instale Git, Node.js 22 ou superior, um editor e Docker Desktop ou Docker Engine com Compose. Os exemplos de CI e implantação usam a linha 22, sem exigir uma versão 22.x específica; não é necessário instalar a AWS CLI localmente.
Obtenha Node.js em nodejs.org, Git em git-scm.com e Docker em docs.docker.com. No macOS, selecione o instalador Apple Silicon quando aplicável. No Linux, siga a instalação do Engine e do plugin Compose correspondente à distribuição. Abra o Docker e aguarde o mecanismo ficar ativo.
Computador local · conferir ferramentas
node --version
npm --version
git --version
docker --version
docker compose version
| Recurso | Nome ou convenção |
|---|---|
| Repositório | duelo-cartas |
| Branch padrão | main |
| Aplicação / ambiente Beanstalk | duelo-cartas-api / duelo-cartas-api-dev |
| Tabelas | duelo-usuarios / duelo-rodadas |
| Segredo | duelo-cartas/jwt |
| Região | us-east-1 |
| Buckets | Um nome globalmente único para o front e outro para os pacotes |
5. C01: repositório e primeira página
Criar o repositório no GitLab
No grupo do GitLab.com, selecione New project > Create blank project. Use duelo-cartas; escolha a visibilidade apropriada e deixe desmarcada a inicialização com README. Copie a URL HTTPS. Substitua SEU_GRUPO pela identificação real do grupo nos comandos.
Computador local · criar o projeto
mkdir duelo-cartas
cd duelo-cartas
git init -b main
git config user.name "Seu Nome"
git config user.email "seu-email-de-commit@example.com"
git remote add origin https://gitlab.com/SEU_GRUPO/duelo-cartas.git
mkdir -p frontend/css frontend/js backend
Para autenticar por HTTPS, use um token com permissão de escrita no repositório quando solicitado. No GitLab, ele pode ser criado em Edit profile > Access > Personal access tokens; configure expiração e escopo write_repository. Informe seu usuário e use o token no campo de senha do Git. Não inclua o token na URL remota ou nos arquivos.
Computador local · raiz de duelo-cartas
touch .gitignore
As regras abaixo impedem que dependências instaladas, credenciais locais e resultados de empacotamento entrem no Git.
.gitignore
# Dependências instaladas e arquivos sensíveis ficam fora do versionamento.
node_modules/
.env
.npm/
.dynamodb/
backend.zip
public/
coverage/
.DS_Store
Criar e visualizar o primeiro HTML
HTML descreve a estrutura do documento. head contém metadados; body contém o conteúdo visível. lang identifica o idioma e charset permite representar a acentuação em UTF-8.
Computador local · raiz de duelo-cartas
mkdir -p frontend
touch frontend/index.html
frontend/index.html
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<!-- Faz o layout acompanhar a largura da tela. -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Duelo de Cartas</title>
</head>
<body>
<main>
<h1>Duelo de Cartas</h1>
<p>Aplicação web com histórico de rodadas.</p>
</main>
</body>
</html>
Um servidor HTTP local também será necessário quando usarmos módulos JavaScript. O comando abaixo baixa e executa a versão indicada do utilitário http-server. Ele serve somente a pasta frontend, na porta 8080, com cache desativado. Mantenha esse terminal aberto; os próximos comandos usam outro terminal na raiz do projeto.
Computador local · raiz de duelo-cartas
npx --yes http-server@14.1.1 frontend -a 127.0.0.1 -p 8080 -c-1
Abra http://localhost:8080. A página deve exibir o título e o parágrafo. Ctrl+C encerra o servidor. Para iniciá-lo novamente, repita o mesmo comando na raiz do projeto.
Computador local · terminal na raiz · versão C01
git status
git add .gitignore frontend/index.html
git commit -m "front: cria primeira pagina"
git push -u origin main
6. C02: Amazon S3 e primeiro pipeline
Criar o destino público do front
O S3 é armazenamento de objetos: um bucket contém objetos, cada um identificado por uma chave, como css/styles.css. Ele entrega os arquivos do site ao navegador; não executa Node.js, login ou consultas ao banco. O website hosting fornece a página inicial e caminhos de navegação.
- Inicie o Learner Lab, abra o Console AWS e selecione N. Virginia / us-east-1.
- Em S3 > Create bucket, escolha um bucket de uso geral e um nome único, por exemplo
duelo-front-grupo99-projeto01. - Mantenha ACLs disabled. Para este bucket de site público, desmarque Block all public access e confirme o aviso.
- Crie o bucket e abra Properties > Static website hosting > Edit.
- Ative Host a static website, defina
index.htmlcomo Index document e deixe o documento de erro sem configuração. Salve. - Copie o Bucket website endpoint. Use o endereço exibido, iniciado por
http://, sem barra final; não construa a URL manualmente.
No Console AWS, dentro do bucket do front no S3, abra Permissions > Bucket policy > Edit e aplique a política abaixo, substituindo NOME-DO-BUCKET. JSON estrito não aceita comentários; por isso os campos são explicados imediatamente após o bloco.
Console S3 · política do bucket do front
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::NOME-DO-BUCKET/*"
}]
}
Version identifica a linguagem da política. Principal: "*" inclui visitantes anônimos; s3:GetObject autoriza apenas leitura de objetos. O ARN identifica o recurso e o sufixo /* inclui as chaves dentro daquele bucket. Nenhuma permissão de upload público é concedida.
Habilitar runners e configurar variáveis
No projeto duelo-cartas, no GitLab.com, abra Settings > CI/CD > Runners e confirme a disponibilidade dos runners hospedados da instância para o projeto. Minutos de computação, verificação da conta ou políticas do grupo podem afetar a disponibilidade. Os jobs Linux abaixo não precisam de tags específicas. No mesmo projeto do GitLab, em Settings > Repository > Branch rules / Protected branches, proteja main e permita o push ao papel que administra o projeto.
No projeto duelo-cartas, no GitLab.com, abra Settings > CI/CD > Variables e cadastre as variáveis abaixo como tipo Variable, escopo *. Marque Protected; nas três credenciais, marque também Masked e desabilite a expansão de referências. Copie os três valores completos de AWS Details, na página do Learner Lab. Volte à aba do GitLab para cadastrá-los.
| Variável | Valor |
|---|---|
AWS_ACCESS_KEY_ID |
Access key temporária do Learner Lab |
AWS_SECRET_ACCESS_KEY |
Secret key temporária do Learner Lab |
AWS_SESSION_TOKEN |
Token completo da mesma sessão |
AWS_DEFAULT_REGION |
us-east-1 |
FRONTEND_BUCKET |
Somente o nome do bucket público |
FRONTEND_URL |
Website endpoint HTTP, sem barra final |
Criar o pipeline imediatamente após o primeiro commit
YAML usa indentação para representar a hierarquia; use espaços, não tabulações. Uma lista começa com hífen. image escolhe o ambiente de execução do job: uma imagem reúne arquivos e programas; um contêiner é sua execução isolada. O runner iniciará esses ambientes automaticamente. A implementação local com Docker será detalhada antes do banco de desenvolvimento.
Computador local · raiz de duelo-cartas
touch .gitlab-ci.yml
Comece pela fase de validação. Alpine é uma imagem Linux pequena que já oferece os comandos simples utilizados aqui.
.gitlab-ci.yml · parte 1 de 2
stages:
- validate
- deploy
front_smoke:
stage: validate
image: alpine:3.22
script:
- test -s frontend/index.html # Exige arquivo existente e não vazio.
- grep -q 'Duelo de Cartas' frontend/index.html
O job de implantação usa uma imagem com a AWS CLI. entrypoint vazio substitui o ponto de entrada da imagem para o runner executar seu script. Acrescente-o logo depois de front_smoke.
.gitlab-ci.yml · parte 2 de 2
- grep -q 'Duelo de Cartas' frontend/index.html
deploy_frontend:
stage: deploy
image:
name: public.ecr.aws/aws-cli/aws-cli:2.31.4
entrypoint: [''] # Permite ao runner iniciar seu próprio shell.
script:
- aws sts get-caller-identity # Confirma qual conta recebeu as credenciais.
- aws s3 sync frontend "s3://${FRONTEND_BUCKET}" --delete
environment:
name: production/frontend
url: $FRONTEND_URL
resource_group: learner-lab-deploy
interruptible: false
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
sync --delete faz o bucket refletir a pasta de origem: objetos extras no destino são excluídos. Esse bucket deve ser exclusivo do front. resource_group impede que dois jobs com o mesmo grupo executem simultaneamente no mesmo projeto. Por padrão, um stage só prossegue depois que os anteriores terminam com sucesso.
Computador local · terminal na raiz · versão C02
git add .gitlab-ci.yml
git commit -m "ci: valida e publica front no S3"
git push
No projeto do GitLab.com, em Build > Pipelines, abra os dois jobs. Ambos devem concluir com sucesso; o website endpoint deve exibir a página inicial. ExpiredToken exige renovar as três credenciais; um job em Pending exige verificar runners e cota de computação.
7. C03: página de entrada com HTML e CSS
Conectar as folhas de estilo à página existente
CSS seleciona elementos do HTML e aplica regras de apresentação. .login-page, por exemplo, seleciona um elemento cuja lista de classes contém login-page. Uma regra reúne um seletor e declarações entre chaves; cada declaração tem uma propriedade e um valor. O navegador recalcula a apresentação quando a folha é alterada e a página é recarregada.
O Bootstrap oferece estilos prontos para formulários, botões e organização da página. Sua folha virá de uma CDN, um serviço de distribuição de arquivos. integrity contém o hash SRI que permite conferir o conteúdo recebido; crossorigin="anonymous" permite essa verificação sem enviar credenciais à CDN. A folha própria virá depois para permitir ajustes sobre os estilos da biblioteca.
Computador local · raiz de duelo-cartas
mkdir -p frontend/css
touch frontend/css/styles.css
No editor local, em frontend/index.html, substitua apenas o elemento head pelo seguinte. O body permanece intacto. A folha própria já existe, embora ainda esteja vazia.
frontend/index.html
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Entrar | Duelo de Cartas</title>
<!-- A CDN entrega uma versão específica do Bootstrap. -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<link rel="stylesheet" href="css/styles.css">
</head>
Em frontend/css/styles.css, defina a paleta compartilhada. As propriedades iniciadas por -- são variáveis CSS; aqui elas evitam repetir o mesmo valor em vários componentes.
frontend/css/styles.css
/* :root corresponde à raiz do documento; as variáveis são herdadas. */
:root {
--game-primary: #4158d0;
--game-primary-dark: #2f3fa4;
--game-ink: #172033;
--game-bg: #f4f6fb;
}
body {
color: var(--game-ink); /* var() consulta a variável definida na raiz. */
}
No navegador local, recarregue http://localhost:8080. O título e o parágrafo continuam visíveis e passam a usar a base visual do Bootstrap. Se a biblioteca não aparecer, confira a conexão com a CDN e a aba Network das ferramentas do navegador.
Construir o cartão de entrada de fora para dentro
Bootstrap divide cada linha em 12 colunas. col-12 ocupa a linha inteira; col-md-8 usa oito colunas a partir de 768 px; col-lg-5 usa cinco a partir de 992 px. A largura menor centraliza o formulário em telas amplas. d-flex ativa Flexbox; align-items-center centraliza no eixo transversal.

A grade adapta a quantidade de colunas ocupadas à largura disponível.
Em frontend/index.html, substitua o body antigo pela estrutura abaixo. Ela cria os limites externos do cartão. min-vh-100 ocupa ao menos a altura da janela; py-4 acrescenta espaço vertical. O cartão está vazio nesta etapa.
frontend/index.html
<body class="login-page">
<main class="container min-vh-100 d-flex align-items-center py-4">
<div class="row justify-content-center w-100 mx-0">
<div class="col-12 col-md-8 col-lg-5">
<section class="card login-card border-0 shadow-lg">
<div class="card-body p-4 p-sm-5"></div>
</section>
</div>
</div>
</main>
</body>
Acrescente estas regras ao final de frontend/css/styles.css. O degradê radial cria uma área de luz no canto superior; o linear preenche o restante do fundo. Recarregue a página: já será possível observar a área central e o fundo.
frontend/css/styles.css
body {
color: var(--game-ink); /* var() consulta a variável definida na raiz. */
}
.login-page {
/* Duas camadas: a primeira é desenhada sobre a segunda. */
background:
radial-gradient(circle at 10% 15%, #4158d047, transparent 34rem), /* Luz circular. */
linear-gradient(135deg, #f7f8ff, #eef1f8); /* Camada inferior, em diagonal. */
}
.login-card {
border-radius: 1.5rem; /* Arredonda os cantos em unidades da fonte raiz. */
}
Dentro do cartão de frontend/index.html, preencha o div.card-body. O formulário ainda está vazio. aria-hidden retira o símbolo decorativo da leitura assistiva; role="alert" identifica mensagens de erro. A classe d-none mantém o alerta oculto até que haja uma mensagem.
frontend/index.html
<div class="card-body p-4 p-sm-5">
<div class="brand-mark mb-4" aria-hidden="true">♠</div>
<p class="eyebrow mb-2">DUELO DE CARTAS</p>
<h1 id="login-title" class="h2 fw-bold mb-2">Entre para jogar</h1>
<p class="text-secondary mb-4">Vence a carta de maior valor.</p>
<div id="login-alert" class="alert alert-danger d-none" role="alert"></div>
<form id="login-form" novalidate></form>
</div>
No final de frontend/css/styles.css, estilize o símbolo e o texto acima do título. rem usa o tamanho da fonte da raiz; em, neste espaçamento, usa o tamanho da fonte do próprio elemento. Recarregue e compare o símbolo antes e depois dessas regras.
frontend/css/styles.css
.login-card {
border-radius: 1.5rem; /* Arredonda os cantos em unidades da fonte raiz. */
}
.brand-mark {
width: 4rem; /* Reserva a largura do símbolo. */
height: 4rem; /* Mantém a área externa quadrada. */
display: grid; /* Cria uma grade para posicionar o conteúdo. */
place-items: center; /* Centraliza o símbolo nos dois eixos. */
border-radius: 1.25rem; /* Suaviza os cantos do quadrado. */
background: var(--game-primary);
color: white;
font-size: 2rem;
}
.eyebrow {
color: var(--game-primary);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em; /* Afasta as letras em relação ao tamanho do texto. */
}
Acrescentar campos e botão dentro do formulário
O DOM representa o HTML em objetos acessíveis pelo JavaScript. Um id identifica um elemento; label for associa o rótulo ao campo de mesmo id. Elementos como div, label e form têm abertura e fechamento. input, meta e link são elementos vazios do HTML e não recebem uma tag de fechamento.
Em frontend/index.html, acrescente o campo dentro do form existente. type="email" e required definem restrições; name será usado para obter o valor. novalidate desliga a mensagem automática no envio, mas preserva as restrições que o controlador consultará com checkValidity().
frontend/index.html
<form id="login-form" novalidate>
<div class="mb-3">
<label for="email" class="form-label fw-semibold">E-mail</label>
<input id="email" name="email" type="email"
class="form-control form-control-lg" autocomplete="username" required>
<div class="invalid-feedback">Informe um e-mail válido.</div>
</div>
</form>
No mesmo formulário, o campo de senha entra abaixo do campo de e-mail. autocomplete informa ao navegador a finalidade do dado; type="password" mascara sua apresentação, mas não criptografa o transporte.
frontend/index.html
<form id="login-form" novalidate>
<div class="mb-3">
<label for="email" class="form-label fw-semibold">E-mail</label>
<input id="email" name="email" type="email"
class="form-control form-control-lg" autocomplete="username" required>
<div class="invalid-feedback">Informe um e-mail válido.</div>
</div>
<div class="mb-4">
<label for="password" class="form-label fw-semibold">Senha</label>
<input id="password" name="password" type="password"
class="form-control form-control-lg" autocomplete="current-password"
minlength="8" required>
<div class="invalid-feedback">Use pelo menos 8 caracteres.</div>
</div>
</form>
Depois do campo de senha e antes de </form>, acrescente o botão. Preserve os dois campos; o comentário apenas indica o lugar que eles já ocupam. disabled mantém o envio indisponível até a implementação do login.
frontend/index.html
<form id="login-form" novalidate>
<!-- Mantenha aqui os dois campos já existentes. -->
<button id="login-button" class="btn btn-primary btn-lg w-100"
type="submit" disabled>
Entrar
</button>
</form>
No final de frontend/css/styles.css, personalize o botão por suas variáveis oficiais. A biblioteca mantém o comportamento de foco e os estados do componente. O botão desabilitado ainda recebe o estilo visual próprio desse estado.
frontend/css/styles.css
.eyebrow {
color: var(--game-primary);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em; /* Afasta as letras em relação ao tamanho do texto. */
}
/* As variáveis --bs-btn-* pertencem ao componente de botão do Bootstrap. */
.btn-primary {
--bs-btn-bg: var(--game-primary); /* Fundo no estado normal. */
--bs-btn-border-color: var(--game-primary); /* Borda no estado normal. */
--bs-btn-hover-bg: var(--game-primary-dark); /* Fundo ao passar o ponteiro. */
--bs-btn-hover-border-color: var(--game-primary-dark); /* Borda no hover. */
}
Abra http://localhost:8080 no navegador local. O formulário deve mostrar título, e-mail, senha e botão. Reduza a janela: o cartão deve permanecer dentro da tela. No navegador, as ferramentas de desenvolvimento permitem simular uma largura de celular.
Computador local · terminal na raiz · versão C03
git add frontend
git commit -m "front: cria formulario responsivo"
git push
8. C04: painel, cartas e histórico
Criar a página do painel e sua moldura
Computador local · raiz de duelo-cartas
mkdir -p frontend
touch frontend/dashboard.html
No computador local, crie frontend/dashboard.html. A página começa completa e vazia; ela usa as mesmas folhas de estilo já existentes.
frontend/dashboard.html
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jogo | Duelo de Cartas</title>
<!-- A CDN entrega uma versão específica do Bootstrap. -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<link rel="stylesheet" href="css/styles.css">
</head>
<body class="dashboard-page">
</body>
</html>
Em frontend/dashboard.html, preencha o body com a barra de navegação e a região principal. Ambas já têm fechamento; seus conteúdos serão inseridos dentro delas.
frontend/dashboard.html
<body class="dashboard-page">
<nav class="navbar navbar-dark game-navbar shadow-sm">
<div class="container"></div>
</nav>
<main class="container py-4 py-lg-5"></main>
</body>
No final de frontend/css/styles.css, acrescente o fundo e a barra do painel. Abra http://localhost:8080/dashboard.html: a região colorida superior já deve ser visível.
frontend/css/styles.css
.dashboard-page {
background: var(--game-bg);
min-height: 100vh; /* Garante fundo em pelo menos toda a altura da janela. */
}
.game-navbar {
/* 90deg faz a transição horizontal entre as duas cores. */
background: linear-gradient(90deg, var(--game-primary-dark), var(--game-primary));
}
Dentro de nav, preencha seu div.container. gap-3 separa os elementos; d-none d-sm-inline oculta o nome em telas pequenas e o exibe a partir de 576 px. O espaço para o nome ainda está vazio.
frontend/dashboard.html
<div class="container">
<a class="navbar-brand fw-bold" href="dashboard.html">♠ Duelo de Cartas</a>
<div class="d-flex align-items-center gap-3">
<span id="player-name" class="text-white d-none d-sm-inline"></span>
<button id="logout-button" class="btn btn-outline-light btn-sm"
type="button" disabled>Sair</button>
</div>
</div>
Preencha main com o alerta e três seções: placar, jogo e histórico. Os identificadores tornam inequívoco o lugar de cada acréscimo. O alerta começa oculto e as seções começam vazias.
frontend/dashboard.html
<main class="container py-4 py-lg-5">
<div id="dashboard-alert" class="alert alert-danger d-none" role="alert"></div>
<section id="stats-grid" class="row g-3 mb-4" aria-label="Placar acumulado"></section>
<section id="game-panel" class="game-panel card border-0 shadow-sm mb-4"></section>
<section id="history-panel" class="card border-0 shadow-sm"></section>
</main>
Montar os indicadores e aplicar seus estilos
Dentro de section#stats-grid, acrescente os dois primeiros indicadores. Cada col-6 ocupa metade da linha pequena; col-lg-3 ocupa um quarto na tela grande. Os valores iniciais são zero.
frontend/dashboard.html
<section id="stats-grid" class="row g-3 mb-4" aria-label="Placar acumulado">
<div class="col-6 col-lg-3">
<div class="stat-card">
<span class="stat-label">Partidas</span>
<strong id="games-stat" class="stat-value">0</strong>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card stat-win">
<span class="stat-label">Vitórias</span>
<strong id="wins-stat" class="stat-value">0</strong>
</div>
</div>
</section>
No final de frontend/css/styles.css, defina o cartão do placar. A faixa lateral diferencia os resultados; o texto também identifica cada indicador, de modo que a informação não depende apenas da cor.
frontend/css/styles.css
.game-navbar {
/* 90deg faz a transição horizontal entre as duas cores. */
background: linear-gradient(90deg, var(--game-primary-dark), var(--game-primary));
}
.stat-card {
height: 100%; /* Faz o cartão preencher a altura da coluna da grade. */
padding: 1.15rem; /* Afasta o conteúdo das quatro bordas. */
border-radius: 1rem; /* Arredonda os cantos do cartão. */
background: white;
border-left: 0.35rem solid var(--game-primary); /* Faixa de identificação. */
box-shadow: 0 0.35rem 1rem #1720330f; /* x, y, desfoque e cor da sombra. */
}
.stat-win { border-left-color: #198754; }
.stat-draw { border-left-color: #d28b00; }
.stat-loss { border-left-color: #dc3545; }
Na mesma seção do placar, preserve as duas colunas anteriores e insira Empates e Derrotas antes de </section>. O conjunto passa a ter duas colunas por linha no celular e quatro na tela grande.
frontend/dashboard.html
<section id="stats-grid" class="row g-3 mb-4" aria-label="Placar acumulado">
<!-- Mantenha aqui as colunas Partidas e Vitórias. -->
<div class="col-6 col-lg-3">
<div class="stat-card stat-draw">
<span class="stat-label">Empates</span>
<strong id="draws-stat" class="stat-value">0</strong>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card stat-loss">
<span class="stat-label">Derrotas</span>
<strong id="losses-stat" class="stat-value">0</strong>
</div>
</div>
</section>
Acrescente os estilos de legenda e valor em frontend/css/styles.css. Recarregue o painel local e altere a largura da janela: os quatro cartões devem reorganizar-se, mantendo os números abaixo de suas legendas.
frontend/css/styles.css
.stat-win { border-left-color: #198754; }
.stat-draw { border-left-color: #d28b00; }
.stat-loss { border-left-color: #dc3545; }
.stat-label {
display: block; /* Coloca a legenda em uma linha própria. */
color: #667085;
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase; /* Muda a apresentação, preservando o texto no DOM. */
}
.stat-value {
display: block; /* Separa o número da legenda. */
margin-top: 0.25rem; /* Cria espaço externo acima do número. */
font-size: 2rem;
}
Construir a área de jogo e as cartas
Dentro de section#game-panel, acrescente o corpo do cartão. p-4 define espaçamento interno; p-lg-5 aumenta esse espaço a partir do breakpoint grande.
frontend/dashboard.html
<section id="game-panel" class="game-panel card border-0 shadow-sm mb-4">
<div class="card-body p-4 p-lg-5"></div>
</section>
Preencha o corpo da seção de jogo com o título e uma linha vazia para as cartas. text-center centraliza o texto; justify-content-center centraliza as colunas da grade.
frontend/dashboard.html
<div class="card-body p-4 p-lg-5">
<div class="text-center mb-4">
<p class="eyebrow mb-2">CARTA MAIOR</p>
<h1 id="game-title" class="h2 fw-bold">Você contra a máquina</h1>
<p class="text-secondary mb-0">Duas cartas diferentes a cada rodada.</p>
</div>
<div id="cards-row" class="row justify-content-center align-items-center g-4 mb-4"></div>
</div>
No final de frontend/css/styles.css, ajuste os cantos da área de jogo. O fundo branco e a sombra vêm das classes do Bootstrap já aplicadas ao HTML.
frontend/css/styles.css
.stat-value {
display: block; /* Separa o número da legenda. */
margin-top: 0.25rem; /* Cria espaço externo acima do número. */
font-size: 2rem;
}
.game-panel {
border-radius: 1.25rem; /* Arredonda o painel que agrupa as cartas. */
}
Dentro de div#cards-row, acrescente a coluna da primeira carta. mx-auto centraliza a carta por suas margens horizontais. aria-live="polite" permite anunciar mudanças de valor sem interromper imediatamente uma leitura assistiva.
frontend/dashboard.html
<div id="cards-row" class="row justify-content-center align-items-center g-4 mb-4">
<div class="col-6 col-md-4 text-center">
<h2 class="h6 text-uppercase text-secondary">Sua carta</h2>
<div id="player-card" class="playing-card mx-auto" aria-live="polite">
<span class="card-rank">?</span>
<span class="card-suit">♠</span>
</div>
</div>
</div>
Acrescente esta regra em frontend/css/styles.css. 24vw equivale a 24% da largura da janela, mas clamp mantém o resultado entre os limites. Recarregue o painel: a primeira carta passa a ter proporção, borda e conteúdo centralizado.
frontend/css/styles.css
.game-panel {
border-radius: 1.25rem; /* Arredonda o painel que agrupa as cartas. */
}
.playing-card {
width: clamp(7.5rem, 24vw, 10rem); /* Mínimo, largura fluida e máximo. */
aspect-ratio: 5 / 7; /* Mantém a relação entre largura e altura. */
display: flex; /* Organiza valor e naipe como itens flexíveis. */
flex-direction: column; /* Empilha os itens; o eixo principal fica vertical. */
align-items: center; /* Centraliza horizontalmente no eixo transversal. */
justify-content: center; /* Centraliza verticalmente no eixo principal. */
border: 0.2rem solid #d8deeb; /* Define espessura, tipo e cor da borda. */
border-radius: 1rem; /* Arredonda os cantos da carta. */
background: white;
box-shadow: 0 0.7rem 1.5rem #1720331f; /* Cria profundidade abaixo da carta. */
}
Na linha das cartas, mantenha a coluna da pessoa e acrescente a coluna da máquina. O desenho é reutilizado pela classe playing-card; os ids diferentes permitirão atualizar os conteúdos separadamente.
frontend/dashboard.html
<div id="cards-row" class="row justify-content-center align-items-center g-4 mb-4">
<div class="col-6 col-md-4 text-center">
<h2 class="h6 text-uppercase text-secondary">Sua carta</h2>
<div id="player-card" class="playing-card mx-auto" aria-live="polite">
<span class="card-rank">?</span>
<span class="card-suit">♠</span>
</div>
</div>
<div class="col-6 col-md-4 text-center">
<h2 class="h6 text-uppercase text-secondary">Máquina</h2>
<div id="machine-card" class="playing-card mx-auto" aria-live="polite">
<span class="card-rank">?</span>
<span class="card-suit">♠</span>
</div>
</div>
</div>
No final de frontend/css/styles.css, dimensione valor e naipe. Um seletor como .playing-card.is-red exige as duas classes no mesmo elemento. Mais adiante, o JavaScript acrescentará a classe da cor retornada pela API.
frontend/css/styles.css
.playing-card.is-red { color: #c92a2a; }
.playing-card.is-black { color: #172033; }
.card-rank {
font-size: clamp(2rem, 7vw, 3.25rem); /* Ajusta o valor à tela com limites. */
font-weight: 800;
line-height: 1; /* A altura da linha acompanha o tamanho da fonte. */
}
.card-suit {
font-size: clamp(2.2rem, 8vw, 3.8rem); /* Ajusta o naipe à tela com limites. */
line-height: 1; /* Evita altura de linha extra ao redor do símbolo. */
}
Dentro do corpo do painel de jogo, depois de div#cards-row, acrescente a mensagem e o botão. Preserve o cabeçalho e as duas cartas. d-grid permite que o botão ocupe a largura da coluna definida para ele.
frontend/dashboard.html
<div class="card-body p-4 p-lg-5">
<!-- Mantenha aqui o cabeçalho e div#cards-row completos. -->
<p id="round-result" class="result-message text-center mb-4" aria-live="polite">
Pronto para a primeira rodada?
</p>
<div class="d-grid col-12 col-sm-8 col-md-5 mx-auto">
<button id="play-button" class="btn btn-primary btn-lg"
type="button" disabled>Jogar uma rodada</button>
</div>
</div>
No final de frontend/css/styles.css, reserve uma altura mínima para o resultado. Recarregue: o painel deve exibir as duas cartas, a mensagem e o botão desabilitado.
frontend/css/styles.css
.card-suit {
font-size: clamp(2.2rem, 8vw, 3.8rem); /* Ajusta o naipe à tela com limites. */
line-height: 1; /* Evita altura de linha extra ao redor do símbolo. */
}
.result-message {
min-height: 2rem; /* Reserva espaço e reduz saltos ao trocar a mensagem. */
font-size: 1.15rem;
font-weight: 700;
}
Montar o histórico e ajustar telas estreitas
Dentro de section#history-panel, crie o corpo do cartão. A tabela ficará dentro desse corpo, depois de seu cabeçalho.
frontend/dashboard.html
<section id="history-panel" class="card border-0 shadow-sm">
<div class="card-body p-4"></div>
</section>
Preencha o corpo do histórico com título e botão. justify-content-between distribui os grupos nas extremidades; flex-wrap permite quebrar a linha quando faltar espaço, evitando sobreposição em celulares.
frontend/dashboard.html
<div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<p class="eyebrow mb-1">CRUD</p>
<h2 id="history-title" class="h4 fw-bold mb-0">Histórico de rodadas</h2>
</div>
<button id="reset-button" class="btn btn-outline-danger btn-sm"
type="button" disabled>Excluir histórico</button>
</div>
</div>
Após o cabeçalho do histórico, ainda dentro do card-body, acrescente a tabela. table-responsive restringe a rolagem horizontal à região da tabela; thead reúne títulos e tbody receberá as rodadas.
frontend/dashboard.html
<div class="card-body p-4">
<!-- Mantenha aqui o cabeçalho e o botão Excluir histórico. -->
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead></thead>
<tbody id="history-body"></tbody>
</table>
</div>
</div>
Dentro da tabela, preencha thead. Cada th identifica uma coluna; scope="col" explicita essa associação para tecnologias assistivas. O tbody permanece vazio até haver dados da API.
frontend/dashboard.html
<thead>
<tr>
<th scope="col">Data</th>
<th scope="col">Você</th>
<th scope="col">Máquina</th>
<th scope="col">Resultado</th>
</tr>
</thead>
Depois do div.table-responsive, acrescente a mensagem de lista vazia. A aplicação ocultará essa mensagem quando receber rodadas. O painel exibirá até vinte registros recentes; os indicadores acumularão todas as rodadas armazenadas.
frontend/dashboard.html
<div class="card-body p-4">
<!-- Mantenha aqui o cabeçalho e a tabela completos. -->
<p id="empty-history" class="text-secondary text-center py-4 mb-0">
Nenhuma rodada registrada.
</p>
</div>
Finalize frontend/css/styles.css com os ajustes de telas estreitas. padding-inline altera o espaço interno nas duas laterais da direção de escrita. O !important é necessário aqui porque os utilitários de espaçamento do Bootstrap também usam essa prioridade; o seletor mais específico resolve a disputa.
frontend/css/styles.css
.result-message {
min-height: 2rem; /* Reserva espaço e reduz saltos ao trocar a mensagem. */
font-size: 1.15rem;
font-weight: 700;
}
/* A consulta de mídia só ativa estas regras abaixo do breakpoint sm. */
@media (max-width: 575.98px) {
.stat-card {
padding: 0.9rem; /* Reduz o espaço interno em telas estreitas. */
}
.stat-value { font-size: 1.6rem; }
.game-panel .card-body {
padding-inline: 1rem !important; /* Supera o utilitário p-* do Bootstrap. */
}
}
No navegador local, confira http://localhost:8080/dashboard.html em larguras de aproximadamente 390 px e 1280 px. A página deve acomodar o placar e as cartas sem rolagem horizontal global. Os botões continuam desabilitados, pois seus controladores ainda serão criados.
Computador local · terminal na raiz · versão C04
git add frontend
git commit -m "front: cria painel e historico"
git push
9. C05: HTTP, status e CORS
HTTP: requisição, endpoint e resposta
HTTP é o protocolo de comunicação usado entre cliente e servidor web. O cliente envia uma requisição: método, endereço, cabeçalhos e, quando necessário, um corpo. O servidor devolve uma resposta: código de status, cabeçalhos e eventualmente um corpo. Um endpoint é um ponto de acesso da API; na prática, o par método e caminho identifica a operação. GET /api/rounds consulta rodadas, enquanto POST /api/rounds cria uma rodada.
Cabeçalhos transportam metadados. Content-Type: application/json informa o formato do corpo. JSON representa objetos, listas, textos, números, valores booleanos e nulo; é um formato de troca de dados, diferente de um objeto JavaScript já carregado em memória. HTTPS acrescenta a proteção TLS à comunicação HTTP.

O método expressa a intenção; o status informa o resultado observado pelo cliente.
Os métodos principais têm semânticas diferentes. Uma operação segura não solicita alteração do recurso, embora o servidor possa registrar logs. Uma operação idempotente produz o mesmo efeito pretendido quando repetida; isso não exige que todas as respostas tenham o mesmo status.
| Método | Uso e exemplo | Repetição |
|---|---|---|
| GET | Obter uma representação. GET /api/me consultará o perfil. |
Seguro e idempotente. |
| HEAD | Obter cabeçalhos equivalentes aos de GET, sem corpo de resposta. | Seguro e idempotente. |
| POST | Submeter dados ou criar um recurso. POST /api/rounds criará uma rodada. |
Não é idempotente por definição. |
| PUT | Criar ou substituir o estado do recurso no endereço indicado. | Idempotente pela semântica. |
| PATCH | Aplicar modificações parciais; por exemplo, alterar somente um nome. | Depende da operação de alteração. |
| DELETE | Remover o recurso indicado. DELETE /api/rounds excluirá o histórico. |
Idempotente pela semântica. |
| OPTIONS | Consultar opções de comunicação de um recurso. | Seguro e idempotente. |
PUT e PATCH aparecem na comparação por serem comuns em APIs; não haverá rotas desses métodos neste projeto. POST não significa sempre cadastro: o login também submete dados com POST. Repetir o POST de uma rodada pode criar outra rodada; a interface e os testes precisam considerar essa diferença.
Interpretar os códigos de status HTTP
O primeiro dígito indica a família: 1xx, informação intermediária; 2xx, sucesso; 3xx, redirecionamento ou uso de cache; 4xx, requisição que não pôde ser atendida nas condições apresentadas; 5xx, falha no lado servidor. O número HTTP é independente do texto JSON da resposta.
| Código | Significado | Exemplo de interpretação |
|---|---|---|
| 200 OK | Requisição atendida. | Perfil obtido ou histórico excluído com um corpo de resultado. |
| 201 Created | Um recurso foi criado. | Nova rodada registrada pela API. |
| 204 No Content | Sucesso sem corpo. | Uma resposta de preflight pode terminar sem JSON. |
| 301 / 302 | Recurso redirecionado. | O cliente recebe outro endereço em Location. |
| 304 Not Modified | Pode reutilizar a cópia em cache. | Validação condicional de um arquivo já obtido. |
Nos erros, vale separar problemas de entrada, identidade e infraestrutura. Os exemplos abaixo são gerais; o código de cada endpoint define quais deles serão produzidos na aplicação.
| Código | Significado prático |
|---|---|
| 400 Bad Request | Dados ou formato da requisição inválidos, como JSON malformado. |
| 401 Unauthorized | Falta autenticação válida: token ausente, inválido ou expirado. |
| 403 Forbidden | Acesso recusado pelas regras aplicáveis à operação. |
| 404 Not Found | Recurso ou rota não encontrado. |
| 409 Conflict | Conflito com o estado atual, como uma gravação concorrente recusada. |
| 413 Content Too Large | Corpo maior que o limite aceito. |
| 429 Too Many Requests | Limite de requisições atingido. |
| 500 Internal Server Error | Falha interna no processamento da aplicação. |
| 502 Bad Gateway | Um proxy recebeu uma resposta inválida do serviço seguinte. |
| 503 Service Unavailable | Serviço temporariamente indisponível. |
CORS antes da primeira rota
Uma origem combina protocolo, host e porta. O caminho não faz parte dela: http://localhost:8080/index.html e http://localhost:8080/dashboard.html têm a mesma origem. Já http://localhost:3000 usa outra porta e, portanto, é outra origem. Trocar localhost por 127.0.0.1 também muda o host, mesmo quando os dois endereços chegam ao mesmo computador.
Por segurança, a política de mesma origem do navegador restringe a leitura de respostas de outra origem pelo JavaScript. CORS (Cross-Origin Resource Sharing) é o protocolo de cabeçalhos pelo qual o servidor declara quais origens podem acessar uma resposta nessas condições. O navegador aplica essa regra. Neste projeto, o front local estará em http://localhost:8080 e a API em http://localhost:3000; por isso a API precisará permitir explicitamente a origem do front.

O navegador consulta a permissão quando precisa de preflight e confere os cabeçalhos da resposta efetiva.
Antes de certas requisições, o navegador envia um preflight: uma consulta automática com o método OPTIONS. Um POST entre origens com Content-Type: application/json exige essa consulta, assim como o uso do cabeçalho Authorization; uma permissão de preflight em cache pode evitar repetir a consulta a cada chamada. A API responde com a origem aceita e com os métodos e cabeçalhos autorizados.
| Cabeçalho | Quem envia e o que comunica |
|---|---|
Origin |
Navegador: origem da página que iniciou a chamada. |
Access-Control-Request-Method |
Navegador, no OPTIONS: método da requisição pretendida. |
Access-Control-Request-Headers |
Navegador, no OPTIONS: cabeçalhos adicionais pretendidos. |
Access-Control-Allow-Origin |
API: origem autorizada a ler a resposta. |
Access-Control-Allow-Methods |
API, no preflight: métodos permitidos. |
Access-Control-Allow-Headers |
API, no preflight: cabeçalhos aceitos. |
O middleware cors, que será instalado a seguir, produzirá esses cabeçalhos e tratará o OPTIONS antes das rotas de negócio. CORS_ORIGINS será nossa variável de configuração com a lista permitida. Ela não é uma opção especial do Node: o próprio código da aplicação lerá esse nome e o passará ao middleware.
10. C05: projeto Node.js e primeira API
Runtime, npm, módulos e dependências
Node.js executa JavaScript fora do navegador. O npm instala bibliotecas e executa scripts descritos em package.json. Um módulo ES exporta valores com export e importa dependências com import. A configuração type=module aplica esse formato aos arquivos .js. O comando de inicialização do projeto é npm init -y; ele cria o manifesto com valores padrão.
Crie o projeto da API dentro de backend. Instale primeiro apenas Express, CORS e dotenv. Express trata requisições e rotas; CORS fornece cabeçalhos de permissão de origem; dotenv lê o arquivo local de variáveis de ambiente.
Computador local · raiz de duelo-cartas
cd backend
npm init -y
npm pkg set name=duelo-cartas-api type=module main=server.js
npm pkg set private=true --json
npm pkg set 'engines.node=>=22'
npm pkg set 'scripts.start=node server.js'
npm pkg set 'scripts.dev=node --watch server.js'
npm install --save-exact express@5.2.1 cors@2.8.6 dotenv@17.4.2
mkdir -p src
cd ..
npm install atualiza o manifesto e gera package-lock.json, que registra a árvore de dependências. --save-exact remove faixas das dependências diretas. O pipeline usará npm ci: ele exige que o lockfile corresponda ao manifesto e instala a árvore registrada. Os arquivos de ambos devem acompanhar o commit; node_modules permanece fora dele.
Configuração: do ambiente ao objeto config
O mesmo código da API será executado no computador, nos jobs de teste do GitLab e nas instâncias da AWS. A porta, o endereço do banco e a origem autorizada podem mudar entre esses ambientes. backend/src/config.js centraliza essa leitura para que o restante da aplicação use propriedades como config.port, sem espalhar nomes de variáveis por todas as rotas.
Uma variável de ambiente é um par nome/valor entregue ao processo que inicia o programa. O Node disponibiliza esses pares em process.env. Um valor existente é lido como texto: uma variável PORT configurada com 3000 fornece a string "3000". Uma variável ausente fornece undefined. Portanto, o código precisa converter tipos e escolher valores padrão.

O ambiente fornece valores; config.js transforma e centraliza o que a aplicação consumirá.
Localmente, dotenv/config carrega as linhas de um arquivo .env para process.env. Por padrão, procura esse arquivo no diretório de trabalho do processo e não substitui variáveis que já existem. Executaremos os comandos da API dentro de backend; o arquivo local será criado na etapa do banco. Enquanto ele não existe, os valores padrão permitem iniciar o endpoint de saúde.
Computador local · raiz de duelo-cartas
mkdir -p backend/src
touch backend/src/config.js
Comece pelo carregamento do ambiente e pela função list. Ela será usada pela lista de origens de CORS. O operador ?? é a coalescência nula: escolhe o operando da direita somente se o da esquerda for null ou undefined. Valores como 0, false e "" são preservados, diferentemente do operador ||.
backend/src/config.js · leitura e conversão da lista
import 'dotenv/config'; // Carrega o .env local, quando ele existir.
function list(value) {
return (value ?? '') // Ausência de valor vira uma string vazia.
.split(',') // Divide o texto em uma lista, usando a vírgula como separador.
.map((item) => item.trim()) // Remove espaços nas extremidades de cada item.
.filter(Boolean); // Descarta strings vazias: Boolean('') é false.
}
Considere a entrada " http://localhost:8080, http://127.0.0.1:8080, ". split produz três itens; map aplica trim em cada item; filter mantém somente os que passam no teste booleano. Nenhuma dessas etapas inicia uma requisição: elas apenas transformam texto em uma lista de strings.
| Etapa | Resultado |
|---|---|
split(',') |
[" http://localhost:8080", " http://127.0.0.1:8080", " "] |
map(...trim()) |
["http://localhost:8080", "http://127.0.0.1:8080", ""] |
filter(Boolean) |
["http://localhost:8080", "http://127.0.0.1:8080"] |
Se a variável não existir, list(undefined) passa por "", depois por [""] e finalmente retorna []. Uma lista vazia não autoriza a origem de uma página web. O endpoint de saúde ainda poderá ser chamado por um cliente sem cabeçalho Origin, como o curl usado nos testes locais.
Após o fechamento de list, acrescente a porta. Number converte o texto em número; o teste impede iniciar com uma porta vazia, fracionária, fora da faixa ou impossível de converter. throw interrompe a inicialização com uma mensagem de configuração.
backend/src/config.js · após list
}
const port = Number(process.env.PORT ?? 3000); // Converte a string em número.
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT deve ser um inteiro entre 1 e 65535.');
}
Abaixo da validação da porta, acrescente o objeto exportado. A forma curta port equivale a port: port. export permite importar o objeto em outros módulos. Object.freeze impede substituir suas propriedades; o congelamento é superficial e não torna valores secretos nem congela automaticamente o array interno.
backend/src/config.js · configuração exportada
}
export const config = Object.freeze({
port,
region: process.env.AWS_REGION ?? 'us-east-1',
dynamodbEndpoint: process.env.DYNAMODB_ENDPOINT,
usersTable: process.env.USERS_TABLE ?? 'duelo-usuarios',
roundsTable: process.env.ROUNDS_TABLE ?? 'duelo-rodadas',
jwtSecret: process.env.JWT_SECRET,
jwtSecretId: process.env.JWT_SECRET_ID,
corsOrigins: list(process.env.CORS_ORIGINS) // A função já foi definida acima.
});
| Propriedade | Origem e finalidade |
|---|---|
port |
PORT; usa 3000 na ausência. É a porta que o processo Node escutará. |
region |
AWS_REGION; usa us-east-1. Define a região dos clientes AWS. |
dynamodbEndpoint |
DYNAMODB_ENDPOINT; ausente na AWS e definido para o banco local. |
usersTable / roundsTable |
USERS_TABLE e ROUNDS_TABLE; nomes das tabelas de usuários e rodadas. |
jwtSecret |
JWT_SECRET; valor usado localmente para assinar tokens, ainda não configurado. |
jwtSecretId |
JWT_SECRET_ID; nome do segredo no Secrets Manager, usado na implantação. |
corsOrigins |
CORS_ORIGINS; lista de origens autorizadas, separadas por vírgulas. |
O objeto é montado quando o módulo é carregado. Alterar .env enquanto o processo está rodando não reconstrói esse objeto: será necessário reiniciar a API. Não coloque access key, secret key ou token de sessão nesse arquivo para a implantação; a aplicação na EC2 usará as credenciais temporárias do perfil de instância.
Express e ordem dos middlewares
Um middleware recebe requisição, resposta e a função next. Ele pode responder ou encaminhar o processamento. A ordem de registro é relevante: CORS vem antes das rotas, o leitor JSON prepara o corpo e o tratamento de erros fica no final. No Express 5, a rejeição de uma função async é encaminhada ao tratamento de erro.

O processamento respeita a ordem; erros são tratados pelo middleware final.
Computador local · raiz de duelo-cartas
mkdir -p backend/src
touch backend/src/app.js
A função de origem é definida antes de ser passada ao middleware. A lista vazia ainda aceita clientes sem Origin, como o health check.
backend/src/app.js · parte 1 de 3
import express from 'express';
import cors from 'cors';
import { config } from './config.js';
function corsOrigin(origin, callback) {
// curl e health checks podem não enviar Origin.
if (!origin || config.corsOrigins.includes(origin)) {
return callback(null, true);
}
const error = new Error('Origem não autorizada.');
error.status = 403;
return callback(error);
}
createApp cria a aplicação e registra o endpoint de saúde. O limite de 16 KiB se aplica ao corpo JSON.
backend/src/app.js · parte 2 de 3
}
export function createApp() {
const app = express();
app.disable('x-powered-by');
app.use(cors({
origin: corsOrigin,
allowedHeaders: ['Content-Type', 'Authorization']
})); // Inclui a resposta automática ao preflight OPTIONS.
app.use(express.json({ limit: '16kb' }));
app.get('/health', (_request, response) => {
response.json({ status: 'ok' });
});
O fallback devolve 404. O handler com quatro parâmetros preserva erros do cliente como 400 e 413, evita expor detalhes internos e finaliza a resposta.
backend/src/app.js · parte 3 de 3
});
app.use((_request, response) => {
response.status(404).json({ message: 'Rota não encontrada.' });
});
// Os quatro parâmetros identificam o middleware de erro do Express.
app.use((error, _request, response, _next) => {
let status = Number(error.status ?? error.statusCode ?? 500);
if (error.name === 'TransactionCanceledException') status = 409;
if (status < 400 || status > 599) status = 500;
// Não registra corpo, senha ou cabeçalho Authorization.
console.error({ event: 'request_error', name: error.name, status });
const message = status === 409 ? 'Conflito. Atualize e tente novamente.'
: status < 500 ? 'Requisição inválida.' : 'Erro interno do servidor.';
response.status(status).json({ message });
});
return app;
}
Ponto de entrada e execução
Computador local · raiz de duelo-cartas
mkdir -p backend
touch backend/server.js
Agora importe a aplicação já definida e abra a porta. Na plataforma Beanstalk, PORT é fornecida pelo ambiente; localmente, o valor padrão é 3000.
backend/server.js
import { createApp } from './src/app.js';
import { config } from './src/config.js';
const app = createApp();
app.listen(config.port, () => {
console.log(`API disponível na porta ${config.port}`);
});
O Procfile é um arquivo de declaração de processos. Quem o interpreta neste projeto é a plataforma gerenciada Node.js do AWS Elastic Beanstalk, instalada nas instâncias EC2 do ambiente. A plataforma inclui sistema operacional, Node, proxy e rotinas de implantação e supervisão. O arquivo informa o comando do processo web que ela deverá manter em execução.

GitLab entrega o pacote; a plataforma Beanstalk prepara e inicia o processo da API na EC2.
Durante a implantação, o Beanstalk prepara os arquivos e as dependências e usa a declaração para iniciar a aplicação. Quando uma nova versão é implantada, o processo passa a executar o novo código. A plataforma monitora processos declarados no Procfile e reinicia os que terminam. O comando não é executado a cada requisição: um processo permanece atendendo várias chamadas HTTP.
Computador local · raiz de duelo-cartas
mkdir -p backend
touch backend/Procfile
No editor local, crie backend/Procfile, com P maiúsculo e sem extensão. O arquivo contém uma única linha:
backend/Procfile
web: npm start
Em web: npm start, web identifica o processo principal de atendimento; o trecho depois dos dois-pontos é o comando. npm start lê o script start do package.json, que já foi definido como node server.js. Ao gerar o ZIP da API, o pipeline colocará Procfile e package.json na raiz do pacote, lado a lado.
No computador local, executar npm start usa diretamente o manifesto; o Node e o npm não interpretam Procfile automaticamente. No GitLab, o job de implantação enviará o pacote e solicitará a atualização do ambiente. O processo público da API será iniciado na EC2 pelo Beanstalk. O proxy Nginx recebe HTTP e encaminha as chamadas à porta fornecida pela plataforma em PORT.
No terminal local da API, entre em backend a partir da raiz do projeto e mantenha o processo em execução. Use outro terminal para as verificações HTTP.
Computador local · terminal da API, iniciado na raiz
cd backend
npm start
curl é um cliente de linha de comando para transferir dados. Sem outras opções, esta URL é consultada com GET e o corpo da resposta é escrito no terminal. Primeiro consulte o endpoint de saúde sem --fail.
Computador local · outro terminal
curl http://localhost:3000/health
# Exibe o corpo da resposta: {"status":"ok"}.
Agora repita com --fail. A resposta de sucesso é a mesma; a diferença está em tratar respostas HTTP de erro como falha do comando. Isso permite que um job de CI falhe quando um endpoint devolve, por exemplo, 404 ou 500.
Computador local · comparar as opções
curl --fail http://localhost:3000/health
# Em HTTP 200, continua exibindo o mesmo JSON.
curl -i http://localhost:3000/health
# -i inclui a linha de status e os cabeçalhos antes do corpo.
Para observar a diferença, use um caminho que não foi registrado. Sem --fail, a transferência da resposta 404 termina normalmente e curl mostra o JSON de erro. Com --fail, esse 404 produz o código de saída 22 e o corpo não é mostrado. $? consulta o código do comando imediatamente anterior no Bash.
Computador local · comparar HTTP com código de saída
curl http://localhost:3000/rota-inexistente
echo $? # 0: a resposta HTTP foi recebida, embora seu status seja 404.
curl --fail http://localhost:3000/rota-inexistente
echo $? # 22: --fail transformou o HTTP 404 em falha do comando.
O endpoint /health deve responder 200 e {"status":"ok"}. Ele comprova que o processo atende HTTP; ainda não consulta banco nem Secrets Manager. Para encerrar a API, use Ctrl+C no terminal que executa npm start. Após alterar configuração ou módulos, inicie novamente dentro de backend.
Computador local · terminal na raiz · versão C05
git add backend
git commit -m "back: cria API e health check"
git push
11. C06: Docker e DynamoDB Local
Do servidor físico aos contêineres
Em uma implantação direta, aplicações e suas dependências são instaladas em um sistema operacional sobre hardware físico. A administração precisa conciliar versões, bibliotecas e recursos da mesma máquina. A virtualização acrescenta um hipervisor: cada máquina virtual recebe hardware virtual e seu próprio sistema operacional, aumentando o isolamento entre ambientes.
Um contêiner isola processos e sua visão de arquivos e recursos usando mecanismos do sistema operacional. Ele carrega a aplicação e suas dependências de espaço de usuário, mas compartilha o kernel do host; não carrega um sistema operacional convidado completo. Docker fornece ferramentas para construir, distribuir e executar essas unidades. Servidores físicos, VMs e contêineres coexistem: contêineres frequentemente executam dentro de VMs.

A fronteira de isolamento muda; um contêiner Linux compartilha um kernel Linux.
No macOS e no Windows, Docker Desktop normalmente usa uma VM Linux para executar contêineres Linux. O compartilhamento de kernel ocorre com essa VM, não diretamente com o kernel do macOS. Em Linux, o Engine pode usar diretamente o kernel do sistema. Isso explica por que uma imagem Linux pode funcionar no computador de desenvolvimento e no runner.
Imagem, tag, contêiner, porta e volume
Uma imagem é o conjunto de camadas de arquivos usado como modelo; um contêiner é uma instância em execução. Uma tag, como 3.0.0, seleciona uma versão publicada; um digest identifica seu conteúdo criptograficamente. Um volume guarda dados fora da camada descartável do contêiner. Publicar uma porta liga uma porta do host à porta do processo no contêiner.

O banco roda em um contêiner; a API local chega até ele pela porta publicada.
Compose descreve serviços e volumes em YAML. O arquivo abaixo fixa a imagem em uma versão publicada para as arquiteturas usuais e publica a porta somente em 127.0.0.1. A opção user: root simplifica a permissão de escrita do volume neste ambiente local; ela não deve ser tomada como padrão para serviços de produção.
Computador local · raiz de duelo-cartas
touch docker-compose.yml
Crie o serviço do banco. A lista de command é passada como argumentos ao executável definido em entrypoint.
docker-compose.yml
services:
dynamodb:
image: amazon/dynamodb-local:3.0.0
# Torna explícito o executável que receberá command.
entrypoint: ['java']
command: ['-jar', 'DynamoDBLocal.jar', '-sharedDb', '-dbPath', './data']
working_dir: /home/dynamodblocal
user: 'root' # Simplificação local para gravar no volume nomeado.
ports:
- '127.0.0.1:8000:8000' # Publica a porta só no próprio computador.
volumes:
- dynamodb_data:/home/dynamodblocal/data
volumes:
dynamodb_data: # Persiste quando o container é recriado sem remover o volume.
| Elemento | O que realmente significa |
|---|---|
entrypoint: [java] |
Executa a JVM presente na imagem. |
-jar DynamoDBLocal.jar |
Pede à JVM que execute a aplicação contida nesse arquivo JAR. |
-sharedDb |
Clientes locais usam a mesma base lógica, sem separação por credencial e região. |
-dbPath ./data |
Grava a base no diretório data, relativo ao working_dir. |
127.0.0.1:8000:8000 |
Interface do host, porta do host e porta do contêiner, nessa ordem. |
dynamodb_data:/home/dynamodblocal/data |
Monta o volume nomeado no diretório de dados do contêiner. |
Inicie o serviço e confira seu estado. A primeira execução baixa a imagem. O script de criação das tabelas também aguardará o banco aceitar requisições; um contêiner iniciado pode ainda estar inicializando a aplicação.
Computador local · raiz de duelo-cartas
docker compose config
docker compose up -d
docker compose ps
docker compose logs dynamodb
12. C06: modelagem no DynamoDB e criação das tabelas
DynamoDB: itens, atributos e chave primária
O DynamoDB é um banco NoSQL gerenciado. Uma tabela contém itens; cada item reúne atributos, como texto, número, lista e mapa. Um mapa permite armazenar um objeto dentro do item, como os dados de uma carta. A chave primária identifica cada item de forma única dentro da tabela. Seus atributos e tipos são escolhidos na criação da tabela.
Uma partition key, ou chave de partição, é um atributo cujo valor participa da localização dos dados no armazenamento distribuído. O DynamoDB aplica uma função de hash a esse valor para determinar a distribuição interna. Ela não é o número de uma máquina e não cria um servidor por usuário. Uma tabela pode ter somente essa chave ou uma chave composta por partition key e sort key.
Chave simples: um usuário por e-mail
Na tabela duelo-usuarios, o atributo email é a partition key, do tipo String, e é a chave primária completa. O valor ana@exemplo.com identifica um único item. Nome, hash de senha e contadores são atributos desse item, não partes da chave. Duas pessoas podem ter o mesmo nome; dois itens dessa tabela não podem coexistir com o mesmo valor de email.

Com chave simples, a partition key sozinha identifica o item; os demais atributos não tornam a chave diferente.
Chave composta: várias rodadas do mesmo usuário
Na tabela duelo-rodadas, userId é a partition key e roundId é a sort key, ou chave de classificação. Itens com o mesmo userId formam uma coleção lógica. Dentro dela, a sort key distingue os itens e define sua ordem. A chave primária completa é o par (userId, roundId): repetir apenas um dos dois componentes é permitido.

A partition key seleciona a coleção lógica; a sort key identifica e ordena os itens dentro dela.
No projeto, userId guardará o e-mail normalizado. roundId combinará uma data UTC em formato ISO 8601, o caractere # e um UUID. UUID é um identificador de 128 bits; o valor aleatório gerado pela aplicação torna colisões extremamente improváveis. A data com largura fixa organiza cronologicamente as strings; o UUID distingue rodadas criadas no mesmo milissegundo, sem garantir ordem exata entre elas.
Este é um exemplo completo do item que a API produzirá. Ele apresenta dados de uma rodada e será gravado pelo código do jogo; não é uma inserção manual no Console. A partition key seleciona Ana e a sort key identifica esta rodada específica.
Exemplo de item · tabela duelo-rodadas
{
"userId": "ana@exemplo.com",
"roundId": "2026-09-08T14:00:00.000Z#f746fa41-0d82-4b98-b49c-54de8fcd4416",
"createdAt": "2026-09-08T14:00:00.000Z",
"playerCard": {
"rank": 14, "label": "A", "suit": "♠", "color": "black"
},
"machineCard": {
"rank": 9, "label": "9", "suit": "♥", "color": "red"
},
"result": "win"
}
JSON estrito não permite comentários. No item acima, createdAt facilita exibir a data sem separar a sort key; playerCard e machineCard são mapas; result registra vitória, empate ou derrota. Nenhum desses atributos adicionais altera a unicidade do par de chaves.
| Combinação | Pode coexistir com o item acima? |
|---|---|
Mesmo userId e outro roundId |
Sim: é outra rodada de Ana. |
Outro userId e o mesmo roundId |
Sim: o par completo é diferente. |
Mesmo userId e mesmo roundId |
Não como segundo item: é a mesma chave primária. |
Mesmo createdAt e outro UUID em roundId |
Sim: a sort key inteira é diferente. |
O DynamoDB não cria uma chave estrangeira entre userId e email de outra tabela. A aplicação obterá a identidade do token autenticado e usará transações que exigem a existência do usuário. Para permitir mudança de e-mail em um sistema real, um identificador imutável separado costuma ser uma chave de usuário mais conveniente.
Buscar uma rodada ou listar o histórico
Para GetItem de uma tabela com chave composta, informe os dois componentes. Com apenas userId, ainda existem várias rodadas possíveis. Uma Query usa a igualdade da partition key e pode acrescentar uma condição sobre a sort key, como prefixo ou intervalo. No histórico, ela selecionará um usuário e percorrerá suas rodadas da mais recente para a mais antiga.

Query restringe a partition key e percorre a sort key; a paginação continua da chave devolvida pelo serviço.
| Necessidade | Informação necessária |
|---|---|
| Ler um usuário | GetItem em duelo-usuarios, com email. |
| Ler uma rodada | GetItem em duelo-rodadas, com userId e roundId completos. |
| Listar rodadas de Ana | Query com userId igual a ana@exemplo.com. |
| Mais recentes primeiro | Query com ScanIndexForward igual a false. |
| Continuar a próxima página | Usar LastEvaluatedKey como ExclusiveStartKey da próxima consulta. |
A sort key String é ordenada pelos bytes UTF-8; o formato de data usado mantém a ordem cronológica para os prefixos. Limit=20 limita os itens avaliados em uma chamada; a resposta também pode ser limitada por tamanho. Sem filtro adicional, como neste histórico, os itens avaliados são os retornados. Uma Scan percorre a tabela e não é necessária para localizar rodadas pela chave do usuário.
Instalar o SDK e definir o ambiente local
O AWS SDK fornece clientes JavaScript que assinam e enviam as requisições aos serviços. O cliente documental converte objetos JavaScript para o formato de atributos do DynamoDB. Instale os pacotes antes de importá-los.
Computador local · raiz de duelo-cartas
cd backend
npm install --save-exact @aws-sdk/client-dynamodb@3.1125.0 \
@aws-sdk/lib-dynamodb@3.1125.0
npm pkg set 'scripts.db:create=node scripts/create-tables.js'
mkdir -p scripts
cd ..
Computador local · raiz de duelo-cartas
mkdir -p backend
touch backend/.env.example
O arquivo de exemplo guarda nomes e valores fictícios. JWT_SECRET fica vazio porque cada ambiente terá seu próprio valor. A senha de usuário abaixo pertence somente ao banco local.
backend/.env.example
PORT=3000
AWS_REGION=us-east-1
DYNAMODB_ENDPOINT=http://127.0.0.1:8000
USERS_TABLE=duelo-usuarios
ROUNDS_TABLE=duelo-rodadas
# Gere o valor e preencha apenas o arquivo .env.
JWT_SECRET=
CORS_ORIGINS=http://localhost:8080
SEED_NAME=Pessoa Exemplo
SEED_EMAIL=aluno@exemplo.com
# Senha fictícia exclusiva do banco local; não reutilize em outros sistemas.
SEED_PASSWORD=SenhaLocal123!
Copie o exemplo e gere 48 bytes aleatórios, codificados em Base64URL. Copie a saída para JWT_SECRET= apenas em backend/.env, sem aspas adicionais. Esse valor é um segredo da aplicação, não uma senha de usuário. Ele será usado quando a autenticação for implementada.
Computador local · raiz de duelo-cartas
cp backend/.env.example backend/.env
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
git check-ignore backend/.env
O último comando deve imprimir o caminho ignorado. Não cadastre as credenciais reais do Learner Lab nesse arquivo. O cliente abaixo fornece credenciais fictícias quando há endpoint local e usa a cadeia padrão do SDK quando o endpoint não existe.
Computador local · raiz de duelo-cartas
mkdir -p backend/src
touch backend/src/dynamo.js
Defina as opções locais antes de criar o cliente. O operador ternário escolhe entre o conjunto local e um objeto vazio.
backend/src/dynamo.js
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { config } from './config.js';
// Somente o endpoint local recebe credenciais fictícias explícitas.
const localOptions = config.dynamodbEndpoint
? {
endpoint: config.dynamodbEndpoint,
credentials: {
accessKeyId: 'local',
secretAccessKey: 'local'
}
}
: {};
export const lowLevelClient = new DynamoDBClient({
region: config.region,
...localOptions // Espalha opções locais; na AWS o objeto está vazio.
});
// O DocumentClient converte objetos JavaScript para o formato do DynamoDB.
export const documentClient = DynamoDBDocumentClient.from(lowLevelClient, {
marshallOptions: { removeUndefinedValues: true }
});
Criar as tabelas sem corrida de inicialização
Um script idempotente pode ser repetido sem duplicar o efeito pretendido. A rotina abaixo só aceita endpoint local, espera o banco responder e ignora exclusivamente o erro de tabela já existente. Ela aguarda a tabela ficar disponível antes de prosseguir.
Computador local · raiz de duelo-cartas
mkdir -p backend/scripts
touch backend/scripts/create-tables.js
Importe os comandos e valide o destino antes de enviar qualquer criação.
backend/scripts/create-tables.js · parte 1 de 4
import { CreateTableCommand, ListTablesCommand, waitUntilTableExists }
from '@aws-sdk/client-dynamodb';
import { setTimeout as delay } from 'node:timers/promises';
import { config } from '../src/config.js';
import { lowLevelClient } from '../src/dynamo.js';
// Evita que este provisionamento de desenvolvimento alcance a AWS.
if (!config.dynamodbEndpoint) {
throw new Error('db:create exige DYNAMODB_ENDPOINT local.');
}
A espera repete uma consulta leve com intervalo, até a aplicação do banco estar pronta.
backend/scripts/create-tables.js · parte 2 de 4
}
async function waitForDatabase() {
for (let attempt = 0; attempt < 40; attempt += 1) {
try {
await lowLevelClient.send(new ListTablesCommand({ Limit: 1 }));
return;
} catch (error) {
if (attempt === 39) throw error;
await delay(500); // O container pode existir antes de aceitar conexões.
}
}
}
A criação pode ser repetida. O waiter acompanha a disponibilidade da tabela em vez de supor que uma solicitação de criação já a tornou utilizável.
backend/scripts/create-tables.js · parte 3 de 4
}
async function createTable(input) {
try {
await lowLevelClient.send(new CreateTableCommand(input));
} catch (error) {
if (error.name !== 'ResourceInUseException') throw error;
// Repetir a criação não apaga a tabela existente.
}
await waitUntilTableExists(
{ client: lowLevelClient, maxWaitTime: 30, minDelay: 1, maxDelay: 2 },
{ TableName: input.TableName }
);
console.log(`Tabela pronta: ${input.TableName}`);
}
Agora execute as funções já definidas e crie as duas tabelas. HASH e RANGE representam partition key e sort key; S significa String.
backend/scripts/create-tables.js · parte 4 de 4
}
await waitForDatabase();
await createTable({
TableName: config.usersTable,
BillingMode: 'PAY_PER_REQUEST',
AttributeDefinitions: [{ AttributeName: 'email', AttributeType: 'S' }],
KeySchema: [{ AttributeName: 'email', KeyType: 'HASH' }]
}); // HASH significa partition key; S significa String.
await createTable({
TableName: config.roundsTable,
BillingMode: 'PAY_PER_REQUEST',
AttributeDefinitions: [
{ AttributeName: 'userId', AttributeType: 'S' },
{ AttributeName: 'roundId', AttributeType: 'S' }
],
KeySchema: [
{ AttributeName: 'userId', KeyType: 'HASH' },
{ AttributeName: 'roundId', KeyType: 'RANGE' }
] // RANGE significa sort key; não é outra tabela ou índice.
});
HASH e RANGE são os nomes usados pela API para partition key e sort key; não representam algoritmos que precisam ser implementados. PAY_PER_REQUEST representa capacidade sob demanda no serviço gerenciado; o DynamoDB Local não simula cobrança nem todas as limitações operacionais.
Computador local · raiz de duelo-cartas
cd backend
npm run db:create
cd ..
A saída deve indicar que as duas tabelas estão prontas. Repita o comando: ele não deve apagar dados ou recriar tabelas existentes.
Computador local · terminal na raiz · versão C06
git add docker-compose.yml backend
git commit -m "dev: adiciona DynamoDB Local"
git push
13. C07: autenticação com bcrypt e JWT
Senha, hash, segredo e token
Autenticação verifica quem está fazendo a requisição; autorização determina quais operações essa identidade pode realizar. O login compara a senha recebida com um hash armazenado. Um hash de senha é uma transformação projetada para tornar tentativas de descoberta caras; ele não é uma cifra que a API precisa reverter.
bcrypt incorpora um salt aleatório e um fator de custo no hash. O salt evita que senhas iguais produzam necessariamente o mesmo resultado; o custo controla o trabalho da comparação. Bcrypt considera no máximo 72 bytes de senha, por isso a API e o script de cadastro rejeitam entradas maiores; a quantidade de bytes pode exceder a quantidade de caracteres em UTF-8.
Após uma comparação válida, a API cria um JWT: um token com cabeçalho, payload e assinatura. Usaremos HS256, que assina por HMAC com SHA-256 e um segredo compartilhado apenas pela API. Assinatura protege a integridade do token; o payload não está criptografado. Não coloque senha, hash ou credenciais AWS nele. O campo sub identifica o usuário; iss, aud e exp restringem emissor, destinatário e validade.

O segredo JWT não é a senha de login e não substitui as credenciais AWS.

A senha participa do login; as rotas seguintes recebem um token verificável.
Instalar bibliotecas e resolver o segredo
Instale bcryptjs, jsonwebtoken e o cliente do Secrets Manager. O segredo local já foi gerado em backend/.env. Na AWS, será criado outro valor independente no Secrets Manager; a API receberá somente seu identificador.
Computador local · raiz de duelo-cartas
cd backend
npm install --save-exact bcryptjs@3.0.3 jsonwebtoken@9.0.3 \
@aws-sdk/client-secrets-manager@3.1125.0
npm pkg set 'scripts.db:seed=node scripts/seed-user.js'
mkdir -p src/routes
cd ..
Computador local · raiz de duelo-cartas
mkdir -p backend/src
touch backend/src/secrets.js
A validação exige texto com comprimento mínimo. Um valor longo escolhido manualmente não garante boa entropia; use o gerador aleatório já apresentado.
backend/src/secrets.js · parte 1 de 2
import { GetSecretValueCommand, SecretsManagerClient }
from '@aws-sdk/client-secrets-manager';
import { config } from './config.js';
const client = new SecretsManagerClient({ region: config.region });
let cachedSecret;
function validateSecret(value) {
if (typeof value !== 'string' || value.length < 32) {
throw new Error('JWT_SECRET deve ter pelo menos 32 caracteres.');
}
return value;
}
getJwtSecret retorna o valor local ou consulta o segredo JSON na AWS. O cache fica na memória do processo para evitar uma consulta por requisição.
backend/src/secrets.js · parte 2 de 2
}
export async function getJwtSecret() {
if (config.jwtSecret) return validateSecret(config.jwtSecret);
if (cachedSecret) return cachedSecret;
if (!config.jwtSecretId) {
throw new Error('Defina JWT_SECRET ou JWT_SECRET_ID.');
}
const result = await client.send(new GetSecretValueCommand({
SecretId: config.jwtSecretId
}));
// O segredo é um objeto JSON com a chave JWT_SECRET.
const data = JSON.parse(result.SecretString ?? '{}');
cachedSecret = validateSecret(data.JWT_SECRET);
return cachedSecret;
}
Definir funções antes de registrar as rotas
Computador local · raiz de duelo-cartas
mkdir -p backend/src
touch backend/src/auth.js
A normalização trata o identificador. A assinatura informa explicitamente algoritmo, identidade, emissor, audiência e expiração.
backend/src/auth.js · parte 1 de 3
import jwt from 'jsonwebtoken';
import { getJwtSecret } from './secrets.js';
export function normalizeEmail(value) {
return typeof value === 'string' ? value.trim().toLowerCase() : '';
}
export async function createToken(user) {
const secret = await getJwtSecret();
return jwt.sign({ name: user.name }, secret, {
algorithm: 'HS256', // Assina usando HMAC com SHA-256.
subject: user.email, // sub identifica o usuário autenticado.
issuer: 'duelo-api',
audience: 'duelo-web',
expiresIn: '1h'
});
}
O middleware extrai o Bearer e verifica o token. O segredo é obtido antes do try de validação, distinguindo falha de infraestrutura de token inválido.
backend/src/auth.js · parte 2 de 3
}
export async function requireAuth(request, response, next) {
// A regex captura o texto após Bearer; ?. trata cabeçalho ausente.
const match = request.get('authorization')?.match(/^Bearer\s+(.+)$/i);
if (!match) {
return response.status(401).json({ message: 'Token ausente.' });
}
// Falha ao obter o segredo é erro do servidor, não do usuário.
const secret = await getJwtSecret();
try {
const payload = jwt.verify(match[1], secret, {
algorithms: ['HS256'], // Recusa tokens com outro algoritmo.
issuer: 'duelo-api',
audience: 'duelo-web'
});
if (typeof payload.sub !== 'string' || !payload.sub) {
throw new Error('Token sem identificação.');
}
request.user = { email: payload.sub, name: payload.name };
return next();
Feche o tratamento da autenticação: assinatura incorreta, conteúdo inválido ou expiração resultam em 401.
backend/src/auth.js · parte 3 de 3
request.user = { email: payload.sub, name: payload.name };
return next();
} catch {
return response.status(401).json({
message: 'Token inválido ou expirado.'
});
}
}
Computador local · raiz de duelo-cartas
mkdir -p backend/src/routes
touch backend/src/routes/auth-routes.js
A rota importa funções já definidas. O hash substituto mantém uma comparação bcrypt mesmo quando o e-mail não existe, reduzindo uma diferença óbvia de tempo.
backend/src/routes/auth-routes.js · parte 1 de 2
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { GetCommand } from '@aws-sdk/lib-dynamodb';
import { createToken, normalizeEmail } from '../auth.js';
import { config } from '../config.js';
import { documentClient } from '../dynamo.js';
export const authRouter = Router();
// Um hash substituto reduz diferenças de tempo para e-mails inexistentes.
const dummyHash = bcrypt.hashSync('nao-e-uma-senha-de-usuario', 12);
authRouter.post('/login', async (request, response) => {
const email = normalizeEmail(request.body?.email);
const password = request.body?.password;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ||
typeof password !== 'string' || password.length < 8 ||
Buffer.byteLength(password, 'utf8') > 72) {
return response.status(400).json({ message: 'Dados inválidos.' });
}
A validação verifica tipos, formato básico do e-mail e limite da senha. A leitura usa a chave completa da tabela de usuários.
backend/src/routes/auth-routes.js · parte 2 de 2
}
const result = await documentClient.send(new GetCommand({
TableName: config.usersTable,
Key: { email },
ConsistentRead: true // Enxerga gravações já confirmadas na tabela.
}));
const valid = await bcrypt.compare(
password, result.Item?.passwordHash ?? dummyHash
);
if (!result.Item || !valid) {
return response.status(401).json({
message: 'E-mail ou senha inválidos.'
});
}
const token = await createToken(result.Item);
return response.json({
token,
user: { email: result.Item.email, name: result.Item.name }
});
});
Acrescentar a rota à aplicação existente
Em backend/src/app.js, acrescente a importação abaixo junto das importações já existentes. O módulo foi criado antes de ser utilizado.
backend/src/app.js · importação
import { config } from './config.js';
import { authRouter } from './routes/auth-routes.js';
function corsOrigin(origin, callback) {
Depois do fechamento da rota /health e antes do middleware 404, acrescente o registro do roteador. O prefixo /api/auth somado a /login forma a URL final.
backend/src/app.js · após health
response.json({ status: 'ok' });
});
app.use('/api/auth', authRouter);
app.use((_request, response) => {
Criar o primeiro usuário
Um script de seed insere dados iniciais controlados. O script abaixo lê nome, e-mail e senha de variáveis de ambiente, gera um hash com custo 12 e usa uma condição de ausência: repetir o comando não substitui uma senha existente.
Computador local · raiz de duelo-cartas
mkdir -p backend/scripts
touch backend/scripts/seed-user.js
Importe as dependências, leia as variáveis e valide nome, e-mail e limites da senha. Só então produza o hash bcrypt com custo 12.
backend/scripts/seed-user.js · parte 1 de 3
import bcrypt from 'bcryptjs';
import { PutCommand } from '@aws-sdk/lib-dynamodb';
import { config } from '../src/config.js';
import { documentClient } from '../src/dynamo.js';
import { normalizeEmail } from '../src/auth.js';
const name = String(process.env.SEED_NAME ?? '').trim();
const email = normalizeEmail(process.env.SEED_EMAIL);
const password = String(process.env.SEED_PASSWORD ?? '');
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ||
password.length < 8 || Buffer.byteLength(password, 'utf8') > 72) {
throw new Error('Defina nome, e-mail e senha de 8 caracteres até 72 bytes.');
}
// O custo 12 controla o trabalho; bcrypt gera um salt automaticamente.
const passwordHash = await bcrypt.hash(password, 12);
Grave o usuário com contadores zerados. A condição de ausência preserva um cadastro já existente.
backend/scripts/seed-user.js · parte 2 de 3
const passwordHash = await bcrypt.hash(password, 12);
try {
// A condição evita trocar silenciosamente a senha de um usuário já criado.
await documentClient.send(
new PutCommand({
TableName: config.usersTable,
Item: {
email,
name,
passwordHash,
games: 0,
wins: 0,
draws: 0,
losses: 0,
createdAt: new Date().toISOString()
},
ConditionExpression: 'attribute_not_exists(email)'
})
);
console.log(`Usuário criado: ${email}`);
Reconheça somente o erro de cadastro existente como uma repetição segura; outras falhas devem encerrar o script com erro.
backend/scripts/seed-user.js · parte 3 de 3
);
console.log(`Usuário criado: ${email}`);
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
console.log(`Usuário já existe; nenhuma senha foi alterada: ${email}`);
} else {
throw error;
}
}
Com o contêiner iniciado e as tabelas prontas, execute o seed e reinicie a API. A senha abaixo vem de backend/.env; se você a alterou, use o valor correspondente no teste de login.
Computador local · terminal da API, partindo da raiz
cd backend
npm run db:seed
npm start
Computador local · outro terminal · autenticação
curl -i -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"aluno@exemplo.com","password":"SenhaErrada123!"}'
curl -i -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"aluno@exemplo.com","password":"SenhaLocal123!"}'
A primeira chamada deve retornar 401; a segunda, 200 com token e identificação pública. -H acrescenta um cabeçalho; -d envia o corpo. Não publique tokens recebidos em logs, issues ou commits.
Computador local · terminal na raiz · versão C07
git add backend
git commit -m "back: implementa autenticacao com JWT"
git push
14. C08: regra do jogo, persistência e consistência
Construir e testar a regra do jogo
A regra deve poder ser executada sem Express e sem banco. A função createDeck constrói 52 cartas; playRound retira duas cartas. Um parâmetro opcional permite substituir a fonte de aleatoriedade nos testes, sem alterar a regra.
Computador local · raiz de duelo-cartas
mkdir -p backend/src
touch backend/src/game.js
Comece pelos naipes e pela função que converte valores numéricos em rótulos.
backend/src/game.js · parte 1 de 3
import { randomInt } from 'node:crypto';
const suits = [
{ symbol: '♣', color: 'black' },
{ symbol: '♦', color: 'red' },
{ symbol: '♥', color: 'red' },
{ symbol: '♠', color: 'black' }
];
function labelFor(rank) {
return { 11: 'J', 12: 'Q', 13: 'K', 14: 'A' }[rank] ?? String(rank);
}
Array.from cria 13 valores por naipe e flatMap reúne as listas em um único baralho.
backend/src/game.js · parte 2 de 3
}
export function createDeck() {
// flatMap reúne as listas de 13 cartas dos quatro naipes.
return suits.flatMap((suit) =>
// Cria 13 posições; index vai de 0 até 12.
Array.from({ length: 13 }, (_, index) => {
const rank = index + 2;
return {
rank,
label: labelFor(rank),
suit: suit.symbol,
color: suit.color
};
})
);
}
randomInt(n) escolhe de zero até n-1. splice retira o item escolhido; assim, a segunda seleção não pode repetir a mesma carta.
backend/src/game.js · parte 3 de 3
}
export function playRound(random = randomInt) {
const deck = createDeck();
// splice retira a carta sorteada e impede duplicação na mesma rodada.
const playerCard = deck.splice(random(deck.length), 1)[0];
const machineCard = deck.splice(random(deck.length), 1)[0];
let result = 'draw';
if (playerCard.rank > machineCard.rank) result = 'win';
if (playerCard.rank < machineCard.rank) result = 'loss';
return { playerCard, machineCard, result };
}
Leituras consistentes e transações
Uma leitura eventualmente consistente pode observar por um curto período o valor anterior a uma escrita confirmada. Nas leituras da tabela base, ConsistentRead: true solicita consistência forte, útil para mostrar o resultado logo após jogar. Isso não cria uma fotografia única para várias consultas independentes: perfil e histórico lidos separadamente podem refletir instantes distintos se houver concorrência.
Uma transação agrupa operações que precisam confirmar juntas. Ao criar uma rodada, serão executados um Put do histórico e um Update dos contadores. Ao excluir, um Delete condicional e o decremento dos mesmos contadores. O uso de ADD evita o padrão vulnerável de ler, somar no JavaScript e sobrescrever um valor que outra requisição pode ter atualizado.

A mesma regra de atomicidade vale para criação e exclusão.
Criar o módulo de acesso aos dados
O módulo de repositório concentra as operações de banco. Funções auxiliares aparecem antes das funções que as invocam. Os atributos #games e #stat são aliases de nomes; os valores :delta e :userId são parâmetros das expressões, não concatenação de entrada em uma consulta.
Computador local · raiz de duelo-cartas
mkdir -p backend/src
touch backend/src/repository.js
Importe os comandos e defina o mapa de resultado para contador. statsFrom normaliza o formato público do placar.
backend/src/repository.js · parte 1 de 7
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { GetCommand, QueryCommand, TransactWriteCommand }
from '@aws-sdk/lib-dynamodb';
import { config } from './config.js';
import { documentClient } from './dynamo.js';
const counters = { win: 'wins', draw: 'draws', loss: 'losses' };
export function statsFrom(item = {}) {
return {
games: item.games ?? 0, wins: item.wins ?? 0,
draws: item.draws ?? 0, losses: item.losses ?? 0
};
}
As leituras usam a identidade recebida do backend. O histórico traz vinte itens por padrão e aceita um cursor para consultas posteriores.
backend/src/repository.js · parte 2 de 7
}
export async function readUser(email) {
const result = await documentClient.send(new GetCommand({
TableName: config.usersTable, Key: { email },
ConsistentRead: true // Evita devolver um placar anterior à escrita.
}));
return result.Item;
}
export async function listRounds(email, lastKey, limit = 20) {
return documentClient.send(new QueryCommand({
TableName: config.roundsTable,
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: { ':userId': email },
ScanIndexForward: false, // Sort key em ordem decrescente.
ConsistentRead: true,
Limit: limit,
ExclusiveStartKey: lastKey // undefined é omitido pelo SDK.
}));
}
A transação mantém o token durante as tentativas internas. Somente conflitos identificados são repetidos, com atraso crescente e jitter.
backend/src/repository.js · parte 3 de 7
}
async function transact(items, token = randomUUID()) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
return await documentClient.send(new TransactWriteCommand({
ClientRequestToken: token, // Reusa o token nas tentativas internas.
TransactItems: items
}));
} catch (error) {
const conflict = error.CancellationReasons?.some(
(reason) => reason.Code === 'TransactionConflict'
);
if (!conflict || attempt === 4) throw error;
// Backoff crescente com jitter reduz colisões entre requisições.
await delay(50 * 2 ** attempt + Math.random() * 50);
}
}
}
O construtor associa o resultado ao nome de contador e exige um usuário existente. ADD permite incrementar ou decrementar sem ler e sobrescrever o valor.
backend/src/repository.js · parte 4 de 7
}
function counterUpdate(email, result, amount) {
return {
Update: {
TableName: config.usersTable,
Key: { email },
ConditionExpression: 'attribute_exists(email)',
// ADD soma valores; amount negativo subtrai sem ler e regravar.
UpdateExpression: 'ADD #games :delta, #stat :delta',
ExpressionAttributeNames: {
'#games': 'games', '#stat': counters[result]
},
ExpressionAttributeValues: { ':delta': amount }
}
};
}
Data e UUID compõem a chave da rodada. Put condicional e atualização do placar entram na mesma transação; o item só é devolvido depois da confirmação.
backend/src/repository.js · parte 5 de 7
}
export async function saveRound(email, outcome) {
const id = randomUUID();
const createdAt = new Date().toISOString();
const round = {
userId: email,
roundId: `${createdAt}#${id}`, // Data ordena; UUID evita colisões.
createdAt,
...outcome // Copia playerCard, machineCard e result.
};
await transact([
{ Put: {
TableName: config.roundsTable, Item: round,
ConditionExpression: 'attribute_not_exists(roundId)'
} },
counterUpdate(email, round.result, 1)
], id);
return round;
}
A exclusão exige uma rodada existente e desconta seu resultado atomicamente. Uma remoção concorrente já concluída não pode descontar novamente.
backend/src/repository.js · parte 6 de 7
}
async function deleteRound(round) {
try {
await transact([
{ Delete: {
TableName: config.roundsTable,
Key: { userId: round.userId, roundId: round.roundId },
ConditionExpression: 'attribute_exists(roundId)'
} },
counterUpdate(round.userId, round.result, -1)
]);
} catch (error) {
// Outra exclusão pode ter removido o mesmo item: não desconta duas vezes.
if (error.CancellationReasons?.[0]?.Code ===
'ConditionalCheckFailed') return;
throw error;
}
}
A exclusão do histórico percorre páginas. O cursor é guardado antes de remover os itens, permitindo continuar a consulta após a chave já processada.
backend/src/repository.js · parte 7 de 7
}
export async function deleteHistory(email) {
let lastKey;
do {
const page = await listRounds(email, lastKey, 25);
// Guarda o cursor antes de remover os itens desta página.
lastKey = page.LastEvaluatedKey;
for (const round of page.Items ?? []) {
await deleteRound(round);
}
} while (lastKey); // Prossegue além do limite de uma página da Query.
}
ClientRequestToken evita repetir o efeito da mesma chamada transacional dentro da janela de idempotência do DynamoDB, de dez minutos, quando os mesmos parâmetros são reenviados. Ele não transforma dois POSTs independentes em uma única rodada: cada nova requisição gera outro UUID. Uma idempotência HTTP completa exigiria chave de requisição fornecida pelo cliente e armazenamento do resultado.
Registrar rotas protegidas
Computador local · raiz de duelo-cartas
mkdir -p backend/src/routes
touch backend/src/routes/game-routes.js
O middleware de autenticação é registrado antes das rotas. O perfil seleciona campos públicos e não devolve o hash.
backend/src/routes/game-routes.js · parte 1 de 2
import { Router } from 'express';
import { requireAuth } from '../auth.js';
import { playRound } from '../game.js';
import { readUser, listRounds, saveRound, deleteHistory, statsFrom }
from '../repository.js';
export const gameRouter = Router();
gameRouter.use(requireAuth); // Protege todas as rotas registradas abaixo.
gameRouter.get('/me', async (request, response) => {
const user = await readUser(request.user.email);
if (!user) {
return response.status(404).json({ message: 'Usuário inexistente.' });
}
return response.json({ user: {
email: user.email, name: user.name, stats: statsFrom(user)
} }); // Seleciona campos públicos; não envia passwordHash.
});
A criação usa o usuário extraído do token e ignora uma identidade enviada no corpo. O histórico também é consultado apenas para essa identidade.
backend/src/routes/game-routes.js · parte 2 de 2
});
gameRouter.get('/rounds', async (request, response) => {
const page = await listRounds(request.user.email);
return response.json({ rounds: page.Items ?? [] });
});
gameRouter.post('/rounds', async (request, response) => {
const email = request.user.email; // Identidade vem do JWT verificado.
const round = await saveRound(email, playRound());
const user = await readUser(email);
return response.status(201).json({ round, stats: statsFrom(user) });
});
gameRouter.delete('/rounds', async (request, response) => {
const email = request.user.email;
await deleteHistory(email);
const user = await readUser(email);
return response.json({ stats: statsFrom(user) });
});
Acrescente a importação do roteador de jogo em backend/src/app.js, imediatamente depois da importação de autenticação.
backend/src/app.js · importação do jogo
import { authRouter } from './routes/auth-routes.js';
import { gameRouter } from './routes/game-routes.js';
function corsOrigin(origin, callback) {
Registre o roteador de jogo depois do roteador de autenticação e antes do 404. O caminho /api será combinado com /me e /rounds.
backend/src/app.js · registro do jogo
app.use('/api/auth', authRouter);
app.use('/api', gameRouter);
app.use((_request, response) => {
Verificar a API por HTTP
Reinicie a API. Em outro terminal, guarde a resposta do login em uma variável. O segundo comando lê JSON pela entrada padrão e retorna somente um token válido; ele falha se o login não tiver produzido um token.
Computador local · outro terminal · obter o token
LOGIN=$(curl --fail --silent --show-error \
-X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"aluno@exemplo.com","password":"SenhaLocal123!"}')
TOKEN=$(printf '%s' "$LOGIN" | node -e '
let text = "";
process.stdin.on("data", chunk => text += chunk);
process.stdin.on("end", () => {
const data = JSON.parse(text);
if (!data.token) process.exit(1);
process.stdout.write(data.token);
});')
$(...) captura a saída de um comando. Authorization transporta o esquema Bearer e o token. Crie uma rodada, consulte perfil e histórico e, por fim, exclua. O método HTTP define a operação sobre o recurso.
Computador local · outro terminal · operações autenticadas
curl --fail -X POST http://localhost:3000/api/rounds \
-H "Authorization: Bearer $TOKEN"
curl --fail http://localhost:3000/api/me \
-H "Authorization: Bearer $TOKEN"
curl --fail http://localhost:3000/api/rounds \
-H "Authorization: Bearer $TOKEN"
curl --fail -X DELETE http://localhost:3000/api/rounds \
-H "Authorization: Bearer $TOKEN"
| Operação | Resultado esperado |
|---|---|
POST /api/rounds |
201; uma rodada e placar atualizado |
GET /api/rounds |
200; até vinte rodadas recentes |
GET /api/me |
200; identificação pública e contadores |
DELETE /api/rounds |
200; contadores após a exclusão |
| Rotas protegidas sem token | 401; nenhuma alteração de dados |
Repita perfil e histórico depois da exclusão: sem outras chamadas concorrentes, a lista deve estar vazia e os quatro contadores, zerados. Não edite os itens do banco manualmente durante essa verificação.
Computador local · terminal na raiz · versão C08
git add backend/src
git commit -m "game: persiste rodadas com transacoes"
git push
15. C09: integrar o front à API
Conferir as origens antes de conectar as páginas
A API já tem o middleware de CORS. Agora o navegador fará chamadas reais a ela, por isso a origem configurada precisa corresponder exatamente ao endereço usado para abrir o front. No navegador, a aba Network permite conferir Origin, OPTIONS e os cabeçalhos de resposta.
backend/.env já contém CORS_ORIGINS=http://localhost:8080. Abra o front exatamente por essa origem; 127.0.0.1:8080 é outra origem para CORS. O módulo da API usará http://localhost:3000. Reinicie a API após alterar variáveis de ambiente.
Computador local · raiz de duelo-cartas
mkdir -p frontend/js
touch frontend/js/config.js
Crie primeiro a configuração pública. Object.freeze impede alterações diretas nas propriedades; ele não torna o conteúdo secreto.
frontend/js/config.js
window.APP_CONFIG = Object.freeze({ API_URL: 'http://localhost:3000' });
HTTP e armazenamento do token
fetch inicia a requisição e retorna uma Promise. await aguarda sua conclusão sem bloquear a execução de todo o processo. Erros HTTP como 401 não rejeitam automaticamente a Promise do fetch; é necessário examinar response.ok ou o status. O corpo JSON também é lido de forma assíncrona.
sessionStorage mantém o token por origem e contexto de navegação da aba. Ele não é um cofre: scripts da mesma origem podem ler o conteúdo. Uma duplicação ou abertura de aba pode copiar o estado inicial em certos fluxos do navegador. O token continua sujeito à expiração validada na API. O código usará textContent ao exibir dados, evitando interpretá-los como HTML.
Computador local · raiz de duelo-cartas
mkdir -p frontend/js
touch frontend/js/api.js
Normalize a URL e defina as funções de acesso ao token antes da função HTTP.
frontend/js/api.js · parte 1 de 3
// ?. trata configuração ausente; a expressão remove a barra final.
const baseUrl = window.APP_CONFIG?.API_URL?.replace(/\/$/, '');
// sessionStorage limita o token à guia atual; não é uma solução para HTTPS ausente.
const tokenKey = 'duelo-cartas-token';
export function getToken() {
return sessionStorage.getItem(tokenKey);
}
export function setToken(token) {
sessionStorage.setItem(tokenKey, token);
}
export function clearToken() {
sessionStorage.removeItem(tokenKey);
}
A função request compõe os cabeçalhos: JSON quando existe corpo, Bearer quando existe token e Accept para a resposta esperada.
frontend/js/api.js · parte 2 de 3
}
export async function request(path, options = {}) {
if (!baseUrl) {
throw new Error('A URL da API não foi configurada.');
}
// Headers permite compor cabeçalhos sem perder os recebidos em options.
const headers = new Headers(options.headers);
headers.set('Accept', 'application/json');
if (options.body) {
headers.set('Content-Type', 'application/json');
}
const token = getToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
Envie a requisição, leia a resposta e trate erros HTTP. Sessão inválida em rota protegida remove o token; erro de login permanece na tela de entrada.
frontend/js/api.js · parte 3 de 3
}
// Toda chamada passa pelo mesmo tratamento de cabeçalhos e de erros.
const response = await fetch(`${baseUrl}${path}`, { ...options, headers });
const payload = await response.json().catch(() => ({}));
if (response.status === 401 && path !== '/api/auth/login') {
clearToken();
window.location.replace('index.html');
throw new Error('Sua sessão expirou. Entre novamente.');
}
if (!response.ok) {
throw new Error(payload.message ?? 'Não foi possível concluir a operação.');
}
return payload;
}
Controlador do formulário
O evento submit representa o envio do formulário. preventDefault impede o envio HTML tradicional, permitindo executar o login pelo fetch. checkValidity consulta as restrições dos campos. O bloco finally restaura o botão tanto em sucesso quanto em erro.
Computador local · raiz de duelo-cartas
mkdir -p frontend/js
touch frontend/js/login.js
Importe o cliente HTTP, selecione os elementos existentes e inicie o tratamento de submit.
frontend/js/login.js · parte 1 de 3
import { getToken, request, setToken } from './api.js';
if (getToken()) {
window.location.replace('dashboard.html');
}
const form = document.querySelector('#login-form');
const alertBox = document.querySelector('#login-alert');
const button = document.querySelector('#login-button');
form.addEventListener('submit', async (event) => {
event.preventDefault();
alertBox.classList.add('d-none');
Consulte a validação nativa, bloqueie o botão e envie os campos como JSON. namedItem encontra os controles pelo atributo name.
frontend/js/login.js · parte 2 de 3
alertBox.classList.add('d-none');
if (!form.checkValidity()) {
form.classList.add('was-validated');
return;
}
button.disabled = true;
button.textContent = 'Entrando...';
try {
// A senha segue apenas no corpo desta requisição e nunca é persistida no front.
const payload = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({
email: form.elements.namedItem('email').value,
password: form.elements.namedItem('password').value
})
});
Grave o token apenas após sucesso; erros são exibidos como texto. finally restaura o botão em ambos os caminhos.
frontend/js/login.js · parte 3 de 3
});
setToken(payload.token);
window.location.replace('dashboard.html');
} catch (error) {
alertBox.textContent = error.message;
alertBox.classList.remove('d-none');
} finally {
button.disabled = false;
button.textContent = 'Entrar';
}
});
Renderização e eventos do painel
O controlador separa obtenção de dados, renderização e tratamento de eventos. querySelector retorna o primeiro elemento que corresponde ao seletor. classList.toggle com nome e condição mantém ou remove uma classe conforme a condição. Promise.all permite aguardar duas consultas independentes em paralelo.
Computador local · raiz de duelo-cartas
mkdir -p frontend/js
touch frontend/js/dashboard.js
Importe o cliente e capture as referências dos elementos que já existem no painel.
frontend/js/dashboard.js · parte 1 de 9
import { clearToken, getToken, request } from './api.js';
const elements = {
alert: document.querySelector('#dashboard-alert'),
playerName: document.querySelector('#player-name'),
games: document.querySelector('#games-stat'),
wins: document.querySelector('#wins-stat'),
draws: document.querySelector('#draws-stat'),
losses: document.querySelector('#losses-stat'),
playerCard: document.querySelector('#player-card'),
machineCard: document.querySelector('#machine-card'),
result: document.querySelector('#round-result'),
playButton: document.querySelector('#play-button'),
resetButton: document.querySelector('#reset-button'),
logoutButton: document.querySelector('#logout-button'),
historyBody: document.querySelector('#history-body'),
emptyHistory: document.querySelector('#empty-history')
};
Defina os textos de resultado e as funções de exibição e limpeza de erro antes dos eventos.
frontend/js/dashboard.js · parte 2 de 9
};
const resultLabels = {
win: 'Você venceu!',
draw: 'Empate.',
loss: 'A máquina venceu.'
};
function showError(error) {
elements.alert.textContent = error.message;
elements.alert.classList.remove('d-none');
}
function clearError() {
elements.alert.classList.add('d-none');
}
Renderize placar e cartas com texto e classes. O início de renderHistory limpa as linhas anteriores e ajusta a mensagem de vazio.
frontend/js/dashboard.js · parte 3 de 9
}
function renderStats(stats) {
elements.games.textContent = stats.games ?? 0;
elements.wins.textContent = stats.wins ?? 0;
elements.draws.textContent = stats.draws ?? 0;
elements.losses.textContent = stats.losses ?? 0;
}
function renderCard(container, card) {
container.querySelector('.card-rank').textContent = card.label;
container.querySelector('.card-suit').textContent = card.suit;
container.classList.toggle('is-red', card.color === 'red');
container.classList.toggle('is-black', card.color === 'black');
}
function renderHistory(rounds) {
// createElement/textContent evitam interpretar dados da API como HTML.
elements.historyBody.replaceChildren();
elements.emptyHistory.classList.toggle('d-none', rounds.length > 0);
Para cada rodada, crie uma linha e suas células. textContent impede que os valores retornados pela API sejam interpretados como HTML.
frontend/js/dashboard.js · parte 4 de 9
elements.emptyHistory.classList.toggle('d-none', rounds.length > 0);
for (const round of rounds) {
const row = document.createElement('tr');
const values = [
new Date(round.createdAt).toLocaleString('pt-BR'),
`${round.playerCard.label}${round.playerCard.suit}`,
`${round.machineCard.label}${round.machineCard.suit}`,
resultLabels[round.result]
];
for (const value of values) {
const cell = document.createElement('td');
cell.textContent = value;
row.appendChild(cell);
}
elements.historyBody.appendChild(row);
}
}
loadDashboard consulta perfil e histórico em paralelo e entrega os resultados às funções de renderização já definidas.
frontend/js/dashboard.js · parte 5 de 9
}
async function loadDashboard() {
clearError();
try {
// Perfil e histórico são independentes e podem ser buscados em paralelo.
const [profile, history] = await Promise.all([
request('/api/me'),
request('/api/rounds')
]);
elements.playerName.textContent = profile.user.name;
renderStats(profile.user.stats);
renderHistory(history.rounds);
} catch (error) {
showError(error);
}
}
setBusy bloqueia jogar e excluir na mesma aba. O evento de jogar pede uma rodada à API e apresenta as cartas e o placar recebidos.
frontend/js/dashboard.js · parte 6 de 9
}
function setBusy(busy) {
elements.playButton.disabled = busy;
elements.resetButton.disabled = busy;
}
elements.playButton.addEventListener('click', async () => {
clearError();
setBusy(true);
elements.playButton.textContent = 'Sorteando...';
try {
const payload = await request('/api/rounds', { method: 'POST' });
renderCard(elements.playerCard, payload.round.playerCard);
renderCard(elements.machineCard, payload.round.machineCard);
elements.result.textContent = resultLabels[payload.round.result];
renderStats(payload.stats);
Conclua o evento de jogar relendo o histórico e liberando os controles. Depois, comece o evento de exclusão solicitando confirmação.
frontend/js/dashboard.js · parte 7 de 9
renderStats(payload.stats);
const history = await request('/api/rounds');
renderHistory(history.rounds);
} catch (error) {
showError(error);
} finally {
setBusy(false);
elements.playButton.textContent = 'Jogar uma rodada';
}
});
elements.resetButton.addEventListener('click', async () => {
const confirmed = window.confirm('Excluir todo o histórico e zerar o placar?');
if (!confirmed) return;
A exclusão bloqueia os controles, chama DELETE e relê o histórico. Os valores visuais das cartas são limpos; finally libera os botões mesmo em erro.
frontend/js/dashboard.js · parte 8 de 9
if (!confirmed) return;
clearError();
setBusy(true);
try {
const payload = await request('/api/rounds', { method: 'DELETE' });
renderStats(payload.stats);
const history = await request('/api/rounds');
renderHistory(history.rounds);
for (const container of [elements.playerCard, elements.machineCard]) {
container.querySelector('.card-rank').textContent = '?';
container.classList.remove('is-red', 'is-black');
}
elements.result.textContent = 'Histórico atualizado. Vamos recomeçar?';
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
});
Sair remove o token. Ao abrir o painel, carregue os dados somente se existe token; a autorização efetiva continua sob responsabilidade da API.
frontend/js/dashboard.js · parte 9 de 9
});
elements.logoutButton.addEventListener('click', () => {
clearToken();
window.location.replace('index.html');
});
if (getToken()) {
loadDashboard();
} else {
window.location.replace('index.html');
}
Ativar os módulos somente depois de criá-los
Em frontend/index.html, o botão login-button já tem controlador definido. Remova apenas o atributo disabled; mantenha seu texto e as demais propriedades.
frontend/index.html
<button id="login-button" class="btn btn-primary btn-lg w-100"
type="submit">
Entrar
</button>
No final de frontend/index.html, dentro de body e depois de todo o conteúdo visual, acrescente os scripts abaixo. A configuração é carregada antes do módulo que a consulta.
frontend/index.html
<body class="login-page">
<!-- Mantenha aqui todo o conteúdo visual já existente. -->
<script src="js/config.js"></script>
<!-- Módulos aceitam import e executam após a análise do HTML. -->
<script type="module" src="js/login.js"></script>
</body>
Em frontend/dashboard.html, os botões logout-button, play-button e reset-button já têm controladores definidos. Remova apenas o atributo disabled de cada um; mantenha seus textos e as demais propriedades.
frontend/dashboard.html
<button id="logout-button" class="btn btn-outline-light btn-sm"
type="button">Sair</button>
<button id="play-button" class="btn btn-primary btn-lg"
type="button">Jogar uma rodada</button>
<button id="reset-button" class="btn btn-outline-danger btn-sm"
type="button">Excluir histórico</button>
No final de frontend/dashboard.html, dentro de body e depois de todo o conteúdo visual, acrescente os scripts abaixo.
frontend/dashboard.html
<body class="dashboard-page">
<!-- Mantenha aqui todo o conteúdo visual já existente. -->
<script src="js/config.js"></script>
<!-- Módulos aceitam import e executam após a análise do HTML. -->
<script type="module" src="js/dashboard.js"></script>
</body>
Com DynamoDB e API ativos, inicie o servidor estático na raiz se ele estiver parado. Abra http://localhost:8080; entre com aluno@exemplo.com e a senha local definida. Jogue, recarregue e confirme persistência; exclua e confirme o ajuste do placar. O botão Sair deve levar ao login.
Computador local · raiz de duelo-cartas
npx --yes http-server@14.1.1 frontend -a 127.0.0.1 -p 8080 -c-1
Computador local · terminal na raiz · versão C09
git add frontend
git commit -m "front: integra autenticacao e jogo"
git push
16. C10: lint e testes unitários no pipeline
Lint e testes verificam aspectos diferentes
Lint identifica problemas estáticos, como nomes inexistentes e variáveis não usadas. Um teste unitário executa uma unidade pequena com entradas conhecidas e compara o resultado esperado. Ambos são úteis: sintaxe válida não demonstra regra correta, e alguns testes aprovados não eliminam todo erro possível.
Crie um manifesto separado na raiz para as ferramentas que inspecionam front e back. O manifesto da API, dentro de backend, continua independente. Instale o ESLint e as descrições de ambientes antes de importar suas configurações.
Computador local · raiz de duelo-cartas
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm install --save-dev --save-exact eslint@9.39.5 \
@eslint/js@9.39.5 globals@16.5.0
npm pkg set 'scripts.lint=eslint frontend/js backend/src backend/scripts backend/test backend/server.js'
mkdir -p backend/test/unit scripts
Computador local · raiz de duelo-cartas
touch eslint.config.js
A configuração plana é uma lista de objetos. Cada objeto ajusta o conjunto de arquivos e o ambiente a que suas regras se aplicam. No navegador existem window e document; no Node, process e Buffer. O prefixo de parâmetro _ sinaliza argumentos exigidos pela API que o código não utiliza.
eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
export default [
{
ignores: ['**/node_modules/**', 'frontend/js/config.js']
},
js.configs.recommended, // Regras de erros comuns de JavaScript.
{
files: ['frontend/js/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.browser // window, document e sessionStorage.
}
},
{
files: ['backend/**/*.js', 'scripts/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.node // process, Buffer e outros globais do Node.
},
rules: {
'no-console': 'off',
// _ indica um parâmetro exigido pela API, mas não usado aqui.
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
}
}
];
Testar o baralho e a normalização
Computador local · raiz de duelo-cartas
mkdir -p backend/test/unit
touch backend/test/unit/game.test.js
A função de sequência injeta índices conhecidos. Ela é declarada antes dos testes e não depende da fonte real de aleatoriedade. Os casos verificam cartas únicas, vitória e empate com valores determinísticos.
backend/test/unit/game.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDeck, playRound } from '../../src/game.js';
function sequence(values) {
return (maximum) => Math.min(values.shift(), maximum - 1);
}
test('o baralho possui 52 cartas únicas', () => {
const deck = createDeck();
const identities = new Set(deck.map((card) => `${card.rank}${card.suit}`));
assert.equal(deck.length, 52);
assert.equal(identities.size, 52);
});
test('a carta de maior valor vence', () => {
const round = playRound(sequence([51, 0]));
assert.equal(round.playerCard.label, 'A');
assert.equal(round.machineCard.label, '2');
assert.equal(round.result, 'win');
});
test('cartas de mesmo valor empatam', () => {
const round = playRound(sequence([0, 12]));
assert.equal(round.playerCard.rank, round.machineCard.rank);
assert.equal(round.result, 'draw');
});
Computador local · raiz de duelo-cartas
mkdir -p backend/test/unit
touch backend/test/unit/auth.test.js
A normalização deve retirar espaços externos, converter para minúsculas e tratar ausência de valor.
backend/test/unit/auth.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeEmail } from '../../src/auth.js';
test('normaliza espaços e letras maiúsculas do e-mail', () => {
assert.equal(normalizeEmail(' Aluno@Exemplo.COM '), 'aluno@exemplo.com');
});
test('converte valor ausente em texto vazio', () => {
assert.equal(normalizeEmail(), '');
});
Cadastre o script na API e execute as duas verificações. O executor node --test já acompanha o runtime, sem outra biblioteca de testes.
Computador local · raiz de duelo-cartas
cd backend
npm pkg set 'scripts.test:unit=node --test test/unit/*.test.js'
npm run test:unit
cd ..
npm run lint
O resultado esperado é cinco testes aprovados e nenhum erro de lint. As assertivas comparam resultados; falha de uma assertiva encerra o processo com status de erro, fazendo o job falhar.
Evoluir a esteira sem duplicar o arquivo
Em .gitlab-ci.yml, acrescente test entre validate e deploy e o bloco default logo após a lista. Jobs de teste poderão ser interrompidos em fluxos configurados para cancelar pipelines redundantes; o deploy já define interruptible: false.
.gitlab-ci.yml · início
stages:
- validate
- test
- deploy
default:
interruptible: true
front_smoke:
Acrescente os dois jobs abaixo antes de deploy_frontend. Eles ficam no nível superior do YAML, sem indentação extra. Jobs do mesmo stage podem executar em paralelo; o deploy continua aguardando os stages anteriores. O lint instala as ferramentas da raiz com npm ci e executa o script do manifesto; os testes entram na pasta backend e instalam a árvore de dependências própria da API.
.gitlab-ci.yml · novos jobs
code_quality:
stage: validate
image: node:22-alpine
before_script:
- npm ci
script:
- npm run lint
unit_tests:
stage: test
image: node:22-alpine
before_script:
- cd backend
- npm ci
script:
- npm run test:unit
No job front_smoke, mantenha a imagem e substitua somente a lista script pelo trecho abaixo. As verificações agora acompanham os arquivos da aplicação integrada.
.gitlab-ci.yml · script de front_smoke
image: alpine:3.22
script:
- test -s frontend/index.html
- test -s frontend/dashboard.html
- test -s frontend/css/styles.css
- test -s frontend/js/api.js
- grep -q 'type="module"' frontend/index.html
Computador local · terminal na raiz · versão C10
git add package.json package-lock.json eslint.config.js backend .gitlab-ci.yml
git commit -m "test: adiciona lint e testes unitarios"
git push
17. C11: testes de integração com DynamoDB no CI
Teste de integração e isolamento de dados
O teste de integração percorre Express, autenticação, repositório e o protocolo do DynamoDB. Supertest permite enviar requisições para a aplicação Express durante a execução do teste. O banco é real para o ambiente local; não é um objeto mockado. Os nomes das tabelas recebem prefixo test-, separando os dados de teste dos dados usados ao explorar a aplicação.
Computador local · raiz de duelo-cartas
cd backend
npm install --save-dev --save-exact supertest@7.2.2
npm pkg set 'scripts.test:integration=node --test --test-concurrency=1 test/integration/*.test.js'
mkdir -p test/integration
cd ..
Os testes usam um usuário exclusivo por execução. before cria a massa e obtém um token; after limpa somente os itens desse usuário. A execução serial mantém a ordem dos casos que compartilham esse cenário. A guarda inicial impede rodar sem endpoint local ou em tabelas sem o prefixo de teste.
Computador local · raiz de duelo-cartas
mkdir -p backend/test/integration
touch backend/test/integration/api.test.js
Importe o executor, as assertivas, a aplicação e os clientes já implementados. A guarda verifica o destino de testes. Um UUID identifica o usuário temporário sem colisão com execuções anteriores.
backend/test/integration/api.test.js · parte 1 de 5
import test, { before, after } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import bcrypt from 'bcryptjs';
import request from 'supertest';
import { PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { createApp } from '../../src/app.js';
import { config } from '../../src/config.js';
import { documentClient } from '../../src/dynamo.js';
import { deleteHistory } from '../../src/repository.js';
if (!config.dynamodbEndpoint ||
!config.usersTable.startsWith('test-') ||
!config.roundsTable.startsWith('test-')) {
throw new Error('Use endpoint local e tabelas com prefixo test-.');
}
const app = createApp();
const credentials = {
email: `teste-${randomUUID()}@exemplo.com`,
password: 'SenhaDeTeste123!'
};
let authorization;
before cria a massa descartável e obtém um token. O custo reduzido do hash vale somente para esse usuário de teste. after limpa os itens criados.
backend/test/integration/api.test.js · parte 2 de 5
let authorization;
before(async () => {
await documentClient.send(new PutCommand({
TableName: config.usersTable,
Item: {
email: credentials.email, name: 'Pessoa de Teste',
// Custo menor somente para acelerar a massa descartável de testes.
passwordHash: await bcrypt.hash(credentials.password, 4),
games: 0, wins: 0, draws: 0, losses: 0
}
}));
const login = await request(app).post('/api/auth/login')
.send(credentials).expect(200);
authorization = `Bearer ${login.body.token}`;
});
after(async () => {
await deleteHistory(credentials.email);
await documentClient.send(new DeleteCommand({
TableName: config.usersTable, Key: { email: credentials.email }
}));
documentClient.destroy(); // Libera as conexões usadas pela suíte.
});
Os primeiros testes verificam saúde e recusa de tokens ausentes ou inválidos, senha incorreta, tipo inválido de e-mail e JSON malformado. Entradas inválidas devem retornar erro do cliente.
backend/test/integration/api.test.js · parte 3 de 5
});
test('health responde sem autenticação', async () => {
const result = await request(app).get('/health').expect(200);
assert.equal(result.body.status, 'ok');
});
test('recusa token ausente ou adulterado', async () => {
await request(app).get('/api/rounds').expect(401);
await request(app).post('/api/rounds')
.set('Authorization', 'Bearer token-invalido').expect(401);
});
test('recusa senha incorreta e entrada inválida', async () => {
await request(app).post('/api/auth/login')
.send({ ...credentials, password: 'SenhaErrada123!' }).expect(401);
await request(app).post('/api/auth/login')
.send({ email: ['invalido'], password: 'SenhaDeTeste123!' }).expect(400);
});
test('JSON inválido é erro 400, não erro interno', async () => {
await request(app).post('/api/auth/login')
.set('Content-Type', 'application/json').send('{').expect(400);
});
Confira o preflight, a recusa de origem não autorizada e a ausência de passwordHash na resposta pública do perfil.
backend/test/integration/api.test.js · parte 4 de 5
});
test('CORS autoriza a origem local e recusa outra origem', async () => {
const result = await request(app).options('/api/rounds')
.set('Origin', 'http://localhost:8080')
.set('Access-Control-Request-Method', 'POST').expect(204);
assert.equal(result.headers['access-control-allow-origin'],
'http://localhost:8080');
await request(app).get('/health')
.set('Origin', 'http://origem-invalida.example').expect(403);
});
test('perfil não revela o hash da senha', async () => {
const result = await request(app).get('/api/me')
.set('Authorization', authorization).expect(200);
assert.equal(result.body.user.email, credentials.email);
assert.equal('passwordHash' in result.body.user, false);
});
A criação deve usar a identidade do token mesmo se outro userId for enviado no corpo. Confira também cartas distintas e leitura da rodada persistida. Depois, crie um histórico maior que uma página. Após excluir, confira todos os contadores, histórico vazio e sucesso de uma repetição da exclusão.
backend/test/integration/api.test.js · parte 5 de 5
});
test('persiste rodada, atualiza placar e isola a identidade', async () => {
const created = await request(app).post('/api/rounds')
.set('Authorization', authorization)
.send({ userId: 'outra-pessoa@exemplo.com' }).expect(201);
assert.equal(created.body.round.userId, credentials.email);
assert.equal(created.body.stats.games, 1);
const { playerCard, machineCard } = created.body.round;
assert.notEqual(`${playerCard.rank}${playerCard.suit}`,
`${machineCard.rank}${machineCard.suit}`);
const history = await request(app).get('/api/rounds')
.set('Authorization', authorization).expect(200);
assert.equal(history.body.rounds.length, 1);
});
test('exclui além de uma página sem desalinhar contadores', async () => {
// 27 novas rodadas mais a anterior ultrapassam a página de 25 itens.
for (let index = 0; index < 27; index += 1) {
await request(app).post('/api/rounds')
.set('Authorization', authorization).expect(201);
}
const history = await request(app).get('/api/rounds')
.set('Authorization', authorization).expect(200);
assert.equal(history.body.rounds.length, 20);
await request(app).delete('/api/rounds')
.set('Authorization', authorization).expect(200);
const profile = await request(app).get('/api/me')
.set('Authorization', authorization).expect(200);
assert.deepEqual(profile.body.user.stats,
{ games: 0, wins: 0, draws: 0, losses: 0 });
const empty = await request(app).get('/api/rounds')
.set('Authorization', authorization).expect(200);
assert.deepEqual(empty.body.rounds, []);
await request(app).delete('/api/rounds')
.set('Authorization', authorization).expect(200);
});
Executar contra o banco local
As variáveis são aplicadas em um subshell, delimitado por parênteses. Ao terminar, elas não alteram o terminal usado para iniciar a API de desenvolvimento. O segredo JWT abaixo foi escolhido apenas para testes descartáveis e não vem da AWS. Ele tem comprimento suficiente para passar pela validação e pode ser conhecido porque não protege dados reais.
Computador local · raiz de duelo-cartas
(
cd backend
export DYNAMODB_ENDPOINT=http://127.0.0.1:8000
export USERS_TABLE=test-usuarios ROUNDS_TABLE=test-rodadas
export JWT_SECRET=segredo-apenas-do-teste-de-integracao
export CORS_ORIGINS=http://localhost:8080
npm run db:create
npm run test:integration
)
Oito testes de integração devem passar. Eles incluem entradas inválidas, CORS, dados públicos, identidade derivada do token e exclusão com mais de 25 itens. O ciclo de vida de teste usa custo bcrypt 4 apenas na massa descartável; o script de criação de usuário continua usando 12.
Adicionar o contêiner de serviço ao job

No runner, localhost é o próprio contêiner do job; o banco é alcançado pelo alias dynamodb.
Acrescente o bloco global variables logo após stages, antes de default. As regiões e nomes de tabelas são públicos. AWS_EC2_METADATA_DISABLED impede o SDK de procurar um perfil de instância no runner; no Beanstalk essa variável não será configurada.
.gitlab-ci.yml · bloco global
variables:
AWS_REGION: 'us-east-1'
AWS_DEFAULT_REGION: 'us-east-1'
USERS_TABLE: 'duelo-usuarios'
ROUNDS_TABLE: 'duelo-rodadas'
AWS_EC2_METADATA_DISABLED: 'true'
AWS_PAGER: '' # Desativa paginação interativa da CLI.
Acrescente integration_tests depois de unit_tests. Ele pertence ao mesmo stage, mas possui seu próprio banco. Não é preciso Docker-in-Docker: o runner inicia o serviço declarado em services. A imagem do serviço é a mesma usada no Compose. Aqui não há volume: o banco é descartado no fim do job.
.gitlab-ci.yml · integration_tests
integration_tests:
stage: test
image: node:22-alpine
services:
- name: amazon/dynamodb-local:3.0.0
alias: dynamodb # DNS interno do banco, acessível apenas ao job.
entrypoint: ['java']
command: ['-jar', 'DynamoDBLocal.jar', '-sharedDb', '-inMemory']
variables:
DYNAMODB_ENDPOINT: 'http://dynamodb:8000'
USERS_TABLE: 'test-usuarios'
ROUNDS_TABLE: 'test-rodadas'
JWT_SECRET: 'segredo-apenas-do-teste-de-integracao'
CORS_ORIGINS: 'http://localhost:8080'
before_script:
- cd backend
- npm ci
script:
- npm run db:create # O script aguarda a prontidão do banco.
- npm run test:integration
Computador local · terminal na raiz · versão C11
git add backend .gitlab-ci.yml
git commit -m "test: valida integracao com DynamoDB"
git push
18. Infraestrutura AWS para a aplicação pública
IAM e responsabilidades no Beanstalk
IAM define quem pode executar quais ações sobre quais recursos. Um role fornece permissões que podem ser assumidas por serviços ou identidades; um instance profile associa um role à instância EC2. O SDK pode obter credenciais temporárias dessa instância automaticamente, sem access key no código.
Elastic Beanstalk é uma plataforma que provisiona e coordena recursos para executar a aplicação. Na plataforma Node.js, o proxy Nginx recebe HTTP e encaminha para o processo da API. O deploy enviará um ZIP de código; não construiremos uma imagem Docker para a API. Docker é usado no desenvolvimento do banco e nos ambientes dos jobs.

O processo web executa dentro da EC2 gerenciada pela plataforma.
O service role permite ao Beanstalk administrar o ambiente. O instance profile permite à aplicação acessar os serviços autorizados. No Learner Lab, selecione as identidades preexistentes previstas nas instruções: LabRole e LabInstanceProfile. Confira esses nomes na conta antes de criar o ambiente; não tente contornar a política criando permissões que o laboratório não permite.
Criar as tabelas no Console
Inicie uma sessão válida e confirme us-east-1. No Console AWS, em DynamoDB > Tables > Create table, crie as tabelas abaixo. Para cada uma, escolha Customize settings > Read/write capacity > On-demand, mantenha as demais opções apropriadas ao laboratório e aguarde status Active.
| Tabela | Partition key | Sort key |
|---|---|---|
duelo-usuarios |
email (String) |
Não possui |
duelo-rodadas |
userId (String) |
roundId (String) |
Não declare atributos de dados como name, passwordHash ou playerCard na criação: a definição antecipada é necessária apenas para as chaves e índices. O esquema da chave primária não é editável depois. Se uma tabela vazia foi criada com chave errada, exclua somente essa tabela e recrie-a com o esquema correto.
Criar o segredo da implantação
Gere um novo segredo, diferente daquele do arquivo local. O comando produz aleatoriedade, não uma senha fácil de memorizar. Copie apenas a saída.
Computador local · raiz de duelo-cartas
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
- Abra Secrets Manager > Store a new secret > Other type of secret.
- Use o modo de pares chave/valor: chave
JWT_SECRET, valor igual à saída recém-gerada. - Mantenha a chave de criptografia padrão do serviço, se permitida no laboratório.
- Use o nome
duelo-cartas/jwte deixe rotação automática desativada neste ambiente. - Revise e armazene. A aplicação lerá o JSON com essa chave; não troque por um texto sem estrutura.
JWT_SECRET_ID receberá o nome duelo-cartas/jwt. O valor secreto não vai para o GitLab, o repositório ou o front. A instância precisa de permissão secretsmanager:GetSecretValue; com uma chave KMS própria, também haveria requisitos de descriptografia correspondentes.
Criar o bucket privado de pacotes
No Console AWS, em S3 > Create bucket, crie um segundo bucket de uso geral, em us-east-1, por exemplo duelo-releases-grupo99-projeto01. Mantenha ACLs desabilitadas e todas as opções de bloqueio de acesso público ativadas. Não habilite website hosting. O bucket guarda os ZIPs de versões, não arquivos para o navegador.
Criar aplicação e ambiente no Beanstalk
- No Console AWS, em Elastic Beanstalk > Create application, informe
duelo-cartas-api. - Escolha Web server environment, nome
duelo-cartas-api-deve um domínio disponível. - Escolha plataforma gerenciada Node.js, branch Node.js 22 running on 64bit Amazon Linux 2023, com a versão de plataforma mantida mais recente disponível dessa branch.
- Selecione Sample application. Ela permite criar o ambiente antes da primeira implantação do código.
- Use a predefinição Single instance. Em acesso ao serviço, selecione
LabRolecomo service role eLabInstanceProfilecomo EC2 instance profile. - Se um par de chaves for exigido, selecione o existente
vockey; a implantação não utilizará SSH. - Use a VPC padrão disponível. Selecione subnet pública com rota para Internet Gateway e habilite endereço IPv4 público quando a tela apresentar a opção.
- Escolha uma única instância x86_64
t3.micro, se admitida pela conta; não associe RDS. Mantenha o proxy Nginx.
A VPC isola uma rede na AWS; uma subnet é uma faixa de endereços dentro dela. Uma subnet é pública quando sua tabela de rotas permite alcançar um Internet Gateway. O IP público permite tráfego com a internet; o security group filtra tráfego da instância. Para essa API HTTP, o ambiente precisa receber TCP 80 e alcançar os endpoints dos serviços e os repositórios de pacotes. Não abra 3000 ou 8000 ao público.
Propriedades da aplicação
No Console AWS, abra Elastic Beanstalk > Environments > duelo-cartas-api-dev. Dentro desse ambiente, em Configuration > Updates, monitoring and logging > Environment properties, ou na etapa equivalente da criação, configure os valores abaixo. O caminho visual pode mudar; procure as propriedades de ambiente do processo.
| Propriedade | Valor |
|---|---|
AWS_REGION |
us-east-1 |
USERS_TABLE |
duelo-usuarios |
ROUNDS_TABLE |
duelo-rodadas |
JWT_SECRET_ID |
duelo-cartas/jwt |
CORS_ORIGINS |
Website endpoint HTTP do S3, sem barra final |
NODE_ENV |
production |
Não defina DYNAMODB_ENDPOINT, JWT_SECRET, access key, secret key ou session token no Beanstalk. A ausência de endpoint seleciona o DynamoDB gerenciado; a ausência de JWT_SECRET faz o resolvedor buscar o Secrets Manager. Deixe a plataforma fornecer PORT. Salve e aguarde o ambiente ficar Ready. A URL deve exibir a aplicação de exemplo; /health do projeto ainda não existe ali.
No Console AWS, abra EC2 > Instances, selecione a instância criada pelo Beanstalk e, na aba Security, abra o security group associado. Confira a entrada HTTP na porta 80 e a saída necessária. Se o ambiente não iniciar, abra Events dentro do ambiente Beanstalk e encontre o primeiro erro. Confirme a disponibilidade de subnet pública, perfil, role e tipo de instância antes de repetir a criação.
Variáveis finais no GitLab
No GitLab.com, abra o projeto duelo-cartas e cadastre em Settings > CI/CD > Variables os nomes abaixo, com escopo * e proteção habilitada. URLs e nomes não são segredos; as três credenciais AWS permanecem mascaradas e protegidas. Se uma sessão nova foi iniciada, atualize as três juntas.
| Variável | Valor |
|---|---|
ARTIFACTS_BUCKET |
Nome do bucket privado |
EB_APPLICATION |
duelo-cartas-api |
EB_ENVIRONMENT |
duelo-cartas-api-dev |
API_URL |
URL HTTP do Beanstalk, sem barra final |
Não cadastre os nomes de tabelas como variáveis de projeto: o pipeline já os define e os testes os substituem no escopo do job. A distinção evita que a precedência do GitLab faça um teste usar tabelas de outro ambiente.
19. C12: empacotar e implantar pelo GitLab
Artefatos e configuração pública
Um artefato de pipeline é um arquivo produzido por um job e preservado para etapas seguintes. O ZIP da API é um source bundle: o código e o manifesto que a plataforma instalará. O front terá uma cópia própria de publicação com a URL pública da API. A configuração local permanece útil ao desenvolvimento.
Crie o script abaixo antes de incluí-lo no pipeline. Ele usa somente bibliotecas internas do Node. new URL valida e decompõe o endereço; a validação exige apenas protocolo, host e eventual porta. JSON.stringify serializa a configuração sem depender de concatenação manual de aspas.
Computador local · raiz de duelo-cartas
mkdir -p scripts
touch scripts/build-frontend.js
O script lê API_URL e exige um endereço HTTP ou HTTPS sem usuário, senha, caminho adicional, consulta ou fragmento.
scripts/build-frontend.js
import { cp, mkdir, writeFile } from 'node:fs/promises';
const apiUrl = new URL(process.env.API_URL ?? '');
if (!['http:', 'https:'].includes(apiUrl.protocol) ||
apiUrl.username || apiUrl.password || apiUrl.pathname !== '/' ||
apiUrl.search || apiUrl.hash) {
throw new Error('API_URL deve conter apenas protocolo, host e porta.');
}
await mkdir('public', { recursive: true });
await cp('frontend', 'public', { recursive: true });
// JSON.stringify escapa o valor antes de incorporá-lo ao JavaScript.
const content = `window.APP_CONFIG = Object.freeze(${JSON.stringify({
API_URL: apiUrl.origin
})});\n`;
await writeFile('public/js/config.js', content);
Inclua a nova pasta de scripts no lint e teste a geração com o endpoint local. Como o argumento vale apenas para esse comando, ele não muda o valor configurado no GitLab.
Computador local · raiz de duelo-cartas
npm pkg set 'scripts.lint=eslint frontend/js backend/src backend/scripts backend/test backend/server.js scripts'
API_URL=http://localhost:3000 node scripts/build-frontend.js
npm run lint
A pasta public deve conter os arquivos do front e js/config.js. Ela é ignorada pelo Git. No job, a mesma geração usará API_URL do ambiente Beanstalk.
Adicionar o estágio e os jobs de pacote
Em .gitlab-ci.yml, acrescente package entre test e deploy. Mantenha o bloco variables e todos os jobs de qualidade já existentes.
.gitlab-ci.yml · stages
stages:
- validate
- test
- package
- deploy
Acrescente os dois jobs abaixo antes dos jobs de deploy. Como não declaram needs, eles respeitam a conclusão de todos os stages anteriores. Um erro em qualquer teste impede o empacotamento. O primeiro job instala zip dentro de sua própria imagem Alpine; ao entrar em backend antes de compactar, coloca package.json na raiz do ZIP. O pacote do front é gerado em Node e preservado como artefato para o job de publicação.
.gitlab-ci.yml · jobs de pacote
package_backend:
stage: package
image: alpine:3.22
before_script:
- apk add --no-cache zip
script:
- cd backend
- zip -r ../backend.zip package.json package-lock.json Procfile server.js src
artifacts:
paths: [backend.zip] # Disponibiliza o ZIP aos próximos jobs.
expire_in: 1 day
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
package_frontend:
stage: package
image: node:22-alpine
script:
- node scripts/build-frontend.js
artifacts:
paths: [public/]
expire_in: 1 day
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
Implantar a API e verificar a versão aplicada
Acrescente deploy_backend antes de deploy_frontend. needs cria uma dependência explícita e traz o ZIP desse job de pacote. A imagem AWS CLI já contém o executável aws; entrypoint vazio permite ao runner usar seu shell. O job consome o artefato da API e impede cancelamento automático durante a atualização do ambiente. O rótulo combina o identificador do commit e o número do pipeline; o ZIP é enviado ao bucket privado.
.gitlab-ci.yml · deploy_backend
deploy_backend:
stage: deploy
image:
name: public.ecr.aws/aws-cli/aws-cli:2.31.4
entrypoint: [''] # Entrega o controle ao shell do runner.
interruptible: false
needs:
- job: package_backend
artifacts: true
script:
- aws sts get-caller-identity
- VERSION_LABEL="${CI_COMMIT_SHORT_SHA}-${CI_PIPELINE_IID}"
- S3_KEY="releases/${VERSION_LABEL}.zip"
- aws s3 cp backend.zip "s3://${ARTIFACTS_BUCKET}/${S3_KEY}"
- |
# Evita recriar o mesmo rótulo ao repetir um job.
CURRENT=$(aws elasticbeanstalk describe-application-versions \
--application-name "$EB_APPLICATION" \
--version-labels "$VERSION_LABEL" \
--query 'ApplicationVersions[0].VersionLabel' --output text)
if [ "$CURRENT" != "$VERSION_LABEL" ]; then
aws elasticbeanstalk create-application-version \
--application-name "$EB_APPLICATION" \
--version-label "$VERSION_LABEL" \
--source-bundle S3Bucket="$ARTIFACTS_BUCKET",S3Key="$S3_KEY"
fi
- |
aws elasticbeanstalk update-environment \
--environment-name "$EB_ENVIRONMENT" --version-label "$VERSION_LABEL"
aws elasticbeanstalk wait environment-updated \
--environment-name "$EB_ENVIRONMENT"
# Ready também pode ocorrer após rollback; confira a versão aplicada.
DEPLOYED=$(aws elasticbeanstalk describe-environments \
--environment-names "$EB_ENVIRONMENT" \
--query 'Environments[0].VersionLabel' --output text)
test "$DEPLOYED" = "$VERSION_LABEL"
environment:
name: production/backend
url: $API_URL
resource_group: learner-lab-deploy # Exclusão mútua por job e projeto.
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
A consulta verifica se o rótulo já existe. O bloco literal YAML com | preserva as quebras de linha de um script de shell. --query aplica uma expressão JMESPath à resposta da AWS CLI; --output text permite comparar o valor no shell. A barra invertida no final da linha continua o mesmo comando. O teste final devolve status diferente de zero se o ambiente não executar o rótulo esperado, impedindo que a falha seja tratada como uma entrega bem-sucedida.
Atualizar somente o job de publicação do front
Substitua o bloco inteiro deploy_frontend existente pelo trecho abaixo, mantendo todos os demais jobs. Esse job recebe o pacote do front e aguarda o deploy da API. artifacts: false na dependência da API evita tentar baixar arquivos de um job que serve apenas como requisito de ordem.
.gitlab-ci.yml · deploy_frontend
deploy_frontend:
stage: deploy
image:
name: public.ecr.aws/aws-cli/aws-cli:2.31.4
entrypoint: ['']
interruptible: false
needs:
- job: deploy_backend # Só publica o front após concluir a API.
artifacts: false
- job: package_frontend
artifacts: true
script:
- aws s3 sync public "s3://${FRONTEND_BUCKET}" --delete --cache-control no-cache
- |
aws s3 cp public/js/config.js "s3://${FRONTEND_BUCKET}/js/config.js" \
--content-type application/javascript --cache-control no-store
environment:
name: production/frontend
url: $FRONTEND_URL
resource_group: learner-lab-deploy
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
resource_group serializa os jobs que usam a mesma chave, mas não transforma uma sequência inteira de deploys em uma transação nem garante, sozinho, a ordem entre pipelines diferentes. No projeto do GitLab.com, em Settings > CI/CD > General pipelines, habilite a prevenção de jobs de implantação desatualizados quando disponível. Evite iniciar várias liberações simultâneas para o mesmo ambiente de laboratório.
Computador local · terminal na raiz · versão C12
git add scripts package.json .gitlab-ci.yml
git commit -m "deploy: publica API e front integrados"
git push
O pipeline deve concluir validação, testes, pacote e deploy. No Beanstalk, a versão aplicada deve conter o rótulo do commit e pipeline; API_URL/health deve responder. No S3, js/config.js deve conter a origem pública da API, sem localhost. O login ainda pode recusar as credenciais porque o usuário público será criado no próximo passo.
20. C13: verificação pública e usuário inicial
Configurar o usuário de laboratório
O usuário do DynamoDB Local não existe automaticamente no DynamoDB da conta AWS. Cadastre as variáveis abaixo no GitLab, com escopo * e proteção habilitada. Mascare a senha e desative expansão de referências. Defina uma senha exclusiva para esse ambiente: o script aceita no mínimo oito caracteres e no máximo 72 bytes UTF-8.
| Variável | Valor |
|---|---|
SEED_NAME |
Pessoa Exemplo |
SEED_EMAIL |
aluno@exemplo.com |
SEED_PASSWORD |
Uma nova senha exclusiva do laboratório público |
Esses valores serão consumidos pelo job manual de seed. A senha não é JWT_SECRET: um valor autentica a pessoa; o outro assina os tokens emitidos pela API. Reexecutar o seed para o mesmo e-mail preserva a senha já cadastrada.
Verificação e bootstrap no pipeline
Acrescente verify ao final dos stages e mantenha as fases já existentes.
.gitlab-ci.yml · stage final
stages:
- validate
- test
- package
- deploy
- verify
Acrescente o smoke test após os deploys. O uso de --fail faz um erro HTTP resultar em falha do job. Salvar o HTML antes de pesquisá-lo mantém as duas verificações separadas e visíveis. O job aguarda a publicação do front, que já depende da API. Ele testa os endpoints públicos sem gravar dados.
.gitlab-ci.yml · smoke_production
smoke_production:
stage: verify
image:
name: curlimages/curl:8.15.0
entrypoint: ['']
needs: [deploy_frontend]
script:
- curl --fail --silent --show-error --retry 5 "$API_URL/health"
- curl --fail --silent --show-error "$FRONTEND_URL" -o home.html
- grep -q 'Duelo de Cartas' home.html
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
Acrescente o job de seed depois do smoke test. A opção when: manual exige acionar o job; allow_failure: true permite que pipelines futuros terminem sem recriar usuários. É necessário conferir o resultado do seed na primeira execução, mesmo se os jobs automáticos estiverem verdes. O job instala apenas dependências de execução da API e verifica que nenhum endpoint local foi configurado.
.gitlab-ci.yml · seed_production_user
seed_production_user:
stage: verify
image: node:22-alpine
interruptible: false
needs: [smoke_production]
before_script:
- cd backend
- npm ci --omit=dev
script:
- test -z "$DYNAMODB_ENDPOINT" # Seed público não pode usar banco local.
- npm run db:seed
environment:
name: production/bootstrap
action: prepare
when: manual
allow_failure: true # O bootstrap é opcional após a primeira criação.
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

Stages controlam as fases; needs explicita pacote e ordem entre os jobs de implantação.
Computador local · terminal na raiz · versão C13
git add .gitlab-ci.yml
git commit -m "ops: verifica endpoints e cria usuario inicial"
git push
Aguarde o smoke test passar. No mesmo pipeline, execute manualmente seed_production_user. O log deve indicar usuário criado ou já existente. Se aparecer AccessDenied, confira sessão, região, nomes de tabela e permissões da identidade do runner. Não transforme uma falha de seed em criação manual de senha em texto puro no DynamoDB.
Validar o fluxo completo no navegador
- Abra o website endpoint do S3, por HTTP.
- Tente uma senha incorreta e confira a mensagem genérica de autenticação.
- Entre com
SEED_EMAILe a senha definida no GitLab. - Jogue três rodadas; recarregue a página e confira placar e histórico persistidos.
- Em Network, confira respostas JSON e o cabeçalho
Authorizationnas rotas protegidas. Não deve aparecerpasswordHashou segredo em respostas. - Exclua o histórico; após concluir, recarregue e confirme a ausência de rodadas e o ajuste dos contadores.
- Saia. O acesso ao painel deve levar ao login; requisições sem token para rotas protegidas devem receber 401.
O health check verifica somente o processo. O login confirma acesso ao usuário e ao segredo; jogar e consultar confirmam persistência; excluir confirma o caminho de remoção. Essas verificações observam responsabilidades diferentes e não devem ser substituídas por uma única URL que retorna 200.
21. Operação, diagnóstico e evolução
Retomar uma sessão
Inicie o Learner Lab e aguarde disponibilidade. Atualize access key, secret key e session token no GitLab. Confira o estado do ambiente Beanstalk e as tabelas. Só depois faça o push ou execute Run pipeline na branch main. Recursos podem permanecer armazenados enquanto sessões mudam; processos e instâncias podem ser interrompidos ou sujeitos às políticas do laboratório.
Para retomar o ambiente local, inicie Docker, o banco e a API. O comando de criação é seguro para tabelas locais já existentes. Em outro terminal na raiz, inicie o servidor estático.
Computador local · raiz de duelo-cartas
docker compose up -d
cd backend
npm run db:create
npm start
Computador local · outro terminal na raiz · front local
npx --yes http-server@14.1.1 frontend -a 127.0.0.1 -p 8080 -c-1
Branches, revisão e rollback
Uma branch permite modificar o código sem alterar diretamente a versão publicada. Crie uma branch, edite e execute as verificações apropriadas. Depois, envie-a e abra um merge request no GitLab. Pelas regras do pipeline, branches comuns executam validação e testes; somente a branch padrão publica.
Computador local · raiz de duelo-cartas
git switch -c feature/melhora-acessibilidade
# Após editar e verificar as alterações:
git add frontend
git commit -m "front: melhora navegacao por teclado"
git push -u origin feature/melhora-acessibilidade
Após o merge, retorne à branch principal e sincronize seu conteúdo. Para desfazer uma mudança já publicada, um revert cria um novo commit que inverte a alteração, preservando o histórico; substitua o identificador pelo commit real que precisa ser revertido.
Computador local · raiz de duelo-cartas
git switch main
git pull --ff-only
# Execute apenas quando houver uma alteração específica a desfazer:
git revert HASH_DO_COMMIT
git push
Para uma reversão imediata da API pelo Console AWS, abra Elastic Beanstalk > Application versions, selecione uma versão anterior conhecida e implante no ambiente. Depois alinhe o repositório com a correção para que o próximo pipeline não publique novamente o defeito. Não edite objetos do front pelo Console como solução permanente.
Diagnóstico por sintoma
| Sintoma | Causa possível | Verificação |
|---|---|---|
| Job Pending | Runner, cota ou tags | Confira runners habilitados, computação disponível e ausência de tags não atendidas. |
| ExpiredToken | Credenciais da sessão anterior | Renove as três variáveis AWS da mesma sessão; execute novo pipeline. |
npm ci falha |
Manifesto e lockfile divergentes | Execute npm install na pasta correta, teste e envie manifesto e lockfile juntos. |
| ECONNREFUSED | Banco ainda indisponível ou endereço errado | No host use 127.0.0.1:8000; no CI use dynamodb:8000. Confira logs e a espera de prontidão. |
| Erro de chave no DynamoDB | Esquema da tabela incorreto | Compare nome, tipo e capitalização de email, userId e roundId. |
| 403 no website S3 | Política, bloqueio público ou objeto ausente | Confira endpoint de website, objeto index.html, GetObject e ARN do bucket. |
| CORS no navegador | Origem diferente da lista | Compare protocolo, host e porta exatamente; remova barra final do valor configurado. |
| Login retorna 400 | Corpo ou dados inválidos | Confira JSON, tipos, formato do e-mail e limites da senha. |
| Login retorna 401 | Credenciais incorretas | Confirme que o seed ocorreu nesse banco e que não se espera trocar senha ao repetir o seed. |
| Login retorna 500 | Banco ou segredo indisponível | No Console AWS, confira os logs do ambiente Beanstalk e o segredo no Secrets Manager. |
| Beanstalk retorna 502 | Processo web não iniciou | Confira Procfile, npm start, logs de instalação, Node 22 e uso de PORT. |
| Deploy Ready, mas falhou | Rollback da versão | Compare o rótulo desejado ao VersionLabel real; leia o primeiro erro em Events. |
| Histórico ou placar parcial | Operação interrompida | Releia os dados. A exclusão pode ser repetida; cada rodada e contador são alterados juntos. |
| Front chama localhost | Configuração pública não foi gerada | Confira o artefato public e o config.js no S3; execute pacote e deploy do mesmo pipeline. |
Encerrar os recursos do projeto
Quando o ambiente não for mais necessário, confirme os nomes antes de excluir recursos. Termine o ambiente Beanstalk e aguarde sua remoção; depois remova a aplicação sem ambientes ativos. Esvazie e exclua somente os dois buckets do projeto, remova as tabelas correspondentes e programe a exclusão do segredo. Retire credenciais e variáveis sensíveis do GitLab. Recursos de armazenamento, segredos e endereços podem ter ciclo de cobrança diferente do processo web.
Localmente, docker compose down encerra o banco preservando o volume. Se a intenção for apagar os dados locais, use a opção -v já apresentada. Encerrar o servidor estático e a API exige Ctrl+C em seus terminais.
22. Parabéns!
Você construiu o Duelo de Cartas de ponta a ponta: front responsivo, API autenticada, persistência com transações e um pipeline GitLab que valida, testa, empacota, implanta e verifica a aplicação na AWS.
Exercícios
- Calcule e exiba a taxa de vitórias, com uma casa decimal. Defina o resultado para zero rodadas e teste os casos de borda.
- Acrescente um teste para o resultado de derrota e verifique que as 52 identidades do baralho continuam únicas.
- Implemente paginação do histórico usando
LastEvaluatedKey. A API deve devolver um cursor opaco, validado e associado ao usuário autenticado; a interface não deve repetir itens. - Projete uma identidade estável de usuário independente do e-mail. Descreva a mudança das chaves e uma estratégia para dados já existentes.
- Implemente uma chave de idempotência para
POST /api/rounds. Reenvios com a mesma chave devem recuperar o mesmo resultado; cargas diferentes com a mesma chave devem ser rejeitadas. - Crie testes com dois usuários e demonstre que consultar, criar e excluir rodadas de um não altera os dados do outro.
- Simule falhas durante a exclusão de histórico. Verifique que os contadores correspondem às rodadas remanescentes e que a operação pode ser concluída ao repetir a chamada.
- Adapte o pipeline para um ambiente de homologação separado, com tabelas, segredo, bucket e ambiente Beanstalk próprios. Defina os escopos das variáveis e as regras de promoção.
- Proponha uma implantação HTTPS com S3 privado e CloudFront, além de TLS na API. Compare
sessionStoragecom sessão em cookie considerando XSS, CSRF e expiração. - Adicione logs estruturados com identificador de requisição e testes que impeçam a exposição de senha, hash, JWT ou credenciais AWS. Explique quais sinais indicariam uma regressão após deploy.
Referências
- OPENJS FOUNDATION. ESLint: version support.
- AMAZON WEB SERVICES. Types of cloud computing.
- AMAZON WEB SERVICES. Regions and Zones.
- MDN CONTRIBUTORS. HTTP request methods.
- MDN CONTRIBUTORS. HTTP response status codes.
- MDN CONTRIBUTORS. Cross-Origin Resource Sharing (CORS).
- OPENJS FOUNDATION. Node.js: environment variables.
- AMAZON WEB SERVICES. Configuring custom start commands with a Procfile on Elastic Beanstalk.
- AMAZON WEB SERVICES. Buildfile and Procfile.
- AMAZON WEB SERVICES. Best practices for using sort keys to organize data.
- AMAZON WEB SERVICES. Querying tables in DynamoDB.
- AMAZON WEB SERVICES. Configuring a static website on Amazon S3.
- AMAZON WEB SERVICES. Website endpoints.
- AMAZON WEB SERVICES. Using the Elastic Beanstalk Node.js platform.
- AMAZON WEB SERVICES. Core components of Amazon DynamoDB.
- AMAZON WEB SERVICES. DynamoDB local usage notes.
- AMAZON WEB SERVICES. Amazon DynamoDB transactions: how it works.
- AMAZON WEB SERVICES. AWS SDK for JavaScript v3 Developer Guide.
- BOOTSTRAP TEAM. Bootstrap 5.3: documentation.
- DOCKER. What is a container?
- DOCKER. What is an image?
- OPENJS FOUNDATION. Express: error handling.
- GITLAB. GitLab CI/CD variables.
- GITLAB. Services.
- GITLAB. Resource groups.
- GITLAB. Deployment safety.
- OWASP FOUNDATION. Password Storage Cheat Sheet.
- OWASP FOUNDATION. HTML5 Security Cheat Sheet.