agent-engineering-skills 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +249 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/bin/cli.js +223 -0
  5. package/docs/agent-integration.md +200 -0
  6. package/docs/philosophy.md +131 -0
  7. package/docs/reference-authoring.md +117 -0
  8. package/docs/skill-authoring.md +126 -0
  9. package/examples/authorization-bypass.md +191 -0
  10. package/examples/frontend-review.md +244 -0
  11. package/examples/race-condition.md +128 -0
  12. package/examples/xp-reward-loop.md +123 -0
  13. package/package.json +45 -0
  14. package/references/engineering.yaml +88 -0
  15. package/references/frontend.yaml +139 -0
  16. package/references/product.yaml +54 -0
  17. package/references/research.yaml +88 -0
  18. package/references/security.yaml +85 -0
  19. package/references/ux.yaml +37 -0
  20. package/scripts/validate.py +454 -0
  21. package/skills/audit/adversarial-review/SKILL.md +190 -0
  22. package/skills/audit/business-logic-audit/SKILL.md +182 -0
  23. package/skills/audit/edge-case-hunter/SKILL.md +159 -0
  24. package/skills/audit/error-flow-audit/SKILL.md +184 -0
  25. package/skills/audit/state-consistency-audit/SKILL.md +174 -0
  26. package/skills/audit/user-flow-audit/SKILL.md +161 -0
  27. package/skills/frontend/accessibility-review/SKILL.md +186 -0
  28. package/skills/frontend/animation-review/SKILL.md +171 -0
  29. package/skills/frontend/interaction-design/SKILL.md +162 -0
  30. package/skills/frontend/ux-review/SKILL.md +172 -0
  31. package/skills/frontend/visual-quality-review/SKILL.md +160 -0
  32. package/skills/meta/research-router/SKILL.md +184 -0
  33. package/skills/meta/skill-router/SKILL.md +206 -0
  34. package/skills/product/gamification-audit/SKILL.md +213 -0
  35. package/skills/reliability/data-integrity-audit/SKILL.md +187 -0
  36. package/skills/reliability/idempotency-audit/SKILL.md +191 -0
  37. package/skills/reliability/race-condition-hunter/SKILL.md +181 -0
  38. package/skills/research/github-reference-research/SKILL.md +197 -0
  39. package/skills/research/implementation-research/SKILL.md +181 -0
  40. package/skills/research/market-research/SKILL.md +202 -0
  41. package/skills/research/reference-research/SKILL.md +186 -0
  42. package/skills/security/api-abuse-audit/SKILL.md +178 -0
  43. package/skills/security/authorization-audit/SKILL.md +176 -0
  44. package/skills/security/input-trust-audit/SKILL.md +178 -0
  45. package/templates/audit-report.md +89 -0
  46. package/templates/bug-report.md +107 -0
  47. package/templates/design-review.md +122 -0
  48. package/templates/research-report.md +96 -0
