From 1f08a7254eea454f0b41716c3abde6a697134658 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 12 Oct 2026 04:57:53 -0300 Subject: [PATCH 001/130] feat: single verification script and CI verdict Until now "done" was declared by the same agent that wrote the code, and the checks only existed as instructions. This adds one definition of "passed" (scripts/verify.sh) and a CI job that re-runs it on every pull request, in the same Compose environment used for development. Missing evidence fails: an empty suite, a skipped test or a missing report now reject the run. The design and the steps still to come are in docs/harness.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 54 +++++++++ AGENTS.md | 15 +++ apps/api/.gitignore | 1 + apps/api/AGENTS.md | 2 +- apps/api/vitest.config.ts | 2 - apps/web/.gitignore | 1 + apps/web/playwright.config.ts | 9 +- docs/harness.md | 113 ++++++++++++++++++ scripts/check-report.cjs | 72 ++++++++++++ scripts/verify.sh | 211 ++++++++++++++++++++++++++++++++++ 10 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/harness.md create mode 100644 scripts/check-report.cjs create mode 100755 scripts/verify.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b9dd0d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +# O veredito do projeto: reexecuta a definição de pronto inteira, no mesmo Compose do +# desenvolvimento. O desenho está no docs/harness.md. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verdict: + name: Veredito + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + # A varredura de segredos compara a branch com a main. + fetch-depth: 0 + + # Os contêineres rodam com o usuário 1000 e escrevem na pasta montada + # (node_modules, .env, relatórios). O usuário do runner é outro. + - name: Liberar a escrita dos contêineres em apps/ + run: chmod -R a+rwX apps + + - name: Subir o ambiente + run: docker compose -f compose.dev.yaml up -d --build --wait + + - name: Verificar + run: scripts/verify.sh full --start-apps + + - name: Logs dos contêineres + if: failure() + run: docker compose -f compose.dev.yaml logs --no-color --tail 200 + + - name: Guardar os relatórios + if: always() + uses: actions/upload-artifact@v4 + with: + name: relatorios + if-no-files-found: ignore + path: | + apps/api/reports/ + apps/web/reports/ + apps/web/playwright-report/ + apps/web/test-results/ diff --git a/AGENTS.md b/AGENTS.md index e4fcce1..632a037 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ Em `docs/` estão os documentos que definem o produto, a arquitetura e os detalh 2. `docs/hld.md`: High-level design, documento de arquitetura de alto nível. Aqui ficam as decisões de arquitetura, os fluxos principais e a visão geral do sistema. 3. `docs/lld.md`: Low-level design, documento de arquitetura de baixo nível. Aqui ficam a estrutura de pastas, as bibliotecas escolhidas, os esquemas de banco, os contratos de API, os estados, as regras de cota, as tarefas do worker, o upload em chunks e as variáveis de ambiente. 4. `docs/design-system.md`: linguagem visual da interface. Aqui ficam os tokens de cor, tipografia, espaçamento, raio e sombra, os estados de interação, os componentes, o comportamento responsivo e o fluxo de trabalho com o Figma. +5. `docs/harness.md`: como o trabalho dos agentes é verificado. Aqui ficam o script de verificação, a CI, a política do que bloqueia e do que só avisa e o que ficou de fora. Cada projeto tem o próprio `AGENTS.md`, com os comandos, as particularidades da stack e as práticas de código dele: @@ -154,6 +155,20 @@ Uma mudança no código só está concluída quando **todos** os itens abaixo pa Se algum item falhar, a tarefa não está pronta. Corrija a causa antes de declarar a conclusão. +**Verificação completa** + +O `scripts/verify.sh full` roda os itens 2 a 4 de uma vez, mais o build da web e a varredura de segredos. Rode-o da raiz do repositório, na máquina (ele mesmo chama os contêineres), com a web e a API no ar: + +```bash +scripts/verify.sh full +``` + +- **O veredito é o da CI.** Ela roda esse mesmo comando em todo pull request. Passar na máquina é condição para abrir o PR, e não prova de que a mudança está pronta. +- **Não contorne uma verificação.** Não pule teste (`.skip`, `.only`, `.todo`, `.fixme`), não apague teste que falha e não altere o script nem o workflow para fazer a mudança passar. A CI reprova teste pulado e suíte vazia. +- **Depois de duas reprovações da CI no mesmo PR, pare** e avise o usuário, em vez de tentar a terceira correção. + +O desenho e os motivos estão no `docs/harness.md`. + Os comandos de teste, de lint e de formatação de cada projeto estão no `AGENTS.md` dele. Rode todos eles dentro do contêiner do projeto, como descrito em [Execução no Docker](#execução-no-docker). diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 9a94532..1fefba2 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -8,6 +8,7 @@ # tests /coverage +/reports # logs npm-debug.log* diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index 8a94495..e4f4849 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -29,7 +29,7 @@ docker compose -f compose.dev.yaml exec api **Cada suíte tem o próprio comando e a própria configuração do Vitest.** O `pnpm test` roda só a de unidade. Para cumprir a definição de pronto, rode as três: `pnpm test`, `pnpm test:int` e `pnpm test:e2e`. -A suíte de unidade passa mesmo sem nenhum arquivo de teste. As de integração e de ponta a ponta precisam do `postgres` e do `mailpit` no ar, o que o Compose já garante. +Uma suíte que não roda nenhum teste falha. As de integração e de ponta a ponta precisam do `postgres` e do `mailpit` no ar, o que o Compose já garante. O `pnpm lint` só aponta os problemas, sem corrigir. A formatação é conferida à parte, pelo `pnpm format`. diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index dd627a9..a05d836 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -7,8 +7,6 @@ export default mergeConfig( defineConfig({ test: { include: ['src/**/*.spec.ts'], - // A suíte pode ficar sem nenhum teste e, mesmo assim, tem de passar. - passWithNoTests: true, }, }), ); diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 9babaf7..a36fbdd 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -14,6 +14,7 @@ /coverage /test-results/ /playwright-report/ +/reports/ # next.js /.next/ diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 8c685bd..75c2cee 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -9,7 +9,14 @@ export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, - reporter: [["list"], ["html", { open: "never" }]], + // Na CI, o teste que falha tem uma nova tentativa. Se passar nela, vira aviso no PR. + retries: process.env.CI ? 1 : 0, + // O JSON é a evidência que o scripts/verify.sh confere. + reporter: [ + ["list"], + ["html", { open: "never" }], + ["json", { outputFile: "reports/playwright.json" }], + ], use: { baseURL: requiredEnv("WEB_URL"), trace: "retain-on-failure", diff --git a/docs/harness.md b/docs/harness.md new file mode 100644 index 0000000..1eded43 --- /dev/null +++ b/docs/harness.md @@ -0,0 +1,113 @@ +# Harness — verificação do trabalho dos agentes + +Este documento é dono do desenho do harness: que controles existem, o que cada um decide, o que ficou de fora e por quê. As regras que o agente segue a cada tarefa ficam no `AGENTS.md` da raiz, que aponta para cá. + +O projeto é uma referência didática que precisa funcionar de verdade. Por isso o harness tem poucas peças, cada uma demonstrável e com o motivo escrito. A origem das decisões é a pesquisa "Verificação e harness para agentes de código: pesquisa consolidada", de 12 de outubro de 2026. + +## Estado + +| Etapa | Peça | Estado | +| --- | --- | --- | +| 1 | Script de verificação e CI | Implementado | +| 2 | Proteção da `main` e guarda do harness | Ainda não implementado | +| 3 | Hook de fim de turno | Ainda não implementado | +| 4 | Tabela de aceite | Ainda não implementado | +| 5 | Revisor em contexto novo | Ainda não implementado | + +As seções das etapas ainda não implementadas registram a decisão tomada. Cada uma é completada quando a etapa entra. + +## Princípios + +- **Quem faz não dá o veredito.** O agente declara que terminou, mas quem decide se passou é a CI, num ambiente que ele não controla. +- **Só fato computacional bloqueia.** Teste que falhou, relatório ausente e segredo nos commits são fatos, e reprovam. Julgamento, como o do revisor, só anota. +- **Evidência ausente reprova.** Suíte que não rodou, relatório que não foi gerado e contêiner fora do ar contam como falha, e não como sucesso. +- **Uma definição só de "passou".** O comando que o agente roda é o mesmo que a CI roda. +- **Toda falha diz como corrigir.** A mensagem traz o que falhou e o próximo passo. + +## Política de severidade + +| Achado | Efeito | Etapa | +| --- | --- | --- | +| Erro de tipos ou de lint | Bloqueia | 1 | +| Teste que falhou | Bloqueia | 1 | +| Suíte sem nenhum teste executado | Bloqueia | 1 | +| Teste pulado (`.skip`, `.only`, `.todo`, `.fixme`) | Bloqueia | 1 | +| Relatório de suíte ausente ou ilegível | Bloqueia | 1 | +| Build da web com erro | Bloqueia | 1 | +| Segredo nos commits da branch | Bloqueia | 1 | +| Teste no navegador que só passou na nova tentativa | Aviso no PR | 1 | +| PR que altera arquivo protegido do harness | Bloqueia | 2 | +| PR que altera configuração de lint ou de teste | Aviso no PR | 2 | +| Linha da tabela de aceite sem teste, com teste inexistente ou pulado | Bloqueia | 4 | +| PR sem tabela de aceite, fora das branches `docs/*` | Bloqueia | 4 | +| Achado do revisor | Comentário no PR | 5 | + +## Script de verificação + +O `scripts/verify.sh` roda as verificações dentro dos contêineres do `compose.dev.yaml` e tem dois modos: + +| Modo | O que roda | Quem chama | +| --- | --- | --- | +| `fast [api] [web]` | Tipos e lint dos projetos citados | O hook de fim de turno (etapa 3) | +| `full [--start-apps]` | O modo rápido, as três suítes da API, o build da web, a suíte no navegador e a varredura de segredos | A CI, e o agente antes de concluir | + +- **Códigos de saída:** 0 quando tudo passou, 1 quando alguma verificação falhou e 2 quando não deu para verificar (contêiner fora do ar, web ou API sem responder). +- **Relatórios:** cada suíte grava um JSON em `reports/`, na pasta do projeto, e o `scripts/check-report.cjs` o confere. Os relatórios antigos são apagados no começo, para que só valha a evidência da execução atual. +- **Web e API no ar:** a suíte no navegador depende das duas em modo de desenvolvimento. Na máquina, o script exige que elas já estejam respondendo. Com `--start-apps`, usado pela CI, ele mesmo as sobe. +- **Segredos:** o gitleaks, em versão fixa, olha os commits que a branch tem a mais do que a `origin/main`. A variável `HARNESS_BASE_REF` troca essa base. + +## CI + +O workflow `CI` roda em todo pull request e em todo push na `main`. O job `Veredito` sobe o `compose.dev.yaml` e chama `scripts/verify.sh full --start-apps`. É o único veredito do projeto: o resultado que o agente obtém na máquina não aprova nada. + +- **Nova tentativa:** na CI, o teste no navegador que falha roda mais uma vez. Se passar, a suíte é aprovada e o teste aparece como aviso no PR. O aviso existe para que um defeito intermitente real não passe em silêncio. +- **Relatórios:** os JSON, o relatório HTML do Playwright e os traces ficam como artefato da execução. +- **Fora do veredito:** a formatação com o Prettier e a validação visual não são conferidas pela CI. + +## Proteção da `main` e guarda do harness + +Ainda não implementado. Decisão: + +- **Ruleset na `main`:** pull request obrigatório, o check `Veredito` obrigatório, nenhuma aprovação exigida, sem push direto e sem force push. Não se exige aprovação porque o projeto tem um único humano, e o GitHub não deixa aprovar o próprio PR. +- **Override:** só o admin pula o ruleset, e só em PR. O registro do GitHub de quem pulou a regra é a trilha de override. Serve para o bloqueio por engano, como o de um teste instável. +- **O agente não faz merge.** O `.claude/settings.json` nega o `gh pr merge`. O agente usa a credencial do humano, então só essa regra garante que um merge foi um ato humano. Ela contém o erro, não a má-fé: o mesmo merge é possível pela API do GitHub. +- **Guarda do harness:** um segundo check obrigatório, que roda com a definição de workflow da `main`, e não a do PR. Ele reprova o PR que altera um workflow, o `.claude/settings.json` ou um hook. Sem ele, um PR poderia enfraquecer a CI e ser aprovado pela CI enfraquecida. A consequência é que toda mudança no harness entra por override do admin. +- **Confirmação local:** editar esses mesmos arquivos pede a confirmação do humano, mesmo no modo automático. +- **Fora do guarda:** as configurações de lint e de teste e os `AGENTS.md` mudam em tarefas normais. O guarda só anota um aviso quando as configurações mudam. + +## Hook de fim de turno + +Ainda não implementado. Decisão: + +- **O que faz:** quando o agente encerra o turno com código alterado em `apps/api` ou `apps/web`, o hook roda o modo rápido do projeto alterado. Se falhar, a saída volta ao agente, que continua o trabalho. +- **Falha fechada:** com código alterado e os contêineres fora do ar, o hook bloqueia e diz para subir o ambiente. Sem alteração em `apps/`, ele não faz nada. +- **Fora:** hook a cada edição de arquivo (o código fica quebrado no meio de uma mudança, e o hook viraria ruído), suítes de teste no hook (lentas) e hook de Git (redundante com a CI e contornável com `--no-verify`). + +## Tabela de aceite + +Ainda não implementado. Decisão: + +- **O que é:** uma tabela no corpo do PR, com uma linha por critério de aceite e, em cada linha, o arquivo e o nome do teste que o prova. Os critérios vêm do ticket ou, em PR sem ticket, do próprio PR. +- **O que a CI confere:** que cada teste citado existe, rodou e passou, pelos relatórios da própria execução. +- **O que o humano confere no merge:** que as linhas da tabela são os critérios do ticket. A CI não lê o Linear. +- **Limite:** a tabela prova que o teste citado passou, e não que ele verifica o critério. Uma asserção fraca continua passando. + +## Revisor em contexto novo + +Ainda não implementado. Decisão: + +- **Quem revisa:** um subagente que não viu a implementação. Ele recebe o diff, o ticket e os documentos, e não tem permissão de escrita. +- **Onde fica o resultado:** num comentário no PR, com o que foi corrigido, o que foi deixado e o motivo. A CI não lê esse comentário. +- **Sem bloqueio:** o revisor não foi calibrado, então só anota. O que fazer com um achado não corrigido é decisão do humano no merge. + +## O que ficou de fora + +| Peça | Por quê | +| --- | --- | +| Mutation testing | Custo alto para o escopo didático. É a resposta conhecida para asserção fraca, e a primeira evolução a considerar. | +| Piso de cobertura | Cobertura mede execução, e não verificação. | +| Revisor com poder de bloqueio | Exige calibrar o revisor com tarefas antigas, e o projeto ainda não tem histórico para isso. | +| Revisor na CI | Pede uma chave de API como segredo num repositório público. Como ele não bloqueia, rodar na máquina entrega o mesmo relatório. | +| Credencial própria para o agente | Fecharia de vez o merge pelo agente, mas é excessivo para o escopo. | +| Sensores contínuos (deriva, código morto, sinais de produção) | O projeto não tem ambiente de produção nem volume de código que os justifique. | +| Limite forçado de rodadas de correção | Fica como regra no `AGENTS.md`, sem mecanismo. | diff --git a/scripts/check-report.cjs b/scripts/check-report.cjs new file mode 100644 index 0000000..9790f32 --- /dev/null +++ b/scripts/check-report.cjs @@ -0,0 +1,72 @@ +// Confere o relatório JSON de uma suíte: ele tem de existir, ter rodado ao menos um teste, +// sem falha e sem teste pulado. Teste instável (falhou e passou na nova tentativa) vira aviso. +// +// Roda dentro do contêiner do projeto, lido pela entrada padrão (ver scripts/verify.sh): +// node - + +const fs = require('node:fs'); + +const [kind, file] = process.argv.slice(2); + +function fail(message) { + console.error(`Relatório ${file}: ${message}`); + process.exit(1); +} + +if (!fs.existsSync(file)) { + fail('não foi gerado. Sem relatório, não há evidência de que a suíte rodou.'); +} + +let report; +try { + report = JSON.parse(fs.readFileSync(file, 'utf8')); +} catch (error) { + fail(`não é um JSON válido (${error.message}).`); +} + +/** Títulos dos testes do Playwright que só passaram na nova tentativa. */ +function flakyTitles(suite, path = []) { + const here = suite.title ? [...path, suite.title] : path; + const own = (suite.specs ?? []) + .filter((spec) => spec.tests.some((test) => test.status === 'flaky')) + .map((spec) => [...here, spec.title].join(' › ')); + const nested = (suite.suites ?? []).flatMap((child) => flakyTitles(child, here)); + return [...own, ...nested]; +} + +let counts; +if (kind === 'vitest') { + counts = { + total: report.numTotalTests, + failed: report.numFailedTests, + skipped: report.numPendingTests + report.numTodoTests, + flaky: [], + }; +} else if (kind === 'playwright') { + const { expected, unexpected, skipped, flaky } = report.stats; + counts = { + total: expected + unexpected + skipped + flaky, + failed: unexpected, + skipped, + flaky: report.suites.flatMap((suite) => flakyTitles(suite)), + }; +} else { + fail(`tipo de relatório desconhecido: ${kind}.`); +} + +for (const title of counts.flaky) { + // O formato `::warning` vira anotação no PR quando roda no GitHub Actions. + console.log(`::warning title=Teste instável::${title} falhou e só passou na nova tentativa.`); +} + +if (!Number.isInteger(counts.total) || counts.total === 0) { + fail('nenhum teste rodou.'); +} +if (counts.failed > 0) { + fail(`${counts.failed} teste(s) falharam.`); +} +if (counts.skipped > 0) { + fail(`${counts.skipped} teste(s) foram pulados.`); +} + +console.log(`Relatório ${file}: ${counts.total} teste(s), nenhum pulado.`); diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..287174d --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# +# Verificação do projeto. É a única definição de "passou": a CI chama o modo completo, +# e os hooks chamam o modo rápido. O desenho está no docs/harness.md. +# +# scripts/verify.sh fast [api] [web] tsc e lint dos projetos citados (os dois, se nenhum) +# scripts/verify.sh full [--start-apps] o modo rápido, as suítes de teste, o build da web +# e a varredura de segredos +# +# Roda tudo nos contêineres do compose.dev.yaml, que já têm de estar no ar. No modo completo, +# a web e a API também têm de estar respondendo; o `--start-apps` as sobe, e é para a CI. +# +# Sai com 0 se tudo passou, 1 se alguma verificação falhou e 2 se não deu para verificar. + +set -uo pipefail +cd "$(dirname "$0")/.." + +COMPOSE=(docker compose -f compose.dev.yaml) +# Versão fixa, para o mesmo diff dar sempre o mesmo resultado. +GITLEAKS_IMAGE=ghcr.io/gitleaks/gitleaks:v8.30.1 +# A varredura de segredos olha os commits que a branch tem a mais do que esta referência. +BASE_REF="${HARNESS_BASE_REF:-origin/main}" + +failures=() + +usage() { + sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +# Não deu para verificar: falha fechada, com o que fazer. +abort() { + printf '\nNÃO VERIFICADO: %s\n' "$1" >&2 + printf 'Como resolver: %s\n' "$2" >&2 + exit 2 +} + +# step +step() { + local name="$1" hint="$2" + shift 2 + printf '\n=== %s\n' "$name" + if "$@"; then + return 0 + fi + failures+=("$name"$'\n'" Como corrigir: $hint") + return 1 +} + +in_container() { + local service="$1" + shift + "${COMPOSE[@]}" exec -T "$service" "$@" +} + +require_container() { + in_container "$1" true >/dev/null 2>&1 || + abort "o contêiner \`$1\` não está no ar." \ + "suba o ambiente com \`docker compose -f compose.dev.yaml up -d --build --wait\` e rode de novo." +} + +# Confere o relatório JSON de uma suíte dentro do contêiner, onde está o Node. +check_report() { + local service="$1" kind="$2" file="$3" + in_container "$service" node - "$kind" "$file" + return this.repo.save({ + content: dto.content, // Raw, unsanitized + authorId: dto.authorId, + }); + } +} + +// Return HTML without sanitization +@Controller('pages') +export class PagesController { + @Get(':slug') + @Header('Content-Type', 'text/html') + async getPage(@Param('slug') slug: string): Promise { + const page = await this.pagesService.findBySlug(slug); + // If page.content contains user input, XSS is possible + return `${page.content}`; + } +} + +// Reflect user input in errors +@Get(':id') +async findOne(@Param('id') id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // XSS if id contains malicious content and error is rendered + throw new NotFoundException(`User ${id} not found`); + } + return user; +} +``` + +**Correct (sanitize content and use proper headers):** + +```typescript +// Sanitize HTML content before storage +import * as sanitizeHtml from 'sanitize-html'; + +@Injectable() +export class CommentsService { + private readonly sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], + allowedAttributes: { + a: ['href', 'title'], + }, + allowedSchemes: ['http', 'https', 'mailto'], + }; + + async create(dto: CreateCommentDto): Promise { + return this.repo.save({ + content: sanitizeHtml(dto.content, this.sanitizeOptions), + authorId: dto.authorId, + }); + } +} + +// Use validation pipe to strip HTML +import { Transform } from 'class-transformer'; + +export class CreatePostDto { + @IsString() + @MaxLength(1000) + @Transform(({ value }) => sanitizeHtml(value, { allowedTags: [] })) + title: string; + + @IsString() + @Transform(({ value }) => + sanitizeHtml(value, { + allowedTags: ['p', 'br', 'b', 'i', 'a'], + allowedAttributes: { a: ['href'] }, + }), + ) + content: string; +} + +// Set proper Content-Type headers +@Controller('api') +export class ApiController { + @Get('data') + @Header('Content-Type', 'application/json') + async getData(): Promise { + // JSON response - browser won't execute scripts + return this.service.getData(); + } +} + +// Sanitize error messages +@Get(':id') +async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // UUID validation ensures safe format + throw new NotFoundException('User not found'); + } + return user; +} + +// Use Helmet for CSP headers +import helmet from 'helmet'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, + }), + ); + + await app.listen(3000); +} +``` + +Reference: [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) diff --git a/.claude/skills/nestjs-best-practices/rules/security-use-guards.md b/.claude/skills/nestjs-best-practices/rules/security-use-guards.md new file mode 100644 index 0000000..fb1359c --- /dev/null +++ b/.claude/skills/nestjs-best-practices/rules/security-use-guards.md @@ -0,0 +1,135 @@ +--- +title: Use Guards for Authentication and Authorization +impact: HIGH +impactDescription: Enforces access control before handlers execute +tags: security, guards, authentication, authorization +--- + +## Use Guards for Authentication and Authorization + +Guards determine whether a request should be handled based on authentication state, roles, permissions, or other conditions. They run after middleware but before pipes and interceptors, making them ideal for access control. Use guards instead of manual checks in controllers. + +**Incorrect (manual auth checks in every handler):** + +```typescript +// Manual auth checks in every handler +@Controller('admin') +export class AdminController { + @Get('users') + async getUsers(@Request() req) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.getUsers(); + } + + @Delete('users/:id') + async deleteUser(@Request() req, @Param('id') id: string) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.deleteUser(id); + } +} +``` + +**Correct (guards with declarative decorators):** + +```typescript +// JWT Auth Guard +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Check for @Public() decorator + const isPublic = this.reflector.getAllAndOverride('isPublic', [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + request.user = await this.jwtService.verifyAsync(token); + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + private extractToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} + +// Roles Guard +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) return true; + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} + +// Decorators +export const Public = () => SetMetadata('isPublic', true); +export const Roles = (...roles: Role[]) => SetMetadata('roles', roles); + +// Register guards globally +@Module({ + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AppModule {} + +// Clean controller +@Controller('admin') +@Roles(Role.Admin) // Applied to all routes +export class AdminController { + @Get('users') + getUsers(): Promise { + return this.adminService.getUsers(); + } + + @Delete('users/:id') + deleteUser(@Param('id') id: string): Promise { + return this.adminService.deleteUser(id); + } + + @Public() // Override: no auth required + @Get('health') + health() { + return { status: 'ok' }; + } +} +``` + +Reference: [NestJS Guards](https://docs.nestjs.com/guards) diff --git a/.claude/skills/nestjs-best-practices/rules/security-validate-all-input.md b/.claude/skills/nestjs-best-practices/rules/security-validate-all-input.md new file mode 100644 index 0000000..f489d06 --- /dev/null +++ b/.claude/skills/nestjs-best-practices/rules/security-validate-all-input.md @@ -0,0 +1,150 @@ +--- +title: Validate All Input with DTOs and Pipes +impact: HIGH +impactDescription: First line of defense against attacks +tags: security, validation, dto, pipes +--- + +## Validate All Input with DTOs and Pipes + +Always validate incoming data using class-validator decorators on DTOs and the global ValidationPipe. Never trust user input. Validate all request bodies, query parameters, and route parameters before processing. + +**Incorrect (trust raw input without validation):** + +```typescript +// Trust raw input without validation +@Controller('users') +export class UsersController { + @Post() + create(@Body() body: any) { + // body could contain anything - SQL injection, XSS, etc. + return this.usersService.create(body); + } + + @Get() + findAll(@Query() query: any) { + // query.limit could be "'; DROP TABLE users; --" + return this.usersService.findAll(query.limit); + } +} + +// DTOs without validation decorators +export class CreateUserDto { + name: string; // No validation + email: string; // Could be "not-an-email" + age: number; // Could be "abc" or -999 +} +``` + +**Correct (validated DTOs with global ValidationPipe):** + +```typescript +// Enable ValidationPipe globally in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + await app.listen(3000); +} + +// Create well-validated DTOs +import { + IsString, + IsEmail, + IsInt, + Min, + Max, + IsOptional, + MinLength, + MaxLength, + Matches, + IsNotEmpty, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(100) + @Transform(({ value }) => value?.trim()) + name: string; + + @IsEmail() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; + + @IsInt() + @Min(0) + @Max(150) + age: number; + + @IsString() + @MinLength(8) + @MaxLength(100) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { + message: 'Password must contain uppercase, lowercase, and number', + }) + password: string; +} + +// Query DTO with defaults and transformation +export class FindUsersQueryDto { + @IsOptional() + @IsString() + @MaxLength(100) + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset: number = 0; +} + +// Param validation +export class UserIdParamDto { + @IsUUID('4') + id: string; +} + +@Controller('users') +export class UsersController { + @Post() + create(@Body() dto: CreateUserDto): Promise { + // dto is guaranteed to be valid + return this.usersService.create(dto); + } + + @Get() + findAll(@Query() query: FindUsersQueryDto): Promise { + // query.limit is a number, query.search is sanitized + return this.usersService.findAll(query); + } + + @Get(':id') + findOne(@Param() params: UserIdParamDto): Promise { + // params.id is a valid UUID + return this.usersService.findById(params.id); + } +} +``` + +Reference: [NestJS Validation](https://docs.nestjs.com/techniques/validation) diff --git a/.claude/skills/nestjs-best-practices/rules/test-e2e-supertest.md b/.claude/skills/nestjs-best-practices/rules/test-e2e-supertest.md new file mode 100644 index 0000000..4265513 --- /dev/null +++ b/.claude/skills/nestjs-best-practices/rules/test-e2e-supertest.md @@ -0,0 +1,178 @@ +--- +title: Use Supertest for E2E Testing +impact: HIGH +impactDescription: Validates the full request/response cycle +tags: testing, e2e, supertest, integration +--- + +## Use Supertest for E2E Testing + +End-to-end tests use Supertest to make real HTTP requests against your NestJS application. They test the full stack including middleware, guards, pipes, and interceptors. E2E tests catch integration issues that unit tests miss. + +**Incorrect (no proper E2E setup or teardown):** + +```typescript +// Only unit test controllers +describe('UsersController', () => { + it('should return users', async () => { + const service = { findAll: jest.fn().mockResolvedValue([]) }; + const controller = new UsersController(service as any); + + const result = await controller.findAll(); + + expect(result).toEqual([]); + // Doesn't test: routes, guards, pipes, serialization + }); +}); + +// E2E tests without proper setup/teardown +describe('Users API', () => { + it('should create user', async () => { + const app = await NestFactory.create(AppModule); + // No proper initialization + // No cleanup after test + // Hits real database + }); +}); +``` + +**Correct (proper E2E setup with Supertest):** + +```typescript +// Proper E2E test setup +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // Apply same config as production + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/users (POST)', () => { + it('should create a user', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'john@test.com' }) + .expect(201) + .expect((res) => { + expect(res.body).toHaveProperty('id'); + expect(res.body.name).toBe('John'); + expect(res.body.email).toBe('john@test.com'); + }); + }); + + it('should return 400 for invalid email', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'invalid-email' }) + .expect(400) + .expect((res) => { + expect(res.body.message).toContain('email'); + }); + }); + }); + + describe('/users/:id (GET)', () => { + it('should return 404 for non-existent user', () => { + return request(app.getHttpServer()) + .get('/users/non-existent-id') + .expect(404); + }); + }); +}); + +// Testing with authentication +describe('Protected Routes (e2e)', () => { + let app: INestApplication; + let authToken: string; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + + // Get auth token + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'test@test.com', password: 'password' }); + + authToken = loginResponse.body.accessToken; + }); + + it('should return 401 without token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .expect(401); + }); + + it('should return user profile with valid token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200) + .expect((res) => { + expect(res.body.email).toBe('test@test.com'); + }); + }); +}); + +// Database isolation for E2E tests +describe('Orders API (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '.env.test', // Test database config + }), + AppModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + dataSource = moduleFixture.get(DataSource); + await app.init(); + }); + + beforeEach(async () => { + // Clean database between tests + await dataSource.synchronize(true); + }); + + afterAll(async () => { + await dataSource.destroy(); + await app.close(); + }); +}); +``` + +Reference: [NestJS E2E Testing](https://docs.nestjs.com/fundamentals/testing#end-to-end-testing) diff --git a/.claude/skills/nestjs-best-practices/rules/test-mock-external-services.md b/.claude/skills/nestjs-best-practices/rules/test-mock-external-services.md new file mode 100644 index 0000000..e29b595 --- /dev/null +++ b/.claude/skills/nestjs-best-practices/rules/test-mock-external-services.md @@ -0,0 +1,179 @@ +--- +title: Mock External Services in Tests +impact: HIGH +impactDescription: Ensures fast, reliable, deterministic tests +tags: testing, mocking, external-services, jest +--- + +## Mock External Services in Tests + +Never call real external services (APIs, databases, message queues) in unit tests. Mock them to ensure tests are fast, deterministic, and don't incur costs. Use realistic mock data and test edge cases like timeouts and errors. + +**Incorrect (calling real APIs and databases):** + +```typescript +// Call real APIs in tests +describe('PaymentService', () => { + it('should process payment', async () => { + const service = new PaymentService(new StripeClient(realApiKey)); + // Hits real Stripe API! + const result = await service.charge('tok_visa', 1000); + // Slow, costs money, flaky + }); +}); + +// Use real database +describe('UsersService', () => { + beforeEach(async () => { + await connection.query('DELETE FROM users'); // Modifies real DB + }); + + it('should create user', async () => { + const user = await service.create({ email: 'test@test.com' }); + // Side effects on shared database + }); +}); + +// Incomplete mocks +const mockHttpService = { + get: jest.fn().mockResolvedValue({ data: {} }), + // Missing error scenarios, missing other methods +}; +``` + +**Correct (mock all external dependencies):** + +```typescript +// Mock HTTP service properly +describe('WeatherService', () => { + let service: WeatherService; + let httpService: jest.Mocked; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + WeatherService, + { + provide: HttpService, + useValue: { + get: jest.fn(), + post: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(WeatherService); + httpService = module.get(HttpService); + }); + + it('should return weather data', async () => { + const mockResponse = { + data: { temperature: 72, humidity: 45 }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + + httpService.get.mockReturnValue(of(mockResponse)); + + const result = await service.getWeather('NYC'); + + expect(result).toEqual({ temperature: 72, humidity: 45 }); + }); + + it('should handle API timeout', async () => { + httpService.get.mockReturnValue( + throwError(() => new Error('ETIMEDOUT')), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable'); + }); + + it('should handle rate limiting', async () => { + httpService.get.mockReturnValue( + throwError(() => ({ + response: { status: 429, data: { message: 'Rate limited' } }, + })), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException); + }); +}); + +// Mock repository instead of database +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked>; + + beforeEach(async () => { + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: getRepositoryToken(User), useValue: mockRepo }, + ], + }).compile(); + + service = module.get(UsersService); + repo = module.get(getRepositoryToken(User)); + }); + + it('should find user by id', async () => { + const mockUser = { id: '1', name: 'John', email: 'john@test.com' }; + repo.findOne.mockResolvedValue(mockUser); + + const result = await service.findById('1'); + + expect(result).toEqual(mockUser); + expect(repo.findOne).toHaveBeenCalledWith({ where: { id: '1' } }); + }); +}); + +// Create mock factory for complex SDKs +function createMockStripe(): jest.Mocked { + return { + paymentIntents: { + create: jest.fn(), + retrieve: jest.fn(), + confirm: jest.fn(), + cancel: jest.fn(), + }, + customers: { + create: jest.fn(), + retrieve: jest.fn(), + }, + } as any; +} + +// Mock time for time-dependent tests +describe('TokenService', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should expire token after 1 hour', async () => { + const token = await service.createToken(); + + // Fast-forward time + jest.advanceTimersByTime(61 * 60 * 1000); + + expect(await service.isValid(token)).toBe(false); + }); +}); +``` + +Reference: [Jest Mocking](https://jestjs.io/docs/mock-functions) diff --git a/.claude/skills/nestjs-best-practices/rules/test-use-testing-module.md b/.claude/skills/nestjs-best-practices/rules/test-use-testing-module.md new file mode 100644 index 0000000..de256f5 --- /dev/null +++ b/.claude/skills/nestjs-best-practices/rules/test-use-testing-module.md @@ -0,0 +1,153 @@ +--- +title: Use Testing Module for Unit Tests +impact: HIGH +impactDescription: Enables proper isolated testing with mocked dependencies +tags: testing, unit-tests, mocking, jest +--- + +## Use Testing Module for Unit Tests + +Use `@nestjs/testing` module to create isolated test environments with mocked dependencies. This ensures your tests run fast, don't depend on external services, and properly test your business logic in isolation. + +**Incorrect (manual instantiation bypassing DI):** + +```typescript +// Instantiate services manually without DI +describe('UsersService', () => { + it('should create user', async () => { + // Manual instantiation bypasses DI + const repo = new UserRepository(); // Real repo! + const service = new UsersService(repo); + + const user = await service.create({ name: 'Test' }); + // This hits the real database! + }); +}); + +// Test implementation details +describe('UsersController', () => { + it('should call service', async () => { + const service = { create: jest.fn() }; + const controller = new UsersController(service as any); + + await controller.create({ name: 'Test' }); + + expect(service.create).toHaveBeenCalled(); // Tests implementation, not behavior + }); +}); +``` + +**Correct (use Test.createTestingModule with mocked dependencies):** + +```typescript +// Use Test.createTestingModule for proper DI +import { Test, TestingModule } from '@nestjs/testing'; + +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UserRepository, + useValue: { + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UsersService); + repo = module.get(UserRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should save and return user', async () => { + const dto = { name: 'John', email: 'john@test.com' }; + const expectedUser = { id: '1', ...dto }; + + repo.save.mockResolvedValue(expectedUser); + + const result = await service.create(dto); + + expect(result).toEqual(expectedUser); + expect(repo.save).toHaveBeenCalledWith(dto); + }); + + it('should throw on duplicate email', async () => { + repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' }); + + await expect( + service.create({ name: 'Test', email: 'test@test.com' }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('findById', () => { + it('should return user when found', async () => { + const user = { id: '1', name: 'John' }; + repo.findOne.mockResolvedValue(user); + + const result = await service.findById('1'); + + expect(result).toEqual(user); + }); + + it('should throw NotFoundException when not found', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findById('999')).rejects.toThrow(NotFoundException); + }); + }); +}); + +// Testing guards and interceptors +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [RolesGuard, Reflector], + }).compile(); + + guard = module.get(RolesGuard); + reflector = module.get(Reflector); + }); + + it('should allow when no roles required', () => { + const context = createMockExecutionContext({ user: { roles: [] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should allow admin for admin-only route', () => { + const context = createMockExecutionContext({ user: { roles: ['admin'] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']); + + expect(guard.canActivate(context)).toBe(true); + }); +}); + +function createMockExecutionContext(request: Partial): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as ExecutionContext; +} +``` + +Reference: [NestJS Testing](https://docs.nestjs.com/fundamentals/testing) diff --git a/.claude/skills/next-best-practices/SKILL.md b/.claude/skills/next-best-practices/SKILL.md new file mode 100644 index 0000000..97f5a65 --- /dev/null +++ b/.claude/skills/next-best-practices/SKILL.md @@ -0,0 +1,151 @@ +--- +name: next-best-practices +description: Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling. Apply these rules when writing or reviewing Next.js code. +user-invocable: false +--- + +# Next.js Best Practices + +## File Conventions + +See [file-conventions.md](./file-conventions.md) for: +- Project structure and special files +- Route segments (dynamic, catch-all, groups) +- Parallel and intercepting routes +- Middleware rename in v16 (middleware → proxy) + +## RSC Boundaries + +Detect invalid React Server Component patterns. + +See [rsc-boundaries.md](./rsc-boundaries.md) for: +- Async client component detection (invalid) +- Non-serializable props detection +- Server Action exceptions + +## Async Patterns + +Next.js 15+ async API changes. + +See [async-patterns.md](./async-patterns.md) for: +- Async `params` and `searchParams` +- Async `cookies()` and `headers()` +- Migration codemod + +## Runtime Selection + +See [runtime-selection.md](./runtime-selection.md) for: +- Default to Node.js runtime +- When Edge runtime is appropriate + +## Directives + +See [directives.md](./directives.md) for: +- `'use client'`, `'use server'` (React) +- `'use cache'` (Next.js) + +## Functions + +See [functions.md](./functions.md) for: +- Navigation hooks: `useRouter`, `usePathname`, `useSearchParams`, `useParams` +- Server functions: `cookies`, `headers`, `draftMode`, `after` +- Generate functions: `generateStaticParams`, `generateMetadata` + +## Error Handling + +See [error-handling.md](./error-handling.md) for: +- `error.tsx`, `global-error.tsx`, `not-found.tsx` +- `redirect`, `permanentRedirect`, `notFound` +- `forbidden`, `unauthorized` (auth errors) +- `unstable_rethrow` for catch blocks + +## Data Patterns + +See [data-patterns.md](./data-patterns.md) for: +- Server Components vs Server Actions vs Route Handlers +- Avoiding data waterfalls (`Promise.all`, Suspense, preload) +- Client component data fetching + +## Route Handlers + +See [route-handlers.md](./route-handlers.md) for: +- `route.ts` basics +- GET handler conflicts with `page.tsx` +- Environment behavior (no React DOM) +- When to use vs Server Actions + +## Metadata & OG Images + +See [metadata.md](./metadata.md) for: +- Static and dynamic metadata +- `generateMetadata` function +- OG image generation with `next/og` +- File-based metadata conventions + +## Image Optimization + +See [image.md](./image.md) for: +- Always use `next/image` over `` +- Remote images configuration +- Responsive `sizes` attribute +- Blur placeholders +- Priority loading for LCP + +## Font Optimization + +See [font.md](./font.md) for: +- `next/font` setup +- Google Fonts, local fonts +- Tailwind CSS integration +- Preloading subsets + +## Bundling + +See [bundling.md](./bundling.md) for: +- Server-incompatible packages +- CSS imports (not link tags) +- Polyfills (already included) +- ESM/CommonJS issues +- Bundle analysis + +## Scripts + +See [scripts.md](./scripts.md) for: +- `next/script` vs native script tags +- Inline scripts need `id` +- Loading strategies +- Google Analytics with `@next/third-parties` + +## Hydration Errors + +See [hydration-error.md](./hydration-error.md) for: +- Common causes (browser APIs, dates, invalid HTML) +- Debugging with error overlay +- Fixes for each cause + +## Suspense Boundaries + +See [suspense-boundaries.md](./suspense-boundaries.md) for: +- CSR bailout with `useSearchParams` and `usePathname` +- Which hooks require Suspense boundaries + +## Parallel & Intercepting Routes + +See [parallel-routes.md](./parallel-routes.md) for: +- Modal patterns with `@slot` and `(.)` interceptors +- `default.tsx` for fallbacks +- Closing modals correctly with `router.back()` + +## Self-Hosting + +See [self-hosting.md](./self-hosting.md) for: +- `output: 'standalone'` for Docker +- Cache handlers for multi-instance ISR +- What works vs needs extra setup + +## Debug Tricks + +See [debug-tricks.md](./debug-tricks.md) for: +- MCP endpoint for AI-assisted debugging +- Rebuild specific routes with `--debug-build-paths` + diff --git a/.claude/skills/next-best-practices/async-patterns.md b/.claude/skills/next-best-practices/async-patterns.md new file mode 100644 index 0000000..dce8d8c --- /dev/null +++ b/.claude/skills/next-best-practices/async-patterns.md @@ -0,0 +1,87 @@ +# Async Patterns + +In Next.js 15+, `params`, `searchParams`, `cookies()`, and `headers()` are asynchronous. + +## Async Params and SearchParams + +Always type them as `Promise<...>` and await them. + +### Pages and Layouts + +```tsx +type Props = { params: Promise<{ slug: string }> } + +export default async function Page({ params }: Props) { + const { slug } = await params +} +``` + +### Route Handlers + +```tsx +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params +} +``` + +### SearchParams + +```tsx +type Props = { + params: Promise<{ slug: string }> + searchParams: Promise<{ query?: string }> +} + +export default async function Page({ params, searchParams }: Props) { + const { slug } = await params + const { query } = await searchParams +} +``` + +### Synchronous Components + +Use `React.use()` for non-async components: + +```tsx +import { use } from 'react' + +type Props = { params: Promise<{ slug: string }> } + +export default function Page({ params }: Props) { + const { slug } = use(params) +} +``` + +### generateMetadata + +```tsx +type Props = { params: Promise<{ slug: string }> } + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params + return { title: slug } +} +``` + +## Async Cookies and Headers + +```tsx +import { cookies, headers } from 'next/headers' + +export default async function Page() { + const cookieStore = await cookies() + const headersList = await headers() + + const theme = cookieStore.get('theme') + const userAgent = headersList.get('user-agent') +} +``` + +## Migration Codemod + +```bash +npx @next/codemod@latest next-async-request-api . +``` diff --git a/.claude/skills/next-best-practices/bundling.md b/.claude/skills/next-best-practices/bundling.md new file mode 100644 index 0000000..ac5e814 --- /dev/null +++ b/.claude/skills/next-best-practices/bundling.md @@ -0,0 +1,180 @@ +# Bundling + +Fix common bundling issues with third-party packages. + +## Server-Incompatible Packages + +Some packages use browser APIs (`window`, `document`, `localStorage`) and fail in Server Components. + +### Error Signs + +``` +ReferenceError: window is not defined +ReferenceError: document is not defined +ReferenceError: localStorage is not defined +Module not found: Can't resolve 'fs' +``` + +### Solution 1: Mark as Client-Only + +If the package is only needed on client: + +```tsx +// Bad: Fails - package uses window +import SomeChart from 'some-chart-library' + +export default function Page() { + return +} + +// Good: Use dynamic import with ssr: false +import dynamic from 'next/dynamic' + +const SomeChart = dynamic(() => import('some-chart-library'), { + ssr: false, +}) + +export default function Page() { + return +} +``` + +### Solution 2: Externalize from Server Bundle + +For packages that should run on server but have bundling issues: + +```js +// next.config.js +module.exports = { + serverExternalPackages: ['problematic-package'], +} +``` + +Use this for: +- Packages with native bindings (sharp, bcrypt) +- Packages that don't bundle well (some ORMs) +- Packages with circular dependencies + +### Solution 3: Client Component Wrapper + +Wrap the entire usage in a client component: + +```tsx +// components/ChartWrapper.tsx +'use client' + +import { Chart } from 'chart-library' + +export function ChartWrapper(props) { + return +} + +// app/page.tsx (server component) +import { ChartWrapper } from '@/components/ChartWrapper' + +export default function Page() { + return +} +``` + +## CSS Imports + +Import CSS files instead of using `` tags. Next.js handles bundling and optimization. + +```tsx +// Bad: Manual link tag + + +// Good: Import CSS +import './styles.css' + +// Good: CSS Modules +import styles from './Button.module.css' +``` + +## Polyfills + +Next.js includes common polyfills automatically. Don't load redundant ones from polyfill.io or similar CDNs. + +Already included: `Array.from`, `Object.assign`, `Promise`, `fetch`, `Map`, `Set`, `Symbol`, `URLSearchParams`, and 50+ others. + +```tsx +// Bad: Redundant polyfills + + +// Good: Next.js Script component +import Script from 'next/script' + + +``` + +## Don't Put Script in Head + +`next/script` should not be placed inside `next/head`. It handles its own positioning. + +```tsx +// Bad: Script inside Head +import Head from 'next/head' +import Script from 'next/script' + + + + +// Good: Next.js component +import { GoogleAnalytics } from '@next/third-parties/google' + +export default function Layout({ children }) { + return ( + + {children} + + + ) +} +``` + +## Google Tag Manager + +```tsx +import { GoogleTagManager } from '@next/third-parties/google' + +export default function Layout({ children }) { + return ( + + + {children} + + ) +} +``` + +## Other Third-Party Scripts + +```tsx +// YouTube embed +import { YouTubeEmbed } from '@next/third-parties/google' + + + +// Google Maps +import { GoogleMapsEmbed } from '@next/third-parties/google' + + +``` + +## Quick Reference + +| Pattern | Issue | Fix | +|---------|-------|-----| +| `'), + routes: routeChanges(snapshot({}), snapshot({ '/v1/items/{id}/copies': COPY_ROUTE })), + changelog: 'endpoint added POST /v1/items/{id}/copies\n', + schemaDiff: SCHEMA_DIFF, + docsDiff: DOCS_DIFF, + message: MESSAGE, +}; + +test('monta a página com as seis abas, cada uma com o conteúdo da sua fonte @LUI-173-CA1', () => { + const html = build(SOURCES); + + // As seis abas, nesta ordem, cada uma com o seu painel. + const labels = [...html.matchAll(/