@@ -0,0 +1,191 @@
1
+ # Example — Authorization Bypass (IDOR + Vertical Escalation)
2
+
3
+ *Demonstração concreta de uma auditoria de autorização. Ilustra:
4
+ `authorization-audit` + `api-abuse-audit` + `input-trust-audit`. Modelo: autenticado ≠
5
+ autorizado (ver `plan.md` §20).*
6
+
7
+ ---
8
+
9
+ ## Target
10
+
11
+ Um sistema com recursos de usuário (pedidos, documentos, perfil) e endpoints de
12
+ moderação (banir usuário, deletar post). Os endpoints exigem autenticação, mas a
13
+ autorização (ownership / role) é inconsistente.
14
+
15
+ ## Hypotheses
16
+
17
+ ```text
18
+ GET /resource/123
19
+
20
+ authenticated ≠ authorized
21
+ ```
22
+
23
+ - **IDOR (horizontal):** usuário A acessa pedido de usuário B apenas trocando o ID.
24
+ - **Vertical escalation:** usuário comum chama endpoint de admin/moderator.
25
+ - **Role from client:** o payload do request contém `role` que o servidor confia.
26
+
27
+ ---
28
+ ## Finding 1 — IDOR: GET /order/{id}
29
+
30
+ ### Reproduction
31
+
32
+ ```http
33
+ POST /auth/login {email: "userA@example.com", password: "..."}
34
+ → token: eyJ...
35
+ ```
36
+
37
+ ```http
38
+ GET /api/orders/1000 (pedido do userA)
39
+ Authorization: Bearer eyJ...
40
+ → 200 { id: 1000, userId: "userA", items: [...], total: 250 }
41
+ ```
42
+
43
+ ```http
44
+ GET /api/orders/1001 (tentativa — pedido do userB)
45
+ Authorization: Bearer eyJ...
46
+ → 200 { id: 1001, userId: "userB", items: [...], total: 500 }
47
+ ```
48
+
49
+ **Resultado:** servidor retornou o pedido `1001` de outro usuário. A única checagem é
50
+ autenticação (token válido). **Não há checagem de ownership.**
51
+
52
+ ### Root cause
53
+
54
+ ```javascript
55
+ // router.get('/api/orders/:id', auth, async (req, res) => {
56
+ // const order = await Order.findById(req.params.id); // ← sem .where({ userId: ... })
57
+ // res.json(order);
58
+ // });
59
+ ```
60
+
61
+ O middleware `auth` só valida o token (quem é). O handler não adiciona
62
+ `req.user.id` ao filtro.
63
+
64
+ ### Recommendation
65
+
66
+ ```javascript
67
+ router.get('/api/orders/:id', auth, async (req, res) => {
68
+ const order = await Order.findOne({
69
+ _id: req.params.id,
70
+ userId: req.user.id, // ← ownership check
71
+ });
72
+ if (!order) return res.status(403).json({ error: 'Forbidden' });
73
+ res.json(order);
74
+ });
75
+ ```
76
+
77
+ ---
78
+ ## Finding 2 — Vertical escalation: POST /admin/ban
79
+
80
+ ### Reproduction
81
+
82
+ ```http
83
+ POST /api/admin/ban
84
+ Authorization: Bearer eyJ... (token de user comum)
85
+ Content-Type: application/json
86
+
87
+ { "userId": "userC", "reason": "spam" }
88
+ ```
89
+
90
+ ```http
91
+ → 200 { ok: true }
92
+ ```
93
+
94
+ **Resultado:** usuário comum banou outro usuário. Nenhuma checagem de `role` no
95
+ handler.
96
+
97
+ ### Root cause
98
+
99
+ ```javascript
100
+ // router.post('/api/admin/ban', auth, async (req, res) => {
101
+ // await User.updateOne({ _id: req.body.userId }, { banned: true });
102
+ // res.json({ ok: true });
103
+ // });
104
+ ```
105
+
106
+ O middleware `auth` só valida o token. O prefixo `/admin` na rota é só convenção de
107
+ nomenclatura — não há middleware de RBAC separado.
108
+
109
+ ### Recommendation
110
+
111
+ ```javascript
112
+ // middleware de role
113
+ const requireRole = (...roles) => (req, res, next) => {
114
+ if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Forbidden' });
115
+ next();
116
+ };
117
+
118
+ router.post('/api/admin/ban', auth, requireRole('admin'), async (req, res) => {
119
+ // ...
120
+ });
121
+ ```
122
+
123
+ ---
124
+ ## Finding 3 — Role from client (mass assignment)
125
+
126
+ ### Reproduction
127
+
128
+ ```http
129
+ PUT /api/profile
130
+ Authorization: Bearer eyJ...
131
+ Content-Type: application/json
132
+
133
+ { "bio": "new bio", "role": "admin" }
134
+ ```
135
+
136
+ ```http
137
+ → 200 { bio: "new bio", role: "admin" }
138
+ ```
139
+
140
+ **Resultado:** o campo `role` foi aceito do body e gravado.
141
+
142
+ ### Root cause
143
+
144
+ ```javascript
145
+ // router.put('/api/profile', auth, async (req, res) => {
146
+ // const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true });
147
+ // res.json(user);
148
+ // });
149
+ ```
150
+
151
+ O bind de `req.body` é genérico. O modelo `User` tem o campo `role`, então o `$set`
152
+ aceita qualquer valor.
153
+
154
+ ### Recommendation
155
+
156
+ ```javascript
157
+ // Allowlist de campos atualizáveis
158
+ const ALLOWED_FIELDS = ['bio', 'displayName', 'avatar'];
159
+
160
+ router.put('/api/profile', auth, async (req, res) => {
161
+ const updates = {};
162
+ for (const field of ALLOWED_FIELDS) {
163
+ if (req.body[field] !== undefined) updates[field] = req.body[field];
164
+ }
165
+ const user = await User.findByIdAndUpdate(req.user.id, updates, { new: true });
166
+ res.json(user);
167
+ });
168
+ ```
169
+
170
+ ---
171
+ ## Summary
172
+
173
+ | # | Finding | Type | Severity | Confidence |
174
+ |---|---|---|---|---|
175
+ | 1 | IDOR horizontal — `GET /order/{id}` sem ownership check | authorization | Critical | CONFIRMED |
176
+ | 2 | Vertical escalation — `POST /admin/ban` sem role check | authorization | Critical | CONFIRMED |
177
+ | 3 | Mass assignment — `role` aceito do body | input-trust | High | CONFIRMED |
178
+
179
+ ## Matriz de autorização
180
+
181
+ | Recurso × Ação | Papel exigido | Onde imposto | ✓/✗ |
182
+ |---|---|---|---|
183
+ | order × read | owner | middleware `auth` só | ✗ |
184
+ | order × delete | owner | middleware `auth` só | ✗ |
185
+ | order × update | owner | middleware `auth` só | ✗ |
186
+ | /admin/ban | admin | handler não checa | ✗ |
187
+ | /admin/delete-post | moderator/admin | handler não checa | ✗ |
188
+ | profile × update | owner | handler não checa role | ✓ (userId do token) |
189
+ | profile × role | N/A (nunca do body) | `req.body` direto → | ✗ |
190
+
191
+ *Ver skills: `authorization-audit`, `api-abuse-audit`, `input-trust-audit`.*
@@ -0,0 +1,244 @@
1
+ # Example — Frontend Review of a Character Creation Screen
2
+
3
+ *Demonstração concreta de uma revisão de frontend integrada. Ilustra: `ux-review` +
4
+ `visual-quality-review` + `interaction-design` + `animation-review` +
5
+ `accessibility-review` + `reference-research` + `market-research` (ver `plan.md`
6
+ §20). Usa o template `design-review.md`.*
7
+
8
+ ---
9
+
10
+ # Design Review — Character Creation Screen
11
+
12
+ **Date:** 2026-08-28
13
+ **Target:** Screen "Criação de Personagem" (multi-step wizard: class → attributes →
14
+ appearance → confirm)
15
+ **Skills used:** ux-review, visual-quality-review, interaction-design, animation-review,
16
+ accessibility-review, reference-research, market-research
17
+ **References consulted:** Laws of UX (methodology), Impeccable (heuristic), Interfaces
18
+ (heuristic), Dribbble (inspiration)
19
+
20
+ ## Executive summary
21
+
22
+ O fluxo de 4 passos funciona, mas tem três problemas sérios: (1) não há feedback de
23
+ progresso nem caminho de volta claro no passo 3 (estado de confusão); (2) contraste
24
+ insuficiente em texto de helper e estado focado removido — falha WCAG; (3) animação de
25
+ transição entre passos é lenta (500ms) e não respeita reduced motion. Correção
26
+ prioritária: acessibilidade e feedback.
27
+
28
+ ## Verdict
29
+
30
+ **Fix first**
31
+
32
+ ## Dimensions
33
+
34
+ | Dimension | Verdict | Findings |
35
+ |---|---|---|
36
+ | UX | ⚠️ | #1, #2, #3 |
37
+ | Visual | ✅ | — |
38
+ | Interaction | ⚠️ | #4, #5 |
39
+ | Animation | ⚠️ | #6 |
40
+ | Accessibility | ❌ | #7, #8 |
41
+
42
+ ---
43
+
44
+ ## Findings
45
+
46
+ ### Finding 1 — Sem feedback de progresso
47
+
48
+ | Field | Value |
49
+ |---|---|
50
+ | **Severity** | Medium |
51
+ | **Confidence** | CONFIRMED |
52
+ | **Dimension** | UX |
53
+ | **Affected component** | Wizard — passo 1-4 |
54
+ | **Affected flow** | criação de personagem |
55
+ | **Reproduction** | No passo 2, não há indicação de "passo X de 4"; usuário não sabe se vai continuar |
56
+ | **Expected behavior** | Barra de progresso / stepper visível com estado atual |
57
+ | **Actual behavior** | Apenas título do passo, sem contexto da posição no fluxo |
58
+ | **Root cause** | Layout não inclui elemento de progresso (viola princípio de feedback/estado visível — Laws of UX, "Feedback") |
59
+ | **Impact** | Usuário abandona ou clica "next" sem entender a jornada; retorno ao passo anterior confuso |
60
+ | **Recommendation** | Adicionar stepper de 4 etapas com estado atual destacado |
61
+
62
+ ### Finding 2 — Empty state do atributo não orienta
63
+
64
+ | Field | Value |
65
+ |---|---|
66
+ | **Severity** | Medium |
67
+ | **Confidence** | CONFIRMED |
68
+ | **Dimension** | UX |
69
+ | **Affected component** | Passo 2 (atributos) — quando nenhum ponto distribuído |
70
+ | **Reproduction** | 0 pontos distribuídos → área vazia sem instrução |
71
+ | **Expected behavior** | Estado vazio guiado ("Distribua seus 10 pontos") |
72
+ | **Actual behavior** | Área em branco |
73
+ | **Root cause** | Empty state não implementado (princípio de empty states) |
74
+ | **Impact** | Usuário não sabe o que fazer a seguir |
75
+ | **Recommendation** | Adicionar empty state com instrução + CTA |
76
+
77
+ ### Finding 3 — Caminho de volta inconsistente
78
+
79
+ | Field | Value |
80
+ |---|---|
81
+ | **Severity** | Low |
82
+ | **Confidence** | HIGH CONFIDENCE |
83
+ | **Dimension** | UX |
84
+ | **Affected component** | Passo 3 (appearance) — sem "back" |
85
+ | **Reproduction** | No passo 3, não há botão voltar (presente nos outros passos) |
86
+ | **Expected behavior** | Navegação consistente entre passos |
87
+ | **Actual behavior** | Passo 3 órfão — usuário precisa fechar e recomeçar |
88
+ | **Root cause** | Botão back omitido no passo 3 (consistência de navegação violada) |
89
+ | **Impact** | Usuário preso em um estado intermediário |
90
+ | **Recommendation** | Adicionar back button consistente em todos os passos |
91
+
92
+ ### Finding 4 — Pressed state ausente
93
+
94
+ | Field | Value |
95
+ |---|---|
96
+ | **Severity** | Low |
97
+ | **Confidence** | HIGH CONFIDENCE |
98
+ | **Dimension** | Interaction |
99
+ | **Affected component** | Botão "Next" |
100
+ | **Reproduction** | Clicar e segurar no botão — nenhuma mudança visual |
101
+ | **Expected behavior** | Escurecer/efeito de pressionamento |
102
+ | **Actual behavior** | Nada muda até o release |
103
+ | **Root cause** | `:active` não estilizado |
104
+ | **Impact** | Usuário não sabe se o clique foi registrado |
105
+ | **Recommendation** | Estilizar `:active` (scale/color) |
106
+
107
+ ### Finding 5 — Loading de "save" congelado
108
+
109
+ | Field | Value |
110
+ |---|---|
111
+ | **Severity** | Medium |
112
+ | **Confidence** | POSSIBLE |
113
+ | **Dimension** | Interaction |
114
+ | **Affected component** | Confirmação (passo 4) — salvar personagem |
115
+ | **Reproduction** | Ao salvar, tela congela ~2s sem feedback (verificar com rede lenta) |
116
+ | **Expected behavior** | Skeleton/spinner + disable do botão |
117
+ | **Actual behavior** | Nada acontece visualmente durante o request |
118
+ | **Root cause** | Estado de loading não implementado |
119
+ | **Impact** | Duplo-submit ou abandono |
120
+ | **Recommendation** | Loading state no botão de salvar |
121
+
122
+ ### Finding 6 — Transição lenta + sem reduced motion
123
+
124
+ | Field | Value |
125
+ |---|---|
126
+ | **Severity** | Medium |
127
+ | **Confidence** | CONFIRMED |
128
+ | **Dimension** | Animation |
129
+ | **Affected component** | Transição entre passos do wizard |
130
+ | **Reproduction** | Transição de 500ms com fade — com `prefers-reduced-motion: reduce`, continua animando |
131
+ | **Expected behavior** | ≤ 200-300ms; desligada com reduced motion |
132
+ | **Actual behavior** | 500ms sempre |
133
+ | **Root cause** | Timing alto + ausência de `@media (prefers-reduced-motion)` |
134
+ | **Impact** | Sensação de lentidão; desconforto vestibular |
135
+ | **Recommendation** | Reduzir para 250ms + desligar/curtar em reduced motion |
136
+
137
+ ### Finding 7 — Contraste insuficiente (helper text)
138
+
139
+ | Field | Value |
140
+ |---|---|
141
+ | **Severity** | High |
142
+ | **Confidence** | CONFIRMED |
143
+ | **Dimension** | Accessibility |
144
+ | **Affected component** | Texto de ajuda sob campos (`#9CA3AF` em fundo branco) |
145
+ | **Reproduction** | Inspeção: contraste calculado = **2.9:1** |
146
+ | **Expected behavior** | ≥ 4.5:1 (WCAG AA) |
147
+ | **Actual behavior** | 2.9:1 |
148
+ | **Root cause** | Cor de texto gray-400 em branco |
149
+ | **Impact** | Ilegível para baixa visão |
150
+ | **Recommendation** | Usar `#6B7280` (4.6:1) ou escurecer mais |
151
+
152
+ ### Finding 8 — Focus ring removido
153
+
154
+ | Field | Value |
155
+ |---|---|
156
+ | **Severity** | High |
157
+ | **Confidence** | CONFIRMED |
158
+ | **Dimension** | Accessibility |
159
+ | **Affected component** | Todos os inputs e botões |
160
+ | **Reproduction** | `*:focus { outline: none }` sem substituto — Tab não mostra posição |
161
+ | **Expected behavior** | Focus ring visível (WCAG 2.4.7) |
162
+ | **Actual behavior** | Nenhuma indicação de foco |
163
+ | **Root cause** | `outline: none` global sem fallback |
164
+ | **Impact** | Usuários de teclado/leitores de tela perdem a posição |
165
+ | **Recommendation** | Remover `outline: none` global; adicionar focus ring visível |
166
+
167
+ ---
168
+
169
+ ## Research synthesis
170
+
171
+ ### Reference
172
+ [Impeccable]
173
+
174
+ ### Relevant Pattern
175
+ Contraste de texto legível, sistema de spacing consistente, sem decoração sem função.
176
+
177
+ ### Why It Matters
178
+ A screen atual usa cores de baixo contraste e espaçamento irregular; Impeccable é o
179
+ bar de referência.
180
+
181
+ ### Adaptation
182
+ Aplicar o sistema de 4px no spacing e revisar a paleta de texto (≥ 4.5:1).
183
+
184
+ ### Trade-offs
185
+ Requer revisão de todos os componentes visuais existentes.
186
+
187
+ ### Recommendation
188
+ Adotar o padrão de contraste do Impeccable + remover animação decorativa.
189
+
190
+ ### Reference 2
191
+ [Laws of UX — Feedback / State visibility]
192
+
193
+ ### Relevant Pattern
194
+ "Feedback: o sistema deve sempre informar o estado atual." "Visibility of system
195
+ status."
196
+
197
+ ### Why It Matters
198
+ As findings #1 e #5 são exatamente a violação deste princípio (sem progresso, sem
199
+ loading).
200
+
201
+ ### Adaptation
202
+ Adicionar stepper + loading states ao wizard.
203
+
204
+ ### Trade-offs
205
+ Nenhum — é padrão esperado.
206
+
207
+ ### Recommendation
208
+ Adotar os princípios de feedback/estado visível no fluxo inteiro.
209
+
210
+ ---
211
+
212
+ ## Accessibility checklist
213
+
214
+ | Check | Pass | Fail | N/A |
215
+ |---|---|---|---|
216
+ | Keyboard reachable | | ✗ (focus ring removido) | |
217
+ | Focus visible | | ✗ (#8) | |
218
+ | Focus trap correct in modals | | | (sem modal no fluxo) |
219
+ | Alt text on informative images | ✓ | | |
220
+ | aria-label on semantic icons | ✓ | | |
221
+ | aria-live on dynamic content | | ✗ (sem aria-live em erros) | |
222
+ | Semantic HTML | ✓ | | |
223
+ | Text contrast ≥ 4.5:1 | | ✗ (#7) | |
224
+ | Touch targets ≥ 44×44px | ✓ | | |
225
+ | `<label>` on every form input | ✓ | | |
226
+ | Errors text + aria-live | | ✗ (erro só por cor) | |
227
+ | `prefers-reduced-motion` respected | | ✗ (#6) | |
228
+
229
+ ---
230
+
231
+ ## Out of scope
232
+
233
+ - Mobile layout (não avaliado neste device).
234
+ - Validação server-side do personagem (ver skills de backend).
235
+ - Performance de assets.
236
+
237
+ ## Next steps
238
+
239
+ 1. **Fix first:** #8 (focus ring), #7 (contraste) — acessibilidade bloqueante.
240
+ 2. **Fix first:** #1 (stepper), #5 (loading) — feedback essencial.
241
+ 3. **Ship-with-fixes:** #6 (reduced motion), #4, #3, #2.
242
+
243
+ *Ver skills: `ux-review`, `visual-quality-review`, `interaction-design`,
244
+ `animation-review`, `accessibility-review`, `reference-research`, `market-research`.*
@@ -0,0 +1,128 @@
1
+ # Example — Race Condition in Balance Checkout
2
+
3
+ *Demonstração concreta de uma auditoria de race condition. Ilustra:
4
+ `race-condition-hunter` + `business-logic-audit` + `idempotency-audit` +
5
+ `data-integrity-audit`. Modelo: READ → DECISION → WRITE (ver `plan.md` §20).*
6
+
7
+ ---
8
+
9
+ ## Target
10
+
11
+ Um sistema de checkout que verifica o saldo do usuário antes de debitar:
12
+
13
+ ```text
14
+ READ balance (≥ price?)
15
+ DECIDE yes → proceed
16
+ WRITE balance = balance - price
17
+ ```
18
+
19
+ ## Hypothesis
20
+
21
+ ```text
22
+ check balance
23
+ → two requests
24
+ → both pass
25
+ → both deduct
26
+ ```
27
+
28
+ Dois requests simultâneos lêem o mesmo saldo, ambos decidem "ok", e ambos debitam —
29
+ resultando em saldo final menor que o permitido (ou negativo).
30
+
31
+ ## Mapeamento da race
32
+
33
+ ```javascript
34
+ // src/services/checkout.js
35
+ async function checkout(userId, price) {
36
+ const user = await User.findById(userId); // ← READ
37
+ if (user.balance < price) throw new Error('...'); // ← DECISION
38
+ user.balance -= price; // ← WRITE
39
+ await user.save();
40
+ await createOrder(userId, price);
41
+ }
42
+ ```
43
+
44
+ **Janela:** entre o `findById` (READ) e o `save` (WRITE), outro request pode executar
45
+ o mesmo bloco, ler o mesmo saldo, e aprovar.
46
+
47
+ ## Reprodução
48
+
49
+ Simular dois requests simultâneos contra `GET /checkout`:
50
+
51
+ | Request | Time | Ação |
52
+ |---|---|---|
53
+ | — | t0 | Saldo inicial: 100 |
54
+ | A | t1 | `findById` → balance=100 (≥ 50? yes) |
55
+ | B | t2 | `findById` → balance=100 (≥ 50? yes) ← mesma leitura, A ainda não salvou |
56
+ | A | t3 | `save` → balance=50 |
57
+ | B | t4 | `save` → balance=50 ← deveria ser 0, mas B usou a leitura de t2 |
58
+
59
+ **Resultado:** saldo final = 50 (em vez de 0). Duas compras de 50, mas o saldo só
60
+ reduziu uma vez. Se o preço fosse 60, o saldo ficaria -20 — estado impossível.
61
+
62
+ ## Evidência
63
+
64
+ ```javascript
65
+ // Reprodução conceitual — dois requests paralelos
66
+ const results = await Promise.all([
67
+ checkout(userId, 50), // request A
68
+ checkout(userId, 50), // request B
69
+ ]);
70
+ // user.balance → 50, 2 orders created → saldo deveria ser 0
71
+ ```
72
+
73
+ ## Causa raiz
74
+
75
+ O código executa um padrão **read-then-write sem atomicidade**. O MongoDB `.save()`
76
+ substitui o documento inteiro — não é um `$inc` atômico — e não há `where` condicional
77
+ que impeça a escrita se o saldo mudou.
78
+
79
+ ## Correção recomendada
80
+
81
+ ### Opção 1 — Atômico (preferida)
82
+
83
+ ```javascript
84
+ // Usa $inc atômico + condicional
85
+ const result = await User.findOneAndUpdate(
86
+ { _id: userId, balance: { $gte: price } }, // ← CAS: balance >= price
87
+ { $inc: { balance: -price } }, // ← atômico
88
+ { new: true }
89
+ );
90
+ if (!result) throw new Error('Insufficient balance');
91
+ ```
92
+
93
+ ### Opção 2 — Transação
94
+
95
+ ```javascript
96
+ const session = await mongoose.startSession();
97
+ session.startTransaction();
98
+ try {
99
+ const user = await User.findById(userId).session(session);
100
+ if (user.balance < price) throw new Error('...');
101
+ user.balance -= price;
102
+ await user.save({ session });
103
+ await createOrder(userId, price, { session });
104
+ await session.commitTransaction();
105
+ } catch {
106
+ await session.abortTransaction();
107
+ }
108
+ ```
109
+
110
+ **Nota:** transação sem `$inc` não fecha a janela de leitura se outro request lê antes
111
+ do commit. Opção 1 (CAS) é mais segura.
112
+
113
+ ### Opção 3 — Unique constraint
114
+
115
+ Se o pedido (`orderId`) for a chave de idempotência, uma unique constraint impede
116
+ duplicação — mas não impede o *segundo* débito se o saldo foi lido antes do primeiro
117
+ commit. Opção 1 é a defesa correta.
118
+
119
+ ## Checklist de aplicação
120
+
121
+ | Invariant | Defesa atual | Defesa recomendada | Prioridade |
122
+ |---|---|---|---|
123
+ | balance ≥ 0 | `save()` sem condicional | `$inc` + `$gte` (CAS) | Critical |
124
+ | saldo não negativo | nenhuma (sem CHECK) | `CHECK (balance >= 0)` no DB | High |
125
+ | order única | nenhuma | UNIQUE (order_id) | High |
126
+
127
+ *Ver skills: `race-condition-hunter`, `business-logic-audit`, `idempotency-audit`,
128
+ `data-integrity-audit`.*
@@ -0,0 +1,123 @@
1
+ # Example — XP Reward Loop Farming
2
+
3
+ *Demonstração concreta de uma auditoria de loop de recompensa. Ilustra:
4
+ `gamification-audit` + `business-logic-audit` + `idempotency-audit` +
5
+ `race-condition-hunter` + `api-abuse-audit`. Modelo: TRIGGER → CONDITION → REWARD →
6
+ REVERSAL (ver `plan.md` §20).*
7
+
8
+ ---
9
+
10
+ ## Target
11
+
12
+ Um sistema de reações sociais em que **reagir dá +10 XP** ao autor do conteúdo. O
13
+ usuário pode remover a reação (unreact).
14
+
15
+ ## Hypothesis (gerada por adversarial-review)
16
+
17
+ ```text
18
+ reaction
19
+ → XP
20
+ → remove reaction
21
+ → reaction
22
+ → XP
23
+ → infinite farming
24
+ ```
25
+
26
+ O farming acontece se a reversão (unreact) **não remove o XP concedido** — porque então
27
+ reagir de novo re-concede XP sobre um saldo que nunca voltou.
28
+
29
+ ## Loop mapeado
30
+
31
+ ```text
32
+ TRIGGER → usuário reage a um post
33
+ CONDITION → usuário não é o autor (self-reward bloqueado); usuário ainda não reagiu
34
+ REWARD → autor do post recebe +10 XP
35
+ REVERSAL → usuário remove a reação → autor perde os 10 XP?
36
+ ```
37
+
38
+ ## Investigação
39
+
40
+ ### 1. O que acontece em `unreact`?
41
+
42
+ ```http
43
+ POST /reactions/unreact {postId: 42}
44
+ ```
45
+
46
+ **Resposta:** `200 OK`. O registro da reação é removido.
47
+
48
+ **Pergunta:** o XP concedido na reação é *devolvido*?
49
+
50
+ Rastreando o handler de unreact:
51
+
52
+ ```javascript
53
+ // src/routes/reactions.js
54
+ router.post('/unreact', auth, async (req, res) => {
55
+ const { postId } = req.body;
56
+ const exists = await Reaction.findOne({ postId, userId: req.user.id });
57
+ if (!exists) return res.status(404).json({ error: 'no reaction' });
58
+
59
+ await Reaction.deleteOne({ postId, userId: req.user.id });
60
+ // ⚠️ NENHUMA chamada para devolver XP ao autor
61
+ res.json({ ok: true });
62
+ });
63
+ ```
64
+
65
+ **Evidência:** `unreact` remove a reação mas **não** reverte o `+10 XP` dado ao autor
66
+ em `react`.
67
+
68
+ ### 2. Confirmação — testar ACTION → REWARD → REVERSE → ACTION
69
+
70
+ | Ação | XP do autor |
71
+ |---|---|
72
+ | (estado inicial) | 0 |
73
+ | `react` | +10 → 10 |
74
+ | `unreact` | **10** (deveria ser 0 — a reversão não remove) |
75
+ | `react` (de novo) | +10 → **20** |
76
+
77
+ Reproduzido: o autor acumula XP indefinidamente alternando react/unreact sobre o
78
+ próprio conteúdo (ou sobre conteúdo de um parceiro).
79
+
80
+ ### 3. Vetores adicionais
81
+
82
+ - **self-reward:** `react` ao *próprio* post é permitido? O handler de `react` checa
83
+ `post.authorId !== req.user.id`? (Rastrear.)
84
+ - **concurrency:** dois `react` simultâneos — ambos passam na checagem "já reagiu?"
85
+ (read-then-write) e concedem 2× o XP? (sem unique constraint → `race-condition-hunter`.)
86
+ - **replay:** `react` com a mesma combinação duas vezes — a checagem de existência
87
+ impede? (idempotência.)
88
+
89
+ ## Findings (resumo)
90
+
91
+ | # | Severity | Confidence | Finding |
92
+ |---|---|---|---|
93
+ | 1 | High | CONFIRMED | Farming infinito: `unreact` não reverte o XP; `react`+`unreact` repetidos acumulam XP sem limite |
94
+ | 2 | High | POSSIBLE | Self-reward possivelmente permitido (handler de `react` não checa `authorId` vs `userId`) |
95
+ | 3 | Medium | HIGH CONFIDENCE | Race no dedup de reação: sem unique constraint, dois `react` simultâneos concedem XP 2× |
96
+
97
+ ## Causa raiz
98
+
99
+ A **REVERSAL do loop é incompleta**: a ação que concede XP (`react`) e a que deveria
100
+ removê-lo (`unreact`) não são transações acopladas. O XP é um efeito colateral do
101
+ `react` sem o inverso no `unreact`. Falta também a constraint que faz da reação
102
+ única por (user, post), e a checagem server-side de self-reward.
103
+
104
+ ## Correção recomendada
105
+
106
+ 1. **Reversão completa** — `unreact` deve executar a dedução dos 10 XP do autor, de
107
+ forma atômica (mesma transação que remove a reação).
108
+ 2. **Unique constraint** — `UNIQUE (user_id, post_id)` na tabela de reações. Impede
109
+ reação duplicada (fecha race e replay).
110
+ 3. **Self-reward server-side** — `react` rejeita quando `post.authorId === userId`.
111
+ Não confiar na UI.
112
+
113
+ ```sql
114
+ -- migração
115
+ ALTER TABLE reactions ADD CONSTRAINT uq_reaction UNIQUE (user_id, post_id);
116
+ CREATE OR REPLACE FUNCTION grant_xp(author_id INT, delta INT)
117
+ RETURNS void AS $$
118
+ UPDATE users SET xp = GREATEST(xp + delta, 0) WHERE id = author_id;
119
+ $$ LANGUAGE sql;
120
+ ```
121
+
122
+ *Ver skills: `gamification-audit`, `business-logic-audit`, `idempotency-audit`,
123
+ `race-condition-hunter`, `api-abuse-audit`, `adversarial-review`.*