feat(core): Phase 1.5 Final Production Gate - Idempotency hardening with canonical request fingerprinting, secure client IP resolver with trusted proxy validation, proactive rate limit & idempotency bucket eviction, strict winner count contract (no silent under-delivery), DRAW_ALREADY_COMPLETED terminal error, 100-concurrency draw regression test, and PRODUCTION_GUARDS documentation
This commit is contained in:
parent
3f795e23fe
commit
a27341a302
23 changed files with 2139 additions and 79 deletions
93
docs/PRODUCTION_GUARDS.md
Normal file
93
docs/PRODUCTION_GUARDS.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Randomayzer — Production Guards & Security Architecture
|
||||||
|
|
||||||
|
This document details the production integrity guards, concurrency safeguards, rate limiting, and idempotency semantics implemented in **Randomayzer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Idempotency Hardening & Request Fingerprinting
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
The API supports the standard `Idempotency-Key` HTTP header on state-mutating endpoints:
|
||||||
|
- `POST /api/giveaways` (Giveaway creation)
|
||||||
|
- `POST /api/giveaways/[id]/participants` (Participant import & enrichment)
|
||||||
|
- `POST /api/giveaways/[id]/snapshot` (Participant snapshot locking)
|
||||||
|
|
||||||
|
### Semantics
|
||||||
|
1. **Scoped Storage Key**:
|
||||||
|
Keys are composite and scoped by `operation`, `giveawayId`, and `idempotencyKey`:
|
||||||
|
`format: ${operation}:${giveawayId || 'global'}:${key}`
|
||||||
|
This prevents cross-endpoint and cross-giveaway key collision.
|
||||||
|
2. **Request Fingerprinting**:
|
||||||
|
Every request computes a canonical SHA-256 fingerprint:
|
||||||
|
`requestFingerprint = SHA256(canonicalStringify(requestPayload))`
|
||||||
|
3. **Replay vs Conflict Handling**:
|
||||||
|
- **Same Key + Same Request**: Returns the previously cached status code and response body without re-executing.
|
||||||
|
- **Same Key + Different Request**: Throws HTTP `409 Conflict` with error code `IDEMPOTENCY_KEY_REUSED`.
|
||||||
|
4. **Key Validation & TTL**:
|
||||||
|
- Maximum key length is 128 characters (exceeding length returns `400 VALIDATION_ERROR`).
|
||||||
|
- Default TTL is 5 minutes with proactive cleanup and upper memory bounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Rate Limiting & Client Identity Resolution
|
||||||
|
|
||||||
|
### Centralized Client IP Resolution (`src/lib/client-ip.ts`)
|
||||||
|
- **Untrusted Proxy Mode (Default)**:
|
||||||
|
When `TRUST_PROXY !== 'true'`, user-supplied `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` headers are **strictly ignored** to prevent IP spoofing attacks. The direct socket connection IP is used.
|
||||||
|
- **Trusted Proxy Mode (`TRUST_PROXY=true`)**:
|
||||||
|
When deployed behind a verified reverse proxy (e.g. Nginx, Cloudflare, AWS ALB), `TRUST_PROXY=true` must be set. The resolver:
|
||||||
|
- Enforces a maximum header length of 1024 characters (oversized headers are rejected as malformed).
|
||||||
|
- Parses multi-value proxy chains (`client, proxy1, proxy2`), extracting and validating the leftmost IP.
|
||||||
|
- Normalizes IPv4, IPv6 (including IPv4-mapped IPv6 `::ffff:192.0.2.1` and loopbacks `::1`).
|
||||||
|
- Validates IP syntax against IPv4/IPv6 standards.
|
||||||
|
|
||||||
|
### Memory Limiter vs Multi-Instance Deployments
|
||||||
|
- The built-in `SlidingWindowRateLimiter` is optimized for single-instance, serverless dev, and test environments.
|
||||||
|
- In multi-instance or horizontal cluster deployments, rate limiting must be offloaded to an edge layer (e.g., Cloudflare Rate Limiting, Nginx limit_req) or a shared distributed cache (Redis/Valkey).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Winner Count Contract (Zero Under-Delivery)
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
Before conducting any draw, the system enforces:
|
||||||
|
$$\text{winnersCount} + \text{reserveWinnersCount} \le \text{eligibleParticipantsCount}$$
|
||||||
|
|
||||||
|
If the requested winners count plus reserve exceeds the locked snapshot's eligible participants:
|
||||||
|
- The system returns HTTP `400 VALIDATION_ERROR` (or `409 CONFLICT`).
|
||||||
|
- **The system NEVER silently clamps or reduces the winners count.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Draw Concurrency & Terminal Retry Contract
|
||||||
|
|
||||||
|
### Atomic Conditional Execution
|
||||||
|
- Draw execution (`POST /api/giveaways/[id]/draw`) requires the giveaway to be in `SNAPSHOT_LOCKED` status with an existing snapshot.
|
||||||
|
- The state transition from `SNAPSHOT_LOCKED` to `DRAWN` occurs atomically inside a database transaction (`updateMany({ where: { id, status: 'SNAPSHOT_LOCKED' } })`).
|
||||||
|
- In a concurrent race (e.g. 20 or 100 simultaneous draw requests), exactly **one** request acquires the lock and transitions to `DRAWN`. All competing requests receive `409 Conflict`.
|
||||||
|
|
||||||
|
### Terminal State Replay (`DRAW_ALREADY_COMPLETED`)
|
||||||
|
- Once a giveaway is in `DRAWN` or `PUBLISHED` status, subsequent draw attempts return:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "DRAW_ALREADY_COMPLETED",
|
||||||
|
"message": "Giveaway has already been drawn and finalized. Repeat draws are not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Clients and UI treat `DRAW_ALREADY_COMPLETED` as a terminal final status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Summary of Environment Variables
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `NODE_ENV` | string | `development` | Environment mode (`production`, `test`, `development`) |
|
||||||
|
| `TRUST_PROXY` | boolean (`true`/`false`) | `false` | Enable only behind trusted upstream proxies |
|
||||||
|
| `ALLOW_MEMORY_IDEMPOTENCY` | boolean | `false` | Silence production warning for memory idempotency in single-instance |
|
||||||
|
| `ALLOW_MEMORY_RATE_LIMITER` | boolean | `false` | Silence production warning for memory rate limiter in single-instance |
|
||||||
|
| `USE_VK_MOCK` | boolean | `false` | Explicitly enable VkMockProvider (forbidden in production unless true) |
|
||||||
|
| `VK_SERVICE_TOKEN` | string | `undefined` | VK Application Service Access Token |
|
||||||
183
grok_review/GROK_PHASE_1_4_STRESS.md
Normal file
183
grok_review/GROK_PHASE_1_4_STRESS.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# Randomayzer — Phase G-2 Production Hardening Stress & Adversarial Review
|
||||||
|
|
||||||
|
**Reviewer:** Grok (xAI)
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Code under test:** Phase 1.4 (`main` @ `3f795e23fe6397832147fb47285584dc0eccbf3c`)
|
||||||
|
**Constraint:** No production Core / Randomizer / AuditProof / Prisma schema / VK OAuth / UI changes. Work via analysis, tests, benchmarks, docs only.
|
||||||
|
|
||||||
|
**Related:** Phase G-1 (concurrency baseline), Antigravity Phase 1.4.1 (in progress).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Verdict
|
||||||
|
|
||||||
|
| Area | Verdict | Notes |
|
||||||
|
|---------------------|-------------------------|-------|
|
||||||
|
| Concurrency (draw) | **PASS WITH WARNINGS** | Conditional `updateMany` + unique + P2002→409 is solid on Prisma. Memory tests pass 100 concurrent. Multi-instance still relies on DB. |
|
||||||
|
| Concurrency (snapshot) | **PASS WITH WARNINGS** | Version computed inside transaction + status guard. Unique constraint + ConflictError mapping present. |
|
||||||
|
| Mixed race | **PASS WITH WARNINGS** | FSM + status guards block most residual TOCTOU windows. |
|
||||||
|
| Idempotency | **FAIL** (multi-instance / adversarial) | In-memory only, no body fingerprint, collidable keys, memory growth under unique-key flood. |
|
||||||
|
| Rate limiting | **FAIL** (bypassable) | Keyed solely on `X-Forwarded-For` (or “anonymous”). Trivially bypassed by header rotation. In-memory only. |
|
||||||
|
| API Validation | **PASS WITH WARNINGS** | Zod schemas solid. winnersCount > eligible is silently capped. |
|
||||||
|
| DB Integrity | **PASS** | Unique constraints + Restrict + transactional status transition protect core invariants. |
|
||||||
|
| Scalability of new stores | **PASS WITH WARNINGS** | 100k–200k unique keys cost tens of MB; lazy TTL only. |
|
||||||
|
| Core performance regression | **PASS** | Randomizer / hash path unchanged from G-1. |
|
||||||
|
|
||||||
|
**Overall readiness for real VK integration after Phase 1.4.1:**
|
||||||
|
**Not yet fully safe.** Draw concurrency is production-viable on a single-writer DB model. Idempotency and rate limiting must be hardened (shared store + body hash + IP normalization / edge limiter) before untrusted traffic or multi-instance deployment. See §12.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Double-Draw Stress (10 / 20 / 50 / 100)
|
||||||
|
|
||||||
|
### Implementation in Phase 1.4
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// prisma-repository.saveDrawResultAndAudit
|
||||||
|
const updatedStatus = await tx.giveaway.updateMany({
|
||||||
|
where: { id, status: 'SNAPSHOT_LOCKED' },
|
||||||
|
data: { status: 'DRAWN', drawnAt, seed },
|
||||||
|
});
|
||||||
|
if (updatedStatus.count === 0) throw new ConflictError(...);
|
||||||
|
// then create DrawResult + AuditRecord (same transaction)
|
||||||
|
// P2002 → ConflictError("already been drawn")
|
||||||
|
```
|
||||||
|
|
||||||
|
- Draw route no longer auto-creates snapshot (strict `latestSnapshot` required).
|
||||||
|
- Rate-limited per `draw-execute:${ip}:${id}`.
|
||||||
|
|
||||||
|
### Expected under stress
|
||||||
|
|
||||||
|
| Concurrent draws | Successes | 409 | 500 | DrawResult | AuditRecord | Status |
|
||||||
|
|------------------|-----------|-----|-----|------------|-------------|--------|
|
||||||
|
| 10–100 | 1 | N-1 | 0 | 1 | 1 | DRAWN |
|
||||||
|
|
||||||
|
Memory repository uses an explicit `drawLocks` Set. Tests in `tests/stress-concurrency-g2.test.ts` + existing `concurrency-draw.test.ts` cover the contract.
|
||||||
|
|
||||||
|
**Prisma path** serializes correctly on the conditional `updateMany` + unique constraint. Loser receives clean 409. No orphan DrawResult possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Snapshot Concurrency (10–50)
|
||||||
|
|
||||||
|
Version is now computed **inside** the transaction (G-1 improvement). Status guard is conditional `updateMany`. P2002 mapped to ConflictError. Multiple historical snapshots are allowed by design; failed attempts roll back. No orphan snapshots from the concurrent path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mixed Race (participants + snapshot + draw)
|
||||||
|
|
||||||
|
FSM + `assertCanModifyParticipants` / `assertCanDraw` + status-conditional updates close the dangerous windows. Once SNAPSHOT_LOCKED, participant updates are rejected. Draw never materializes a snapshot itself.
|
||||||
|
|
||||||
|
Impossible states (DRAWN without DrawResult/Audit, multiple DrawResults, DrawResult without snapshot) are prevented by transaction + unique + FK Restrict.
|
||||||
|
|
||||||
|
Re-run the mixed suite after Antigravity 1.4.1 against real Postgres under pool pressure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Idempotency Adversarial Results
|
||||||
|
|
||||||
|
**Implementation:** process-local `Map`, 5 min TTL, no body hash.
|
||||||
|
|
||||||
|
| Scenario | Result | Severity |
|
||||||
|
|----------|--------|----------|
|
||||||
|
| Same key + same body | Cached (correct) | OK |
|
||||||
|
| Same key + different body | Returns first / overwrites | **HIGH** |
|
||||||
|
| Same key across giveaways / endpoints | Collision risk | HIGH |
|
||||||
|
| Parallel identical requests | Race on set | MEDIUM |
|
||||||
|
| Flood 100k–200k unique keys | Heap +20–45 MB, no proactive cleanup | HIGH |
|
||||||
|
| Guessable / reused key → foreign response | Possible | **CRITICAL** if keys weak |
|
||||||
|
|
||||||
|
Production requires shared store + body fingerprint (or reject mismatch) + key length limit + TTL sweeper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rate Limiter Abuse
|
||||||
|
|
||||||
|
Key = `x-forwarded-for || 'anonymous'`. Synthetic: 100 requests with 50 rotating fake IPs → **100 allowed** (full bypass).
|
||||||
|
|
||||||
|
| Attack | Result | Severity |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Rotate X-Forwarded-For | Full bypass | **CRITICAL** |
|
||||||
|
| Missing header | Shared “anonymous” bucket | LOW–MED |
|
||||||
|
| Multi-value / IPv6 / huge header | No normalization | MEDIUM |
|
||||||
|
| 50k–100k unique fake IPs | Map growth, no idle GC | HIGH |
|
||||||
|
|
||||||
|
Move to edge limiter or authenticated identity + shared store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Memory of New Stores (synthetic)
|
||||||
|
|
||||||
|
| Store | Keys | Heap delta |
|
||||||
|
|-------|------|------------|
|
||||||
|
| IdempotencyStore | 100k | ~43 MB |
|
||||||
|
| IdempotencyStore | 200k | measurable |
|
||||||
|
| RateLimiter | 50k–100k | ~20–30 MB |
|
||||||
|
|
||||||
|
Lazy TTL only → unbounded growth under unique-key flood.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API Validation / Fuzz Surface
|
||||||
|
|
||||||
|
Zod (strict) covers counts, lengths, unknown fields, malformed JSON → 400.
|
||||||
|
**Gap:** `winnersCount > eligible` is silently capped by Core (`Math.min`). Prefer explicit 400/409.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. winnersCount Invariant
|
||||||
|
|
||||||
|
| eligible | winners | reserve | Core today | Preferred API |
|
||||||
|
|----------|---------|---------|------------|---------------|
|
||||||
|
| 3 | 3 | 0 | 3 | 200 |
|
||||||
|
| 3 | 3 | 3 | 3 (reserve 0) | policy |
|
||||||
|
| 3 | 4 | 0 | **3 silent** | **400/409** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. DB Invariants After Stress
|
||||||
|
|
||||||
|
Exactly one DrawResult, one AuditRecord, existing Snapshot, consistent hashes, status=DRAWN. No orphans from concurrent or crash-inside-tx paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Crash Windows
|
||||||
|
|
||||||
|
| Window | State | Safe retry |
|
||||||
|
|--------|-------|------------|
|
||||||
|
| Before tx | SNAPSHOT_LOCKED | Yes |
|
||||||
|
| Inside tx | Rolled back | Yes |
|
||||||
|
| After commit, before HTTP | DRAWN + records | Yes → 409 |
|
||||||
|
| Client disconnect after commit | Same | Clients must treat 409 as terminal |
|
||||||
|
|
||||||
|
Draw path still lacks Idempotency-Key that would return the original DrawResult on retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Performance vs G-1
|
||||||
|
|
||||||
|
Core path unchanged. G-1 100k baseline still holds. No significant regression from Zod or Map lookups under normal traffic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Recommendations (blocking before real VK)
|
||||||
|
|
||||||
|
1. Edge rate limiting **or** shared store + normalized client identity.
|
||||||
|
2. Shared IdempotencyStore + body fingerprint + proactive TTL.
|
||||||
|
3. Explicit 400/409 when `winnersCount + reserve > eligible`.
|
||||||
|
4. Clients treat 409 on draw as “already done”.
|
||||||
|
5. After 1.4.1: re-run 100-concurrent draw + mixed race on real Postgres.
|
||||||
|
|
||||||
|
**VK integration readiness:** only after rate-limit and idempotency are no longer process-local and spoofable, and winnersCount contract is explicit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Files Added by G-2
|
||||||
|
|
||||||
|
- `docs/GROK_PHASE_1_4_STRESS.md` (this file)
|
||||||
|
- `docs/PHASE_1_4_FAILURE_MATRIX.md`
|
||||||
|
- `tests/stress-concurrency-g2.test.ts`
|
||||||
|
|
||||||
|
No production source modified.
|
||||||
|
|
||||||
|
**Commit under review:** `3f795e23fe6397832147fb47285584dc0eccbf3c`
|
||||||
383
grok_review/GROK_REVIEW (1).md
Normal file
383
grok_review/GROK_REVIEW (1).md
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
# Randomayzer — Phase G-1 Concurrency, Load, Abuse & Failure-Mode Review
|
||||||
|
|
||||||
|
**Reviewer:** Grok (xAI)
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Scope:** Concurrency, load/scalability, abuse resistance, failure modes, DB invariants, observability, rate limiting, memory & algorithmic complexity.
|
||||||
|
**Out of scope (per assignment):** Public Verification Integrity (Antigravity), general QA/security review & VK Integration prep (OpenCode). No changes to Randomizer core, proof format, OAuth, UI branding, VK Provider contract, or large Prisma schema rewrites.
|
||||||
|
|
||||||
|
**Repository snapshot:** local extract of `randomayzer-main` (matches provided zip / expected main).
|
||||||
|
**Commit SHA (from zip metadata):** `26e82fcf8d8e5855ac9e46fa8af21ca7daacc36f`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| Category | Critical | High | Medium | Low |
|
||||||
|
|-----------------------|----------|------|--------|-----|
|
||||||
|
| Concurrency | 2 | 2 | 1 | 0 |
|
||||||
|
| Failure Modes | 0 | 3 | 2 | 1 |
|
||||||
|
| Abuse Resistance | 1 | 3 | 2 | 1 |
|
||||||
|
| Database Invariants | 0 | 2 | 2 | 0 |
|
||||||
|
| Scalability / Memory | 1 | 3 | 2 | 0 |
|
||||||
|
| **Total** | **4** | **13**| **9** | **2**|
|
||||||
|
|
||||||
|
**Maximum participant count synthetically exercised:** 100 000 (core hashing / Fisher-Yates / verify).
|
||||||
|
**Estimated safe production limit (current architecture, single Node process, 2–4 GB RAM):** ~30–50k eligible participants.
|
||||||
|
**Race conditions found:** Double-draw (protected by unique constraint but poor error handling), Snapshot version collision (unique constraint only), Participant-update vs Draw interleaving.
|
||||||
|
|
||||||
|
**Production Core was not modified.** Only documentation + optional offline benchmark script added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Findings
|
||||||
|
|
||||||
|
### C1. Double-Draw Race Condition (Application-level TOCTOU)
|
||||||
|
|
||||||
|
**Location:** `src/app/api/giveaways/[id]/draw/route.ts` + `PrismaGiveawayRepository.saveDrawResultAndAudit`
|
||||||
|
|
||||||
|
**Scenario:** Two almost simultaneous `POST /api/giveaways/:id/draw`.
|
||||||
|
|
||||||
|
1. Both requests read Giveaway (status = `READY` or `SNAPSHOT_LOCKED`).
|
||||||
|
2. Both pass the early `if (status === 'DRAWN')` guard.
|
||||||
|
3. Both may create / reuse snapshot.
|
||||||
|
4. Both call `executeDeterministicDrawV1` (possibly with different seeds).
|
||||||
|
5. Both enter `$transaction` and attempt `drawResult.create({ giveawayId })`.
|
||||||
|
|
||||||
|
**Protection today:**
|
||||||
|
- `DrawResult.giveawayId @unique` → second insert fails with Prisma `P2002`.
|
||||||
|
- Transaction is atomic for the successful request.
|
||||||
|
|
||||||
|
**Problems:**
|
||||||
|
- Second request receives **500 Internal Server Error** (unhandled unique violation) instead of clean 409/400 “already drawn”.
|
||||||
|
- Client may retry and keep failing.
|
||||||
|
- Work (hashing, Fisher-Yates) is wasted on the loser.
|
||||||
|
- No `SELECT … FOR UPDATE` / optimistic version / conditional `UPDATE … WHERE status = 'SNAPSHOT_LOCKED'`.
|
||||||
|
- Default Prisma isolation (Read Committed) does not prevent the race.
|
||||||
|
|
||||||
|
**Impact:** Data integrity is preserved (only one DrawResult), but availability and UX under concurrent load are broken. In a load-balanced multi-instance deployment the race window is larger.
|
||||||
|
|
||||||
|
**Recommended fix (proposal only — do not implement without agreement):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Inside transaction, use conditional update as gate
|
||||||
|
const updated = await tx.giveaway.updateMany({
|
||||||
|
where: { id, status: 'SNAPSHOT_LOCKED' },
|
||||||
|
data: { status: 'DRAWN', drawnAt: ..., seed: ... },
|
||||||
|
});
|
||||||
|
if (updated.count === 0) {
|
||||||
|
throw new Error('ALREADY_DRAWN_OR_INVALID_STATE'); // map to 409
|
||||||
|
}
|
||||||
|
// then create DrawResult + AuditRecord
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use PostgreSQL advisory lock (`pg_advisory_xact_lock(hashtext(giveawayId))`) at the start of the transaction, or a dedicated `draw_lock` row.
|
||||||
|
|
||||||
|
Optimistic locking via a `version` / `statusVersion` integer column is also viable.
|
||||||
|
|
||||||
|
### C2. Snapshot Version Race
|
||||||
|
|
||||||
|
**Location:** `PrismaGiveawayRepository.createAndLockSnapshot`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const latestVersion = current.snapshots.length > 0
|
||||||
|
? Math.max(...current.snapshots.map(s => s.version)) : 0;
|
||||||
|
const newVersion = latestVersion + 1;
|
||||||
|
// then $transaction([ create with newVersion, update status ])
|
||||||
|
```
|
||||||
|
|
||||||
|
Version is computed **outside** any lock. Two concurrent snapshot creations can choose the same `version` → unique constraint `@@unique([giveawayId, version])` rejects one with 500.
|
||||||
|
|
||||||
|
**Sufficient for data integrity?** Yes (constraint works).
|
||||||
|
**Production-safe?** No — error handling and retry semantics are missing. Status can also be left inconsistent if one succeeds and the other fails after status update.
|
||||||
|
|
||||||
|
**Proposal:** Compute next version inside a serializable transaction or use `INSERT … ON CONFLICT` / sequence / `MAX(version)+1` under `SELECT FOR UPDATE` on the Giveaway row.
|
||||||
|
|
||||||
|
### C3. Full Participant Lists Loaded into Memory on Every getById / listAll
|
||||||
|
|
||||||
|
`getGiveawayById` and `listGiveaways` always `include: { participants: true, snapshots: true }`.
|
||||||
|
|
||||||
|
For a giveaway with 100k participants the JSON response + in-memory objects easily exceed hundreds of MB. `listAll` multiplies the problem.
|
||||||
|
|
||||||
|
Combined with snapshot `eligibleParticipants Json` this is the primary OOM vector.
|
||||||
|
|
||||||
|
### C4. Snapshot Storage as Monolithic JSON
|
||||||
|
|
||||||
|
`ParticipantSnapshot.eligibleParticipants Json` stores the entire canonical array.
|
||||||
|
|
||||||
|
| Eligible count | Approx. canonical JSON size | Prisma / PG TOAST risk | Memory on read |
|
||||||
|
|----------------|-----------------------------|------------------------|----------------|
|
||||||
|
| 10k | ~1.5–2.5 MB | OK | Low |
|
||||||
|
| 50k | ~8–14 MB | Acceptable | Medium |
|
||||||
|
| 100k | ~20–30 MB | High (large object) | High |
|
||||||
|
| 500k | ~80–140 MB | Problematic | Almost certain OOM on small instances |
|
||||||
|
|
||||||
|
No chunking, compression, or external object storage. Verification and UI that re-load the snapshot will re-materialize the whole array.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High Findings
|
||||||
|
|
||||||
|
### H1. Participant Update vs Draw Race
|
||||||
|
|
||||||
|
`saveParticipants` is allowed only when status ∉ {SNAPSHOT_LOCKED, DRAWN, PUBLISHED}.
|
||||||
|
Draw can create a snapshot from the in-memory `giveaway.participants` if none exists.
|
||||||
|
|
||||||
|
If both run concurrently while status = READY:
|
||||||
|
- Draw may snapshot a partially-updated list (deleteMany has finished, createMany has not, or vice-versa).
|
||||||
|
- Or snapshot the old list while the new list is committed.
|
||||||
|
|
||||||
|
Result: Draw operates on a non-atomic participant set. Integrity of the snapshot relative to the final stored participants is not guaranteed.
|
||||||
|
|
||||||
|
**Mitigation proposal:** Always require an explicit snapshot before draw (already partially true via FSM), and make `createAndLockSnapshot` take a DB-level lock that also blocks `saveParticipants`.
|
||||||
|
|
||||||
|
### H2. No Idempotency Keys
|
||||||
|
|
||||||
|
None of the mutating endpoints accept or honour `Idempotency-Key`:
|
||||||
|
|
||||||
|
- `POST /api/giveaways` (create)
|
||||||
|
- `POST …/participants`
|
||||||
|
- `POST …/snapshot`
|
||||||
|
- `POST …/draw`
|
||||||
|
- `POST …/publish` (if exists)
|
||||||
|
|
||||||
|
For draw the unique constraint gives a crude form of “at-most-once”, but the error is not clean.
|
||||||
|
For create / participants / snapshot a client retry after a network blip can create duplicates or re-fetch VK data unnecessarily.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Draw / Publish → rely on DB state machine + unique constraints (already present) + map P2002 → 409.
|
||||||
|
- Create Giveaway, Snapshot, Participants fetch → accept optional `Idempotency-Key` header, store in a short-lived table or Redis, return the previous response on conflict.
|
||||||
|
|
||||||
|
### H3. API Response Size / DoS Surface
|
||||||
|
|
||||||
|
`POST …/participants` returns the full `allParticipants` array.
|
||||||
|
`GET /api/giveaways` returns every giveaway with every participant and every snapshot.
|
||||||
|
|
||||||
|
A malicious or naïve client can trigger multi-hundred-MB responses. Combined with lack of rate limiting this is an easy memory / bandwidth exhaustion vector.
|
||||||
|
|
||||||
|
### H4. No Request Timeouts / Cancellation on VK Fetch
|
||||||
|
|
||||||
|
`VkProvider.fetchParticipants` (and pagination) can run for a long time. There is no `AbortController`, no overall request timeout, no job queue. A slow VK response or large comment tree holds a Node request forever and can exhaust the connection pool.
|
||||||
|
|
||||||
|
**When HTTP request-response stops being suitable:**
|
||||||
|
When expected VK fetch + enrichment > 15–20 s for typical giveaways, or when 10k+ participants become common. Move to background job (BullMQ / Inngest / custom) with status polling.
|
||||||
|
|
||||||
|
### H5. Failure Modes – Partial Crash After Commit
|
||||||
|
|
||||||
|
- Crash after successful `$transaction` in `saveDrawResultAndAudit` but before HTTP response → client retries → unique violation → 500.
|
||||||
|
- Crash in the middle of `saveParticipants` transaction → rolled back (good).
|
||||||
|
- Process death after snapshot create but before draw → status = SNAPSHOT_LOCKED, safe to draw later.
|
||||||
|
- VK 429 / 500 / network break during pagination → unhandled, leaves giveaway in FETCHING or READY with incomplete data. No partial-progress resume.
|
||||||
|
|
||||||
|
### H6. winnersCount / reserveWinnersCount Abuse
|
||||||
|
|
||||||
|
No hard upper bound on `winnersCount` or `reserveWinnersCount` beyond `Math.min(..., eligible.length)`.
|
||||||
|
A client can request `winnersCount: 1_000_000` on a 100-participant giveaway; the code will still allocate and run the partial Fisher-Yates for the actual needed size, but the request body and subsequent JSON can be large.
|
||||||
|
More importantly, no validation that `winnersCount + reserve ≤ reasonable constant` (e.g. 100).
|
||||||
|
|
||||||
|
### H7. Missing Conditional Status Updates
|
||||||
|
|
||||||
|
All status transitions (`READY` → `SNAPSHOT_LOCKED`, `SNAPSHOT_LOCKED` → `DRAWN`) are plain `update` without `WHERE status = expected`. Under concurrency the wrong status can be overwritten.
|
||||||
|
|
||||||
|
### H8. listAll + Dashboard Memory Amplification
|
||||||
|
|
||||||
|
Dashboard that calls `listAll` will pull every participant of every giveaway into the Node process on each page load.
|
||||||
|
|
||||||
|
### H9. No Soft Limits on Concurrent Snapshots / Draws per Giveaway
|
||||||
|
|
||||||
|
A single giveaway can accumulate many snapshots (version keeps increasing). Each stores a full JSON copy. No retention policy yet.
|
||||||
|
|
||||||
|
### H10–H13. See detailed sections below (Rate limit, Observability, Privacy, Algorithmic notes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium Findings
|
||||||
|
|
||||||
|
- FSM guards are only application-level; a direct DB write can bypass them.
|
||||||
|
- `MemoryGiveawayRepository` has no concurrency protection at all (single-process only).
|
||||||
|
- Canonical stringify + sort is correct but CPU-heavy for 100k+; no incremental hashing.
|
||||||
|
- Verify endpoint re-loads full snapshot every time — expensive for large draws.
|
||||||
|
- No body-size limit middleware visible (Next.js default is generous).
|
||||||
|
- Malformed / extremely long VK URLs not explicitly rejected early.
|
||||||
|
- Unicode / null-byte handling in user names relies on JSON/Postgres; edge cases untested under load.
|
||||||
|
- Retry storms on 500 from unique violations can amplify load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low Findings
|
||||||
|
|
||||||
|
- Seed length is not capped; extremely long custom seed is accepted (only affects HMAC input size).
|
||||||
|
- No explicit `ON DELETE RESTRICT` behaviour documented for operators.
|
||||||
|
- AuditRecord and DrawResult both store winner IDs — minor redundancy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concurrency Review (Detailed)
|
||||||
|
|
||||||
|
| Scenario | Protected by unique / constraint? | Clean error? | Safe retry? | Recommendation |
|
||||||
|
|---------------------------------|-----------------------------------|--------------|-------------|----------------|
|
||||||
|
| Double draw | Yes (`giveawayId` unique) | No (500) | No | Conditional update + 409 mapping |
|
||||||
|
| Concurrent snapshot | Yes (`giveawayId+version`) | No (500) | Partial | Version under lock |
|
||||||
|
| Update participants + draw | Partial (FSM) | — | Risky | Explicit lock / require pre-existing snapshot |
|
||||||
|
| Concurrent create giveaway | No | — | Creates dups| Idempotency-Key |
|
||||||
|
| Concurrent participants fetch | No | — | Re-fetches | Idempotency or debounce |
|
||||||
|
|
||||||
|
Prisma `$transaction` (interactive or sequential array) uses the connection’s isolation level (default Read Committed). It does **not** by itself serialise the “read status → write DrawResult” critical section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure-Mode Analysis
|
||||||
|
|
||||||
|
| Failure | Resulting state | Safe to retry? | Compensation needed? |
|
||||||
|
|--------------------------------------|------------------------------------------|----------------|----------------------|
|
||||||
|
| Postgres unavailable | 500, no write | Yes | No |
|
||||||
|
| Prisma transaction timeout | Rolled back | Yes | No |
|
||||||
|
| VK 429 | 500 / incomplete participants | After backoff | Possibly clear partial |
|
||||||
|
| VK 500 / network mid-pagination | Incomplete list saved or error | Yes | Re-fetch |
|
||||||
|
| Crash after snapshot, before draw | SNAPSHOT_LOCKED, no DrawResult | Yes (draw) | No |
|
||||||
|
| Crash after DrawResult+Audit commit, before HTTP | DRAWN + full audit records | Client sees error, retry → 409/500 | Map unique to 409 |
|
||||||
|
| Crash between DrawResult and Audit (impossible – same tx) | Atomic | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abuse Cases & Suggested Limits
|
||||||
|
|
||||||
|
| Abuse vector | Current behaviour | Suggested limit / mitigation |
|
||||||
|
|-------------------------------------|------------------------------------|------------------------------|
|
||||||
|
| Thousands of Giveaways | Unlimited | 50–100 / user / day (once auth exists) |
|
||||||
|
| Spam participants fetch | Re-hits VK every time | Rate limit + cache / debounce 5 min |
|
||||||
|
| Spam verify | Cheap after first load | 30 req/min per IP / giveaway |
|
||||||
|
| winnersCount = millions | Capped by eligible | Hard max 100 + 100 reserve |
|
||||||
|
| Massive custom seed | Accepted | Max 256–512 chars |
|
||||||
|
| Oversized request body | Next.js default | Explicit 1–2 MB limit |
|
||||||
|
| Extremely long VK URL | Parsed | Max 2 kB, early reject |
|
||||||
|
| Repeated snapshot generation | Unlimited versions | Max 5–10 snapshots / giveaway, retention |
|
||||||
|
| Retry storm after 500 | Amplifies | 429 + Retry-After, circuit breaker |
|
||||||
|
|
||||||
|
Full policy → `docs/RATE_LIMIT_POLICY.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Invariants Audit
|
||||||
|
|
||||||
|
| Invariant | Guaranteed by DB? | Guaranteed by app code? | Notes |
|
||||||
|
|------------------------------------------------|-------------------|--------------------------|-------|
|
||||||
|
| At most one successful DrawResult per Giveaway | Yes (`@unique`) | Yes (FSM + unique) | Strong |
|
||||||
|
| At most one AuditRecord per Giveaway | Yes (`@unique`) | Yes | Strong |
|
||||||
|
| DrawResult always references existing snapshot | Yes (FK + Restrict)| Yes | Strong |
|
||||||
|
| Snapshot version unique per giveaway | Yes (`@@unique`) | Yes | Strong |
|
||||||
|
| Cannot delete snapshot that has Draw/Audit | Yes (onDelete: Restrict) | — | Strong |
|
||||||
|
| Status = DRAWN implies DrawResult exists | No | Yes (same transaction) | Soft – possible manual inconsistency |
|
||||||
|
| Status = DRAWN implies AuditRecord exists | No | Yes (same transaction) | Soft |
|
||||||
|
| Participants unique per (giveaway, user) | Yes | Yes | Strong |
|
||||||
|
|
||||||
|
No CHECK constraints or triggers enforce status ↔ existence of DrawResult/Audit. Application must remain the sole writer of status transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scalability & Algorithmic Complexity
|
||||||
|
|
||||||
|
| Operation | Time complexity | Memory complexity | Notes |
|
||||||
|
|----------------------------|--------------------------|------------------------|-------|
|
||||||
|
| Participant deduplication | O(n) | O(n) | Map |
|
||||||
|
| Filter engine | O(n) | O(n) | — |
|
||||||
|
| Canonical sort | O(n log n) | O(n) | localeCompare |
|
||||||
|
| Snapshot hashing | O(n · L) | O(n · L) | L ≈ 150–200 B/item |
|
||||||
|
| Partial Fisher-Yates | O(k · C_HMAC) expected | O(n) (copy) | k = winners+reserve |
|
||||||
|
| Verification | Same as draw | O(n) | Full re-execution |
|
||||||
|
| Prisma createMany | O(n) | O(n) | Batch |
|
||||||
|
|
||||||
|
No O(n²) algorithms found in the reviewed core paths. Dominant cost at scale is **canonical JSON construction + SHA-256** and **memory residency of the full participant arrays**.
|
||||||
|
|
||||||
|
**Synthetic baseline (Node 24, single core, see `docs/PERFORMANCE_BASELINE.md`):**
|
||||||
|
|
||||||
|
| n | Hash (ms) | JSON size (MB) | Heap after gen (MB) | Draw 10 winners (ms) |
|
||||||
|
|--------|-----------|----------------|---------------------|----------------------|
|
||||||
|
| 100 | ~3 | 0.03 | ~5 | <1 |
|
||||||
|
| 1 000 | ~6 | 0.27 | ~5 | <1 |
|
||||||
|
| 10 000 | ~56 | 2.8 | ~9 | ~3 |
|
||||||
|
| 50 000 | ~276 | 13.8 | ~30 | <1 |
|
||||||
|
| 100 000| ~994 | 27.7 | ~133 | ~1 |
|
||||||
|
|
||||||
|
Extrapolated 500 k: hash ≈ 5 s, JSON ≈ 80–140 MB, heap pressure > 600 MB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Pressure Hotspots
|
||||||
|
|
||||||
|
1. `getGiveawayById` / `listGiveaways` – always include full participants + snapshots.
|
||||||
|
2. `createAndLockSnapshot` – materialises full array + JSON for Prisma.
|
||||||
|
3. `computeParticipantsSnapshotHash` – creates sorted copy + large intermediate string.
|
||||||
|
4. `executeDeterministicDrawV1` – copies the eligible array.
|
||||||
|
5. API handlers that return full participant lists.
|
||||||
|
6. VK provider (if it accumulates all pages in memory before returning).
|
||||||
|
|
||||||
|
**Worst-case 500 k participants on a 1 GB container:** high probability of OOM during hash or snapshot write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations (Prioritised)
|
||||||
|
|
||||||
|
### Before any production traffic
|
||||||
|
1. Map Prisma unique-violation (P2002) on DrawResult / Snapshot to HTTP 409 with clear message.
|
||||||
|
2. Make draw status transition conditional (`updateMany` WHERE status = expected).
|
||||||
|
3. Stop returning full participant arrays from list / participants endpoints (paginate or return stats + IDs only).
|
||||||
|
4. Add hard caps: `winnersCount ≤ 100`, `reserve ≤ 100`, request body size, seed length.
|
||||||
|
5. Add basic rate limiting (even if only in reverse-proxy / Next middleware).
|
||||||
|
|
||||||
|
### Short-term (Phase 2)
|
||||||
|
6. Introduce optimistic locking or advisory locks for draw + snapshot.
|
||||||
|
7. Require explicit snapshot before draw; never auto-create inside the draw handler under concurrency.
|
||||||
|
8. Add `Idempotency-Key` support for create / participants / snapshot.
|
||||||
|
9. Paginate or stream large participant responses.
|
||||||
|
10. Add request-level timeout + AbortController for VK calls.
|
||||||
|
|
||||||
|
### Medium-term / Phase 3+ (architecture proposals)
|
||||||
|
11. Move large snapshots out of Postgres JSON:
|
||||||
|
- Option A: normalised `SnapshotParticipant` rows (chunked inserts).
|
||||||
|
- Option B: compressed JSON (gzip / zstd) in bytea + object storage (S3) for the bulk.
|
||||||
|
- Option C: Merkle-tree root only in DB, leaves in object storage (best for public verification of very large sets).
|
||||||
|
12. Background job for VK fetch + enrichment when n > ~5–10 k.
|
||||||
|
13. Giveaway-level retention / anonymisation policy (see Privacy section).
|
||||||
|
14. Connection pooling, statement timeouts, and circuit breakers for VK + DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed / Added
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|-----------------------------------|----------|---------|
|
||||||
|
| `docs/GROK_REVIEW.md` | Created | This review |
|
||||||
|
| `docs/LOAD_TEST_PLAN.md` | Created | Load scenarios |
|
||||||
|
| `docs/PERFORMANCE_BASELINE.md` | Created | Measured numbers |
|
||||||
|
| `docs/RATE_LIMIT_POLICY.md` | Created | Production rate limits |
|
||||||
|
| `docs/OBSERVABILITY.md` | Created | Metrics, logs, alerts |
|
||||||
|
| `scripts/benchmarks/core-performance.mjs` | Created | Offline core micro-benchmarks |
|
||||||
|
|
||||||
|
**No production Core, Prisma schema, proof format, or API contracts were altered.**
|
||||||
|
**No existing tests were broken** (benchmark is standalone).
|
||||||
|
|
||||||
|
**Tests added:** 0 (analysis-only phase; benchmark is not a unit test).
|
||||||
|
**Maximum participant count exercised:** 100 000.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done Checklist
|
||||||
|
|
||||||
|
- [x] Double draw race investigated
|
||||||
|
- [x] Snapshot race investigated
|
||||||
|
- [x] Participant update vs draw investigated
|
||||||
|
- [x] Idempotency strategy proposed
|
||||||
|
- [x] Load up to 500 k estimated
|
||||||
|
- [x] Performance baseline recorded
|
||||||
|
- [x] Memory risks identified
|
||||||
|
- [x] Abuse cases covered
|
||||||
|
- [x] Failure modes described
|
||||||
|
- [x] DB constraints audited
|
||||||
|
- [x] Rate limit policy written
|
||||||
|
- [x] Observability plan written
|
||||||
|
- [x] Production Core left intact
|
||||||
|
- [x] Deliverables present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Grok Phase G-1 Review*
|
||||||
383
grok_review/GROK_REVIEW.md
Normal file
383
grok_review/GROK_REVIEW.md
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
# Randomayzer — Phase G-1 Concurrency, Load, Abuse & Failure-Mode Review
|
||||||
|
|
||||||
|
**Reviewer:** Grok (xAI)
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Scope:** Concurrency, load/scalability, abuse resistance, failure modes, DB invariants, observability, rate limiting, memory & algorithmic complexity.
|
||||||
|
**Out of scope (per assignment):** Public Verification Integrity (Antigravity), general QA/security review & VK Integration prep (OpenCode). No changes to Randomizer core, proof format, OAuth, UI branding, VK Provider contract, or large Prisma schema rewrites.
|
||||||
|
|
||||||
|
**Repository snapshot:** local extract of `randomayzer-main` (matches provided zip / expected main).
|
||||||
|
**Commit SHA (from zip metadata):** `26e82fcf8d8e5855ac9e46fa8af21ca7daacc36f`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| Category | Critical | High | Medium | Low |
|
||||||
|
|-----------------------|----------|------|--------|-----|
|
||||||
|
| Concurrency | 2 | 2 | 1 | 0 |
|
||||||
|
| Failure Modes | 0 | 3 | 2 | 1 |
|
||||||
|
| Abuse Resistance | 1 | 3 | 2 | 1 |
|
||||||
|
| Database Invariants | 0 | 2 | 2 | 0 |
|
||||||
|
| Scalability / Memory | 1 | 3 | 2 | 0 |
|
||||||
|
| **Total** | **4** | **13**| **9** | **2**|
|
||||||
|
|
||||||
|
**Maximum participant count synthetically exercised:** 100 000 (core hashing / Fisher-Yates / verify).
|
||||||
|
**Estimated safe production limit (current architecture, single Node process, 2–4 GB RAM):** ~30–50k eligible participants.
|
||||||
|
**Race conditions found:** Double-draw (protected by unique constraint but poor error handling), Snapshot version collision (unique constraint only), Participant-update vs Draw interleaving.
|
||||||
|
|
||||||
|
**Production Core was not modified.** Only documentation + optional offline benchmark script added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Findings
|
||||||
|
|
||||||
|
### C1. Double-Draw Race Condition (Application-level TOCTOU)
|
||||||
|
|
||||||
|
**Location:** `src/app/api/giveaways/[id]/draw/route.ts` + `PrismaGiveawayRepository.saveDrawResultAndAudit`
|
||||||
|
|
||||||
|
**Scenario:** Two almost simultaneous `POST /api/giveaways/:id/draw`.
|
||||||
|
|
||||||
|
1. Both requests read Giveaway (status = `READY` or `SNAPSHOT_LOCKED`).
|
||||||
|
2. Both pass the early `if (status === 'DRAWN')` guard.
|
||||||
|
3. Both may create / reuse snapshot.
|
||||||
|
4. Both call `executeDeterministicDrawV1` (possibly with different seeds).
|
||||||
|
5. Both enter `$transaction` and attempt `drawResult.create({ giveawayId })`.
|
||||||
|
|
||||||
|
**Protection today:**
|
||||||
|
- `DrawResult.giveawayId @unique` → second insert fails with Prisma `P2002`.
|
||||||
|
- Transaction is atomic for the successful request.
|
||||||
|
|
||||||
|
**Problems:**
|
||||||
|
- Second request receives **500 Internal Server Error** (unhandled unique violation) instead of clean 409/400 “already drawn”.
|
||||||
|
- Client may retry and keep failing.
|
||||||
|
- Work (hashing, Fisher-Yates) is wasted on the loser.
|
||||||
|
- No `SELECT … FOR UPDATE` / optimistic version / conditional `UPDATE … WHERE status = 'SNAPSHOT_LOCKED'`.
|
||||||
|
- Default Prisma isolation (Read Committed) does not prevent the race.
|
||||||
|
|
||||||
|
**Impact:** Data integrity is preserved (only one DrawResult), but availability and UX under concurrent load are broken. In a load-balanced multi-instance deployment the race window is larger.
|
||||||
|
|
||||||
|
**Recommended fix (proposal only — do not implement without agreement):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Inside transaction, use conditional update as gate
|
||||||
|
const updated = await tx.giveaway.updateMany({
|
||||||
|
where: { id, status: 'SNAPSHOT_LOCKED' },
|
||||||
|
data: { status: 'DRAWN', drawnAt: ..., seed: ... },
|
||||||
|
});
|
||||||
|
if (updated.count === 0) {
|
||||||
|
throw new Error('ALREADY_DRAWN_OR_INVALID_STATE'); // map to 409
|
||||||
|
}
|
||||||
|
// then create DrawResult + AuditRecord
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use PostgreSQL advisory lock (`pg_advisory_xact_lock(hashtext(giveawayId))`) at the start of the transaction, or a dedicated `draw_lock` row.
|
||||||
|
|
||||||
|
Optimistic locking via a `version` / `statusVersion` integer column is also viable.
|
||||||
|
|
||||||
|
### C2. Snapshot Version Race
|
||||||
|
|
||||||
|
**Location:** `PrismaGiveawayRepository.createAndLockSnapshot`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const latestVersion = current.snapshots.length > 0
|
||||||
|
? Math.max(...current.snapshots.map(s => s.version)) : 0;
|
||||||
|
const newVersion = latestVersion + 1;
|
||||||
|
// then $transaction([ create with newVersion, update status ])
|
||||||
|
```
|
||||||
|
|
||||||
|
Version is computed **outside** any lock. Two concurrent snapshot creations can choose the same `version` → unique constraint `@@unique([giveawayId, version])` rejects one with 500.
|
||||||
|
|
||||||
|
**Sufficient for data integrity?** Yes (constraint works).
|
||||||
|
**Production-safe?** No — error handling and retry semantics are missing. Status can also be left inconsistent if one succeeds and the other fails after status update.
|
||||||
|
|
||||||
|
**Proposal:** Compute next version inside a serializable transaction or use `INSERT … ON CONFLICT` / sequence / `MAX(version)+1` under `SELECT FOR UPDATE` on the Giveaway row.
|
||||||
|
|
||||||
|
### C3. Full Participant Lists Loaded into Memory on Every getById / listAll
|
||||||
|
|
||||||
|
`getGiveawayById` and `listGiveaways` always `include: { participants: true, snapshots: true }`.
|
||||||
|
|
||||||
|
For a giveaway with 100k participants the JSON response + in-memory objects easily exceed hundreds of MB. `listAll` multiplies the problem.
|
||||||
|
|
||||||
|
Combined with snapshot `eligibleParticipants Json` this is the primary OOM vector.
|
||||||
|
|
||||||
|
### C4. Snapshot Storage as Monolithic JSON
|
||||||
|
|
||||||
|
`ParticipantSnapshot.eligibleParticipants Json` stores the entire canonical array.
|
||||||
|
|
||||||
|
| Eligible count | Approx. canonical JSON size | Prisma / PG TOAST risk | Memory on read |
|
||||||
|
|----------------|-----------------------------|------------------------|----------------|
|
||||||
|
| 10k | ~1.5–2.5 MB | OK | Low |
|
||||||
|
| 50k | ~8–14 MB | Acceptable | Medium |
|
||||||
|
| 100k | ~20–30 MB | High (large object) | High |
|
||||||
|
| 500k | ~80–140 MB | Problematic | Almost certain OOM on small instances |
|
||||||
|
|
||||||
|
No chunking, compression, or external object storage. Verification and UI that re-load the snapshot will re-materialize the whole array.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High Findings
|
||||||
|
|
||||||
|
### H1. Participant Update vs Draw Race
|
||||||
|
|
||||||
|
`saveParticipants` is allowed only when status ∉ {SNAPSHOT_LOCKED, DRAWN, PUBLISHED}.
|
||||||
|
Draw can create a snapshot from the in-memory `giveaway.participants` if none exists.
|
||||||
|
|
||||||
|
If both run concurrently while status = READY:
|
||||||
|
- Draw may snapshot a partially-updated list (deleteMany has finished, createMany has not, or vice-versa).
|
||||||
|
- Or snapshot the old list while the new list is committed.
|
||||||
|
|
||||||
|
Result: Draw operates on a non-atomic participant set. Integrity of the snapshot relative to the final stored participants is not guaranteed.
|
||||||
|
|
||||||
|
**Mitigation proposal:** Always require an explicit snapshot before draw (already partially true via FSM), and make `createAndLockSnapshot` take a DB-level lock that also blocks `saveParticipants`.
|
||||||
|
|
||||||
|
### H2. No Idempotency Keys
|
||||||
|
|
||||||
|
None of the mutating endpoints accept or honour `Idempotency-Key`:
|
||||||
|
|
||||||
|
- `POST /api/giveaways` (create)
|
||||||
|
- `POST …/participants`
|
||||||
|
- `POST …/snapshot`
|
||||||
|
- `POST …/draw`
|
||||||
|
- `POST …/publish` (if exists)
|
||||||
|
|
||||||
|
For draw the unique constraint gives a crude form of “at-most-once”, but the error is not clean.
|
||||||
|
For create / participants / snapshot a client retry after a network blip can create duplicates or re-fetch VK data unnecessarily.
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Draw / Publish → rely on DB state machine + unique constraints (already present) + map P2002 → 409.
|
||||||
|
- Create Giveaway, Snapshot, Participants fetch → accept optional `Idempotency-Key` header, store in a short-lived table or Redis, return the previous response on conflict.
|
||||||
|
|
||||||
|
### H3. API Response Size / DoS Surface
|
||||||
|
|
||||||
|
`POST …/participants` returns the full `allParticipants` array.
|
||||||
|
`GET /api/giveaways` returns every giveaway with every participant and every snapshot.
|
||||||
|
|
||||||
|
A malicious or naïve client can trigger multi-hundred-MB responses. Combined with lack of rate limiting this is an easy memory / bandwidth exhaustion vector.
|
||||||
|
|
||||||
|
### H4. No Request Timeouts / Cancellation on VK Fetch
|
||||||
|
|
||||||
|
`VkProvider.fetchParticipants` (and pagination) can run for a long time. There is no `AbortController`, no overall request timeout, no job queue. A slow VK response or large comment tree holds a Node request forever and can exhaust the connection pool.
|
||||||
|
|
||||||
|
**When HTTP request-response stops being suitable:**
|
||||||
|
When expected VK fetch + enrichment > 15–20 s for typical giveaways, or when 10k+ participants become common. Move to background job (BullMQ / Inngest / custom) with status polling.
|
||||||
|
|
||||||
|
### H5. Failure Modes – Partial Crash After Commit
|
||||||
|
|
||||||
|
- Crash after successful `$transaction` in `saveDrawResultAndAudit` but before HTTP response → client retries → unique violation → 500.
|
||||||
|
- Crash in the middle of `saveParticipants` transaction → rolled back (good).
|
||||||
|
- Process death after snapshot create but before draw → status = SNAPSHOT_LOCKED, safe to draw later.
|
||||||
|
- VK 429 / 500 / network break during pagination → unhandled, leaves giveaway in FETCHING or READY with incomplete data. No partial-progress resume.
|
||||||
|
|
||||||
|
### H6. winnersCount / reserveWinnersCount Abuse
|
||||||
|
|
||||||
|
No hard upper bound on `winnersCount` or `reserveWinnersCount` beyond `Math.min(..., eligible.length)`.
|
||||||
|
A client can request `winnersCount: 1_000_000` on a 100-participant giveaway; the code will still allocate and run the partial Fisher-Yates for the actual needed size, but the request body and subsequent JSON can be large.
|
||||||
|
More importantly, no validation that `winnersCount + reserve ≤ reasonable constant` (e.g. 100).
|
||||||
|
|
||||||
|
### H7. Missing Conditional Status Updates
|
||||||
|
|
||||||
|
All status transitions (`READY` → `SNAPSHOT_LOCKED`, `SNAPSHOT_LOCKED` → `DRAWN`) are plain `update` without `WHERE status = expected`. Under concurrency the wrong status can be overwritten.
|
||||||
|
|
||||||
|
### H8. listAll + Dashboard Memory Amplification
|
||||||
|
|
||||||
|
Dashboard that calls `listAll` will pull every participant of every giveaway into the Node process on each page load.
|
||||||
|
|
||||||
|
### H9. No Soft Limits on Concurrent Snapshots / Draws per Giveaway
|
||||||
|
|
||||||
|
A single giveaway can accumulate many snapshots (version keeps increasing). Each stores a full JSON copy. No retention policy yet.
|
||||||
|
|
||||||
|
### H10–H13. See detailed sections below (Rate limit, Observability, Privacy, Algorithmic notes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium Findings
|
||||||
|
|
||||||
|
- FSM guards are only application-level; a direct DB write can bypass them.
|
||||||
|
- `MemoryGiveawayRepository` has no concurrency protection at all (single-process only).
|
||||||
|
- Canonical stringify + sort is correct but CPU-heavy for 100k+; no incremental hashing.
|
||||||
|
- Verify endpoint re-loads full snapshot every time — expensive for large draws.
|
||||||
|
- No body-size limit middleware visible (Next.js default is generous).
|
||||||
|
- Malformed / extremely long VK URLs not explicitly rejected early.
|
||||||
|
- Unicode / null-byte handling in user names relies on JSON/Postgres; edge cases untested under load.
|
||||||
|
- Retry storms on 500 from unique violations can amplify load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low Findings
|
||||||
|
|
||||||
|
- Seed length is not capped; extremely long custom seed is accepted (only affects HMAC input size).
|
||||||
|
- No explicit `ON DELETE RESTRICT` behaviour documented for operators.
|
||||||
|
- AuditRecord and DrawResult both store winner IDs — minor redundancy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concurrency Review (Detailed)
|
||||||
|
|
||||||
|
| Scenario | Protected by unique / constraint? | Clean error? | Safe retry? | Recommendation |
|
||||||
|
|---------------------------------|-----------------------------------|--------------|-------------|----------------|
|
||||||
|
| Double draw | Yes (`giveawayId` unique) | No (500) | No | Conditional update + 409 mapping |
|
||||||
|
| Concurrent snapshot | Yes (`giveawayId+version`) | No (500) | Partial | Version under lock |
|
||||||
|
| Update participants + draw | Partial (FSM) | — | Risky | Explicit lock / require pre-existing snapshot |
|
||||||
|
| Concurrent create giveaway | No | — | Creates dups| Idempotency-Key |
|
||||||
|
| Concurrent participants fetch | No | — | Re-fetches | Idempotency or debounce |
|
||||||
|
|
||||||
|
Prisma `$transaction` (interactive or sequential array) uses the connection’s isolation level (default Read Committed). It does **not** by itself serialise the “read status → write DrawResult” critical section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure-Mode Analysis
|
||||||
|
|
||||||
|
| Failure | Resulting state | Safe to retry? | Compensation needed? |
|
||||||
|
|--------------------------------------|------------------------------------------|----------------|----------------------|
|
||||||
|
| Postgres unavailable | 500, no write | Yes | No |
|
||||||
|
| Prisma transaction timeout | Rolled back | Yes | No |
|
||||||
|
| VK 429 | 500 / incomplete participants | After backoff | Possibly clear partial |
|
||||||
|
| VK 500 / network mid-pagination | Incomplete list saved or error | Yes | Re-fetch |
|
||||||
|
| Crash after snapshot, before draw | SNAPSHOT_LOCKED, no DrawResult | Yes (draw) | No |
|
||||||
|
| Crash after DrawResult+Audit commit, before HTTP | DRAWN + full audit records | Client sees error, retry → 409/500 | Map unique to 409 |
|
||||||
|
| Crash between DrawResult and Audit (impossible – same tx) | Atomic | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abuse Cases & Suggested Limits
|
||||||
|
|
||||||
|
| Abuse vector | Current behaviour | Suggested limit / mitigation |
|
||||||
|
|-------------------------------------|------------------------------------|------------------------------|
|
||||||
|
| Thousands of Giveaways | Unlimited | 50–100 / user / day (once auth exists) |
|
||||||
|
| Spam participants fetch | Re-hits VK every time | Rate limit + cache / debounce 5 min |
|
||||||
|
| Spam verify | Cheap after first load | 30 req/min per IP / giveaway |
|
||||||
|
| winnersCount = millions | Capped by eligible | Hard max 100 + 100 reserve |
|
||||||
|
| Massive custom seed | Accepted | Max 256–512 chars |
|
||||||
|
| Oversized request body | Next.js default | Explicit 1–2 MB limit |
|
||||||
|
| Extremely long VK URL | Parsed | Max 2 kB, early reject |
|
||||||
|
| Repeated snapshot generation | Unlimited versions | Max 5–10 snapshots / giveaway, retention |
|
||||||
|
| Retry storm after 500 | Amplifies | 429 + Retry-After, circuit breaker |
|
||||||
|
|
||||||
|
Full policy → `docs/RATE_LIMIT_POLICY.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Invariants Audit
|
||||||
|
|
||||||
|
| Invariant | Guaranteed by DB? | Guaranteed by app code? | Notes |
|
||||||
|
|------------------------------------------------|-------------------|--------------------------|-------|
|
||||||
|
| At most one successful DrawResult per Giveaway | Yes (`@unique`) | Yes (FSM + unique) | Strong |
|
||||||
|
| At most one AuditRecord per Giveaway | Yes (`@unique`) | Yes | Strong |
|
||||||
|
| DrawResult always references existing snapshot | Yes (FK + Restrict)| Yes | Strong |
|
||||||
|
| Snapshot version unique per giveaway | Yes (`@@unique`) | Yes | Strong |
|
||||||
|
| Cannot delete snapshot that has Draw/Audit | Yes (onDelete: Restrict) | — | Strong |
|
||||||
|
| Status = DRAWN implies DrawResult exists | No | Yes (same transaction) | Soft – possible manual inconsistency |
|
||||||
|
| Status = DRAWN implies AuditRecord exists | No | Yes (same transaction) | Soft |
|
||||||
|
| Participants unique per (giveaway, user) | Yes | Yes | Strong |
|
||||||
|
|
||||||
|
No CHECK constraints or triggers enforce status ↔ existence of DrawResult/Audit. Application must remain the sole writer of status transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scalability & Algorithmic Complexity
|
||||||
|
|
||||||
|
| Operation | Time complexity | Memory complexity | Notes |
|
||||||
|
|----------------------------|--------------------------|------------------------|-------|
|
||||||
|
| Participant deduplication | O(n) | O(n) | Map |
|
||||||
|
| Filter engine | O(n) | O(n) | — |
|
||||||
|
| Canonical sort | O(n log n) | O(n) | localeCompare |
|
||||||
|
| Snapshot hashing | O(n · L) | O(n · L) | L ≈ 150–200 B/item |
|
||||||
|
| Partial Fisher-Yates | O(k · C_HMAC) expected | O(n) (copy) | k = winners+reserve |
|
||||||
|
| Verification | Same as draw | O(n) | Full re-execution |
|
||||||
|
| Prisma createMany | O(n) | O(n) | Batch |
|
||||||
|
|
||||||
|
No O(n²) algorithms found in the reviewed core paths. Dominant cost at scale is **canonical JSON construction + SHA-256** and **memory residency of the full participant arrays**.
|
||||||
|
|
||||||
|
**Synthetic baseline (Node 24, single core, see `docs/PERFORMANCE_BASELINE.md`):**
|
||||||
|
|
||||||
|
| n | Hash (ms) | JSON size (MB) | Heap after gen (MB) | Draw 10 winners (ms) |
|
||||||
|
|--------|-----------|----------------|---------------------|----------------------|
|
||||||
|
| 100 | ~3 | 0.03 | ~5 | <1 |
|
||||||
|
| 1 000 | ~6 | 0.27 | ~5 | <1 |
|
||||||
|
| 10 000 | ~56 | 2.8 | ~9 | ~3 |
|
||||||
|
| 50 000 | ~276 | 13.8 | ~30 | <1 |
|
||||||
|
| 100 000| ~994 | 27.7 | ~133 | ~1 |
|
||||||
|
|
||||||
|
Extrapolated 500 k: hash ≈ 5 s, JSON ≈ 80–140 MB, heap pressure > 600 MB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Pressure Hotspots
|
||||||
|
|
||||||
|
1. `getGiveawayById` / `listGiveaways` – always include full participants + snapshots.
|
||||||
|
2. `createAndLockSnapshot` – materialises full array + JSON for Prisma.
|
||||||
|
3. `computeParticipantsSnapshotHash` – creates sorted copy + large intermediate string.
|
||||||
|
4. `executeDeterministicDrawV1` – copies the eligible array.
|
||||||
|
5. API handlers that return full participant lists.
|
||||||
|
6. VK provider (if it accumulates all pages in memory before returning).
|
||||||
|
|
||||||
|
**Worst-case 500 k participants on a 1 GB container:** high probability of OOM during hash or snapshot write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations (Prioritised)
|
||||||
|
|
||||||
|
### Before any production traffic
|
||||||
|
1. Map Prisma unique-violation (P2002) on DrawResult / Snapshot to HTTP 409 with clear message.
|
||||||
|
2. Make draw status transition conditional (`updateMany` WHERE status = expected).
|
||||||
|
3. Stop returning full participant arrays from list / participants endpoints (paginate or return stats + IDs only).
|
||||||
|
4. Add hard caps: `winnersCount ≤ 100`, `reserve ≤ 100`, request body size, seed length.
|
||||||
|
5. Add basic rate limiting (even if only in reverse-proxy / Next middleware).
|
||||||
|
|
||||||
|
### Short-term (Phase 2)
|
||||||
|
6. Introduce optimistic locking or advisory locks for draw + snapshot.
|
||||||
|
7. Require explicit snapshot before draw; never auto-create inside the draw handler under concurrency.
|
||||||
|
8. Add `Idempotency-Key` support for create / participants / snapshot.
|
||||||
|
9. Paginate or stream large participant responses.
|
||||||
|
10. Add request-level timeout + AbortController for VK calls.
|
||||||
|
|
||||||
|
### Medium-term / Phase 3+ (architecture proposals)
|
||||||
|
11. Move large snapshots out of Postgres JSON:
|
||||||
|
- Option A: normalised `SnapshotParticipant` rows (chunked inserts).
|
||||||
|
- Option B: compressed JSON (gzip / zstd) in bytea + object storage (S3) for the bulk.
|
||||||
|
- Option C: Merkle-tree root only in DB, leaves in object storage (best for public verification of very large sets).
|
||||||
|
12. Background job for VK fetch + enrichment when n > ~5–10 k.
|
||||||
|
13. Giveaway-level retention / anonymisation policy (see Privacy section).
|
||||||
|
14. Connection pooling, statement timeouts, and circuit breakers for VK + DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed / Added
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|-----------------------------------|----------|---------|
|
||||||
|
| `docs/GROK_REVIEW.md` | Created | This review |
|
||||||
|
| `docs/LOAD_TEST_PLAN.md` | Created | Load scenarios |
|
||||||
|
| `docs/PERFORMANCE_BASELINE.md` | Created | Measured numbers |
|
||||||
|
| `docs/RATE_LIMIT_POLICY.md` | Created | Production rate limits |
|
||||||
|
| `docs/OBSERVABILITY.md` | Created | Metrics, logs, alerts |
|
||||||
|
| `scripts/benchmarks/core-performance.mjs` | Created | Offline core micro-benchmarks |
|
||||||
|
|
||||||
|
**No production Core, Prisma schema, proof format, or API contracts were altered.**
|
||||||
|
**No existing tests were broken** (benchmark is standalone).
|
||||||
|
|
||||||
|
**Tests added:** 0 (analysis-only phase; benchmark is not a unit test).
|
||||||
|
**Maximum participant count exercised:** 100 000.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done Checklist
|
||||||
|
|
||||||
|
- [x] Double draw race investigated
|
||||||
|
- [x] Snapshot race investigated
|
||||||
|
- [x] Participant update vs draw investigated
|
||||||
|
- [x] Idempotency strategy proposed
|
||||||
|
- [x] Load up to 500 k estimated
|
||||||
|
- [x] Performance baseline recorded
|
||||||
|
- [x] Memory risks identified
|
||||||
|
- [x] Abuse cases covered
|
||||||
|
- [x] Failure modes described
|
||||||
|
- [x] DB constraints audited
|
||||||
|
- [x] Rate limit policy written
|
||||||
|
- [x] Observability plan written
|
||||||
|
- [x] Production Core left intact
|
||||||
|
- [x] Deliverables present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Grok Phase G-1 Review*
|
||||||
108
grok_review/PHASE_1_4_FAILURE_MATRIX.md
Normal file
108
grok_review/PHASE_1_4_FAILURE_MATRIX.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Phase 1.4 Failure Matrix — Grok G-2
|
||||||
|
|
||||||
|
**Commit:** `3f795e23fe6397832147fb47285584dc0eccbf3c`
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
|
||||||
|
## Legend
|
||||||
|
- **Protected** = data integrity holds, clean client error
|
||||||
|
- **Degraded** = integrity holds but UX / ops suffer
|
||||||
|
- **Bypassable** = attacker can defeat the control
|
||||||
|
- **Gap** = missing or incomplete protection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Concurrency
|
||||||
|
|
||||||
|
| Scenario | Protection | HTTP outcome | Data integrity | Notes |
|
||||||
|
|----------|------------|--------------|----------------|-------|
|
||||||
|
| 2 concurrent draws | Conditional updateMany + unique | 200 + 409 | Protected | Good |
|
||||||
|
| 10–100 concurrent draws | Same | 1×200 + (N-1)×409 | Protected | Memory tests pass; Prisma relies on DB |
|
||||||
|
| Concurrent snapshots | Version inside tx + unique | 409 on conflict | Protected | Multiple historical versions allowed |
|
||||||
|
| Participants update while SNAPSHOT_LOCKED | FSM assert | 409 | Protected | |
|
||||||
|
| Participants + snapshot + draw interleaving | FSM + status guards | 409 / sequential success | Protected (residual TOCTOU low) | Re-test after 1.4.1 |
|
||||||
|
|
||||||
|
## 2. Idempotency
|
||||||
|
|
||||||
|
| Scenario | Protection | Outcome | Grade |
|
||||||
|
|----------|------------|---------|-------|
|
||||||
|
| Same key + same body | In-memory Map | Cached response | OK (single instance) |
|
||||||
|
| Same key + different body | None (no body hash) | Wrong / first body returned | **Gap / HIGH** |
|
||||||
|
| Key reuse across giveaways | Weak prefix only | Possible collision | **Gap** |
|
||||||
|
| Parallel identical requests | Race on set | One wins | Degraded |
|
||||||
|
| Flood unique keys | Lazy TTL only | Memory growth | **Gap** |
|
||||||
|
| Multi-instance deployment | Process-local | No shared semantics | **Fail** |
|
||||||
|
|
||||||
|
## 3. Rate Limiting
|
||||||
|
|
||||||
|
| Scenario | Protection | Outcome | Grade |
|
||||||
|
|----------|------------|---------|-------|
|
||||||
|
| Burst same IP | Sliding window | 429 after limit | OK |
|
||||||
|
| Rotate X-Forwarded-For | None | Full bypass | **Bypassable / CRITICAL** |
|
||||||
|
| Missing X-Forwarded-For | Falls to “anonymous” | Shared bucket | Degraded |
|
||||||
|
| IPv6 / multi-value header | No normalization | Unexpected keys | Gap |
|
||||||
|
| Unique fake IP flood | No idle key GC | Memory growth | Gap |
|
||||||
|
| Multi-instance | Process-local | Independent buckets | Fail for global limit |
|
||||||
|
|
||||||
|
## 4. Validation & Contract
|
||||||
|
|
||||||
|
| Scenario | Protection | Outcome | Grade |
|
||||||
|
|----------|------------|---------|-------|
|
||||||
|
| winnersCount 0 / 101 / negative | Zod | 400 | Protected |
|
||||||
|
| seed > 512 chars | Zod | 400 | Protected |
|
||||||
|
| URL > 2048 | Zod | 400 | Protected |
|
||||||
|
| Malformed JSON | handleApiError | 400 | Protected |
|
||||||
|
| Unknown fields | .strict() | 400 | Protected |
|
||||||
|
| winnersCount > eligible | Silent Math.min in Core | 200 with fewer winners | **Gap** (contract) |
|
||||||
|
| Nested / huge JSON before Zod | Node parser limits | Possible 400 / memory | Acceptable |
|
||||||
|
|
||||||
|
## 5. Database / Crash
|
||||||
|
|
||||||
|
| Scenario | State after | Retry safe? | Grade |
|
||||||
|
|----------|-------------|-------------|-------|
|
||||||
|
| Crash before draw tx | SNAPSHOT_LOCKED | Yes | Protected |
|
||||||
|
| Crash inside draw tx | Rolled back | Yes | Protected |
|
||||||
|
| Crash after commit, before HTTP | DRAWN + records | Yes → 409 | Protected (client must understand 409) |
|
||||||
|
| Unique violation on DrawResult | ConflictError 409 | N/A | Protected |
|
||||||
|
| Orphan DrawResult / Audit | Impossible (same tx + unique) | — | Protected |
|
||||||
|
| Snapshot delete with Draw/Audit | Restrict | — | Protected |
|
||||||
|
|
||||||
|
## 6. Memory / Scalability of Phase 1.4 Stores
|
||||||
|
|
||||||
|
| Load | IdempotencyStore | RateLimiter | Risk |
|
||||||
|
|------|------------------|-------------|------|
|
||||||
|
| 10k unique keys | ~5 MB | low | OK |
|
||||||
|
| 100k unique keys | ~43 MB | ~20–30 MB | Warning |
|
||||||
|
| 200k+ / continuous flood | Unbounded (lazy TTL) | Unbounded | High under abuse |
|
||||||
|
|
||||||
|
## 7. Summary Grades (G-2)
|
||||||
|
|
||||||
|
| Area | Grade |
|
||||||
|
|------|-------|
|
||||||
|
| Concurrency | **PASS WITH WARNINGS** |
|
||||||
|
| Idempotency | **FAIL** (for production multi-instance / adversarial) |
|
||||||
|
| Rate limiting | **FAIL** (header spoof bypass) |
|
||||||
|
| Validation | **PASS WITH WARNINGS** (silent winners cap) |
|
||||||
|
| DB integrity | **PASS** |
|
||||||
|
| Scalability of new stores | **PASS WITH WARNINGS** |
|
||||||
|
|
||||||
|
## 8. VK Integration Readiness Answer
|
||||||
|
|
||||||
|
**Is it safe to start real VK integration after Phase 1.4.1?**
|
||||||
|
|
||||||
|
**No — not yet fully safe.**
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
1. Draw concurrency and DB invariants are solid enough for a single-instance or carefully pooled deployment.
|
||||||
|
2. Rate limiting is trivially bypassable via `X-Forwarded-For` rotation; any public exposure can be flooded.
|
||||||
|
3. Idempotency is process-local and lacks body fingerprinting; multi-instance or retry storms will misbehave.
|
||||||
|
4. Silent under-delivery of winners when requested count exceeds eligible violates a clear API contract expectation.
|
||||||
|
5. After Antigravity finishes 1.4.1, the mixed-race and 100-concurrent suites must be re-validated on real Postgres.
|
||||||
|
|
||||||
|
**Minimum before VK production traffic:**
|
||||||
|
- Edge (or shared) rate limiting with non-spoofable identity
|
||||||
|
- Shared idempotency store with body hash / mismatch rejection
|
||||||
|
- Explicit error when winnersCount + reserve > eligible
|
||||||
|
- Client contract that 409 on draw means “already completed”
|
||||||
|
|
||||||
|
Once those are in place, Phase 1.4 concurrency + validation form a reasonable base for controlled VK integration.
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||||
import { GiveawayFSM } from '@/core/fsm/giveaway-fsm';
|
|
||||||
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
|
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
|
||||||
import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
|
import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
|
||||||
import { executeDrawSchema } from '@/core/validation/giveaway-schemas';
|
import { executeDrawSchema } from '@/core/validation/giveaway-schemas';
|
||||||
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
import {
|
||||||
|
handleApiError,
|
||||||
|
NotFoundError,
|
||||||
|
ConflictError,
|
||||||
|
ValidationError,
|
||||||
|
DrawAlreadyCompletedError
|
||||||
|
} from '@/core/errors/http-errors';
|
||||||
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -13,18 +19,29 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
expensiveApiRateLimiter.assertAllowed(`draw-execute:${ip}:${id}`);
|
expensiveApiRateLimiter.assertAllowed(`draw-execute:${clientIp}:${id}`);
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Strict FSM Guard: Draw is permitted ONLY in SNAPSHOT_LOCKED status
|
// 1. Strict Terminal State Guard: If already DRAWN or PUBLISHED, return 409 DRAW_ALREADY_COMPLETED
|
||||||
GiveawayFSM.assertCanDraw(giveaway.status);
|
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
|
||||||
|
throw new DrawAlreadyCompletedError(
|
||||||
|
`Giveaway "${id}" has already been drawn and finalized. Repeat draws are not permitted.`,
|
||||||
|
{ giveawayId: id, status: giveaway.status, drawnAt: giveaway.drawnAt }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Strict Snapshot requirement: Never create a snapshot implicitly
|
if (giveaway.status !== 'SNAPSHOT_LOCKED') {
|
||||||
|
throw new ConflictError(
|
||||||
|
`Cannot execute draw: giveaway status is "${giveaway.status}", but draw requires "SNAPSHOT_LOCKED"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Strict Snapshot requirement
|
||||||
const snapshot = giveaway.latestSnapshot;
|
const snapshot = giveaway.latestSnapshot;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
throw new ConflictError(
|
throw new ConflictError(
|
||||||
|
|
@ -37,11 +54,20 @@ export async function POST(
|
||||||
|
|
||||||
const winnersCount = validated.winnersCount;
|
const winnersCount = validated.winnersCount;
|
||||||
const reserveWinnersCount = validated.reserveWinnersCount;
|
const reserveWinnersCount = validated.reserveWinnersCount;
|
||||||
|
const totalRequired = winnersCount + reserveWinnersCount;
|
||||||
|
|
||||||
|
// 3. Strict Winner Count Contract: Never under-deliver winners
|
||||||
|
if (totalRequired > snapshot.participantCount) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Requested ${winnersCount} winners and ${reserveWinnersCount} reserve winners (${totalRequired} total) exceeds eligible participants count (${snapshot.participantCount})`,
|
||||||
|
{ winnersCount, reserveWinnersCount, eligibleCount: snapshot.participantCount }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Use CSPRNG crypto.randomBytes seed if none provided (Math.random is strictly forbidden)
|
// Use CSPRNG crypto.randomBytes seed if none provided (Math.random is strictly forbidden)
|
||||||
const seed = (validated.seed && validated.seed.trim()) || generateCryptoSecureSeed();
|
const seed = (validated.seed && validated.seed.trim()) || generateCryptoSecureSeed();
|
||||||
|
|
||||||
// 3. Execute Provably Fair Fisher-Yates Draw V1
|
// 4. Execute Provably Fair Fisher-Yates Draw V1
|
||||||
const drawResult = executeDeterministicDrawV1({
|
const drawResult = executeDeterministicDrawV1({
|
||||||
giveawayId: id,
|
giveawayId: id,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|
@ -52,7 +78,7 @@ export async function POST(
|
||||||
filterRules: giveaway.filterRules,
|
filterRules: giveaway.filterRules,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Save DrawResult & AuditRecord in database with atomic status transition
|
// 5. Save DrawResult & AuditRecord in database with atomic status transition
|
||||||
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
|
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { fetchParticipantsSchema, validateProviderCapabilities } from '@/core/va
|
||||||
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
|
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
|
||||||
import { expensiveApiRateLimiter, generalApiRateLimiter } from '@/lib/rate-limiter';
|
import { expensiveApiRateLimiter, generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
import { IdempotencyStore } from '@/lib/idempotency';
|
import { IdempotencyStore } from '@/lib/idempotency';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -13,8 +14,8 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
generalApiRateLimiter.assertAllowed(`participants-get:${ip}`);
|
generalApiRateLimiter.assertAllowed(`participants-get:${clientIp}`);
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||||
|
|
@ -39,16 +40,8 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
expensiveApiRateLimiter.assertAllowed(`participants-import:${ip}:${id}`);
|
expensiveApiRateLimiter.assertAllowed(`participants-import:${clientIp}:${id}`);
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('idempotency-key');
|
|
||||||
if (idempotencyKey) {
|
|
||||||
const cached = IdempotencyStore.get(`import-part:${idempotencyKey}`);
|
|
||||||
if (cached) {
|
|
||||||
return NextResponse.json(cached.body, { status: cached.statusCode });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
|
|
@ -58,6 +51,19 @@ export async function POST(
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = fetchParticipantsSchema.parse(rawBody);
|
const validated = fetchParticipantsSchema.parse(rawBody);
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('idempotency-key');
|
||||||
|
if (idempotencyKey) {
|
||||||
|
const cached = IdempotencyStore.get({
|
||||||
|
key: idempotencyKey,
|
||||||
|
operation: 'participants-import',
|
||||||
|
giveawayId: id,
|
||||||
|
requestPayload: validated,
|
||||||
|
});
|
||||||
|
if (cached) {
|
||||||
|
return NextResponse.json(cached.body, { status: cached.statusCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const provider = ProviderFactory.getVkProvider();
|
const provider = ProviderFactory.getVkProvider();
|
||||||
validateProviderCapabilities(validated.filterRules, provider.capabilities);
|
validateProviderCapabilities(validated.filterRules, provider.capabilities);
|
||||||
|
|
||||||
|
|
@ -91,7 +97,14 @@ export async function POST(
|
||||||
};
|
};
|
||||||
|
|
||||||
if (idempotencyKey) {
|
if (idempotencyKey) {
|
||||||
IdempotencyStore.set(`import-part:${idempotencyKey}`, 200, responseBody);
|
IdempotencyStore.set({
|
||||||
|
key: idempotencyKey,
|
||||||
|
operation: 'participants-import',
|
||||||
|
giveawayId: id,
|
||||||
|
requestPayload: validated,
|
||||||
|
statusCode: 200,
|
||||||
|
body: responseBody,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(responseBody);
|
return NextResponse.json(responseBody);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||||
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
|
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
|
||||||
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -9,8 +10,8 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
generalApiRateLimiter.assertAllowed(`giveaway-get:${ip}`);
|
generalApiRateLimiter.assertAllowed(`giveaway-get:${clientIp}`);
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createSnapshotSchema, validateProviderCapabilities } from '@/core/valid
|
||||||
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
||||||
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
import { IdempotencyStore } from '@/lib/idempotency';
|
import { IdempotencyStore } from '@/lib/idempotency';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -13,16 +14,8 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${ip}:${id}`);
|
expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${clientIp}:${id}`);
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('idempotency-key');
|
|
||||||
if (idempotencyKey) {
|
|
||||||
const cached = IdempotencyStore.get(`lock-snap:${idempotencyKey}`);
|
|
||||||
if (cached) {
|
|
||||||
return NextResponse.json(cached.body, { status: cached.statusCode });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
|
|
@ -36,6 +29,19 @@ export async function POST(
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = createSnapshotSchema.parse(rawBody);
|
const validated = createSnapshotSchema.parse(rawBody);
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('idempotency-key');
|
||||||
|
if (idempotencyKey) {
|
||||||
|
const cached = IdempotencyStore.get({
|
||||||
|
key: idempotencyKey,
|
||||||
|
operation: 'snapshot-lock',
|
||||||
|
giveawayId: id,
|
||||||
|
requestPayload: validated,
|
||||||
|
});
|
||||||
|
if (cached) {
|
||||||
|
return NextResponse.json(cached.body, { status: cached.statusCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const provider = ProviderFactory.getVkProvider();
|
const provider = ProviderFactory.getVkProvider();
|
||||||
validateProviderCapabilities(validated.filterRules, provider.capabilities);
|
validateProviderCapabilities(validated.filterRules, provider.capabilities);
|
||||||
|
|
||||||
|
|
@ -64,7 +70,14 @@ export async function POST(
|
||||||
};
|
};
|
||||||
|
|
||||||
if (idempotencyKey) {
|
if (idempotencyKey) {
|
||||||
IdempotencyStore.set(`lock-snap:${idempotencyKey}`, 200, responseBody);
|
IdempotencyStore.set({
|
||||||
|
key: idempotencyKey,
|
||||||
|
operation: 'snapshot-lock',
|
||||||
|
giveawayId: id,
|
||||||
|
requestPayload: validated,
|
||||||
|
statusCode: 200,
|
||||||
|
body: responseBody,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(responseBody);
|
return NextResponse.json(responseBody);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { GiveawayStore } from '@/lib/giveaway-store';
|
||||||
import { verifyDrawResult } from '@/core/randomizer/deterministic';
|
import { verifyDrawResult } from '@/core/randomizer/deterministic';
|
||||||
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
||||||
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -10,8 +11,8 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
expensiveApiRateLimiter.assertAllowed(`verify-get:${ip}:${id}`);
|
expensiveApiRateLimiter.assertAllowed(`verify-get:${clientIp}:${id}`);
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ import { createGiveawaySchema } from '@/core/validation/giveaway-schemas';
|
||||||
import { handleApiError } from '@/core/errors/http-errors';
|
import { handleApiError } from '@/core/errors/http-errors';
|
||||||
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
import { IdempotencyStore } from '@/lib/idempotency';
|
import { IdempotencyStore } from '@/lib/idempotency';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
generalApiRateLimiter.assertAllowed(`giveaways-list:${ip}`);
|
generalApiRateLimiter.assertAllowed(`giveaways-list:${clientIp}`);
|
||||||
|
|
||||||
// Return lightweight summary for scalability (no massive participant/snapshot payloads)
|
// Return lightweight summary for scalability (no massive participant/snapshot payloads)
|
||||||
const summaries = await GiveawayStore.listSummaries();
|
const summaries = await GiveawayStore.listSummaries();
|
||||||
|
|
@ -24,20 +25,24 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
generalApiRateLimiter.assertAllowed(`giveaway-create:${ip}`);
|
generalApiRateLimiter.assertAllowed(`giveaway-create:${clientIp}`);
|
||||||
|
|
||||||
|
const rawBody = await req.json();
|
||||||
|
const validated = createGiveawaySchema.parse(rawBody);
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('idempotency-key');
|
const idempotencyKey = req.headers.get('idempotency-key');
|
||||||
if (idempotencyKey) {
|
if (idempotencyKey) {
|
||||||
const cached = IdempotencyStore.get(`create-gw:${idempotencyKey}`);
|
const cached = IdempotencyStore.get({
|
||||||
|
key: idempotencyKey,
|
||||||
|
operation: 'create-giveaway',
|
||||||
|
requestPayload: validated,
|
||||||
|
});
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return NextResponse.json(cached.body, { status: cached.statusCode });
|
return NextResponse.json(cached.body, { status: cached.statusCode });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawBody = await req.json();
|
|
||||||
const validated = createGiveawaySchema.parse(rawBody);
|
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.create({
|
const giveaway = await GiveawayStore.create({
|
||||||
sourceUrl: validated.sourceUrl,
|
sourceUrl: validated.sourceUrl,
|
||||||
post: validated.post,
|
post: validated.post,
|
||||||
|
|
@ -53,7 +58,13 @@ export async function POST(req: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (idempotencyKey) {
|
if (idempotencyKey) {
|
||||||
IdempotencyStore.set(`create-gw:${idempotencyKey}`, 201, responseBody);
|
IdempotencyStore.set({
|
||||||
|
key: idempotencyKey,
|
||||||
|
operation: 'create-giveaway',
|
||||||
|
requestPayload: validated,
|
||||||
|
statusCode: 201,
|
||||||
|
body: responseBody,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(responseBody, { status: 201 });
|
return NextResponse.json(responseBody, { status: 201 });
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import { ProviderFactory } from '@/providers/factory';
|
||||||
import { postPreviewSchema } from '@/core/validation/giveaway-schemas';
|
import { postPreviewSchema } from '@/core/validation/giveaway-schemas';
|
||||||
import { handleApiError } from '@/core/errors/http-errors';
|
import { handleApiError } from '@/core/errors/http-errors';
|
||||||
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { resolveClientIp } from '@/lib/client-ip';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
const clientIp = resolveClientIp(req);
|
||||||
generalApiRateLimiter.assertAllowed(`post-preview:${ip}`);
|
generalApiRateLimiter.assertAllowed(`post-preview:${clientIp}`);
|
||||||
|
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = postPreviewSchema.parse(rawBody);
|
const validated = postPreviewSchema.parse(rawBody);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,30 @@ export class ConflictError extends AppError {
|
||||||
readonly code = 'CONFLICT';
|
readonly code = 'CONFLICT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IdempotencyKeyReusedError extends AppError {
|
||||||
|
readonly statusCode = 409;
|
||||||
|
readonly code = 'IDEMPOTENCY_KEY_REUSED';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string = 'Idempotency key was previously used with different request parameters',
|
||||||
|
details?: any
|
||||||
|
) {
|
||||||
|
super(message, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DrawAlreadyCompletedError extends AppError {
|
||||||
|
readonly statusCode = 409;
|
||||||
|
readonly code = 'DRAW_ALREADY_COMPLETED';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string = 'Giveaway has already been drawn and finalized. Repeat draws are not permitted.',
|
||||||
|
details?: any
|
||||||
|
) {
|
||||||
|
super(message, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class RateLimitError extends AppError {
|
export class RateLimitError extends AppError {
|
||||||
readonly statusCode = 429;
|
readonly statusCode = 429;
|
||||||
readonly code = 'RATE_LIMIT_EXCEEDED';
|
readonly code = 'RATE_LIMIT_EXCEEDED';
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,15 @@ export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExe
|
||||||
// 2. Initialize unbiased HMAC stream keyed by seed and snapshotHash
|
// 2. Initialize unbiased HMAC stream keyed by seed and snapshotHash
|
||||||
const stream = new DeterministicHmacStream(seed, snapshotHash);
|
const stream = new DeterministicHmacStream(seed, snapshotHash);
|
||||||
|
|
||||||
const totalNeeded = Math.min(winnersCount + reserveWinnersCount, pool.length);
|
const totalNeeded = winnersCount + reserveWinnersCount;
|
||||||
const actualWinnersCount = Math.min(winnersCount, totalNeeded);
|
if (totalNeeded > pool.length) {
|
||||||
const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount);
|
throw new Error(
|
||||||
|
`Requested ${winnersCount} winners and ${reserveWinnersCount} reserve winners (${totalNeeded} total) exceeds eligible participants count (${pool.length})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualWinnersCount = winnersCount;
|
||||||
|
const actualReserveCount = reserveWinnersCount;
|
||||||
|
|
||||||
// 3. True partial Fisher-Yates shuffle: swap pool[i] with pool[j] where j in [i, n-1]
|
// 3. True partial Fisher-Yates shuffle: swap pool[i] with pool[j] where j in [i, n-1]
|
||||||
for (let i = 0; i < totalNeeded; i++) {
|
for (let i = 0; i < totalNeeded; i++) {
|
||||||
|
|
|
||||||
83
src/lib/client-ip.ts
Normal file
83
src/lib/client-ip.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const MAX_HEADER_LENGTH = 1024;
|
||||||
|
|
||||||
|
// Simple regex for basic IPv4 and IPv6 format checking
|
||||||
|
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
|
||||||
|
const IPV6_REGEX = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}$|^(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}$|^(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}$|^:(?::[0-9a-fA-F]{1,4}){1,7}$|^::$|^::1$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes an IP string (handling IPv6 brackets, ports, and ::ffff: mapped IPv4).
|
||||||
|
*/
|
||||||
|
export function normalizeIp(rawIp: string): string {
|
||||||
|
let ip = rawIp.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Strip brackets from IPv6 (e.g. [::1]:8080 or [::1])
|
||||||
|
if (ip.startsWith('[') && ip.includes(']')) {
|
||||||
|
ip = ip.substring(1, ip.indexOf(']'));
|
||||||
|
} else if (ip.startsWith('::ffff:')) {
|
||||||
|
// IPv4-mapped IPv6 (e.g., ::ffff:192.168.1.1)
|
||||||
|
const ipv4Part = ip.substring(7);
|
||||||
|
if (IPV4_REGEX.test(ipv4Part)) {
|
||||||
|
return ipv4Part;
|
||||||
|
}
|
||||||
|
} else if (ip.includes(':') && ip.includes('.')) {
|
||||||
|
// IPv4 with port (e.g. 192.168.1.1:3000)
|
||||||
|
const [host] = ip.split(':');
|
||||||
|
if (IPV4_REGEX.test(host)) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 loopback normalization
|
||||||
|
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
|
||||||
|
return '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the client identity IP address.
|
||||||
|
* Strictly ignores untrusted X-Forwarded-For headers unless TRUST_PROXY=true is configured.
|
||||||
|
*/
|
||||||
|
export function resolveClientIp(req: NextRequest): string {
|
||||||
|
const isTrustProxy = process.env.TRUST_PROXY === 'true';
|
||||||
|
|
||||||
|
if (!isTrustProxy) {
|
||||||
|
// When proxy is not trusted, ignore spoofable headers from the client
|
||||||
|
return req.ip || 'direct-client';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy is trusted: extract and validate header
|
||||||
|
const xForwardedFor = req.headers.get('x-forwarded-for');
|
||||||
|
const xRealIp = req.headers.get('x-real-ip');
|
||||||
|
const cfConnectingIp = req.headers.get('cf-connecting-ip');
|
||||||
|
|
||||||
|
const rawHeader = xForwardedFor || xRealIp || cfConnectingIp || req.ip;
|
||||||
|
|
||||||
|
if (!rawHeader) {
|
||||||
|
return 'unknown-client';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawHeader.length > MAX_HEADER_LENGTH) {
|
||||||
|
// Oversized header attack guard
|
||||||
|
return 'malformed-oversized-ip';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multi-value proxy chains: "client, proxy1, proxy2"
|
||||||
|
// The first (leftmost) entry is the client-reported IP
|
||||||
|
const parts = rawHeader.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return 'unknown-client';
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientCandidate = normalizeIp(parts[0]);
|
||||||
|
|
||||||
|
// Validate that the parsed string is a legitimate IPv4 or IPv6 address
|
||||||
|
if (IPV4_REGEX.test(clientCandidate) || IPV6_REGEX.test(clientCandidate)) {
|
||||||
|
return clientCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'malformed-client-ip';
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,156 @@
|
||||||
interface IdempotentResponse {
|
import { canonicalStringify, sha256 } from '@/core/randomizer/canonical';
|
||||||
|
import { IdempotencyKeyReusedError, ValidationError } from '@/core/errors/http-errors';
|
||||||
|
|
||||||
|
export interface IdempotentResponse {
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
body: any;
|
body: any;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
requestFingerprint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IdempotencyStore {
|
export interface IdempotencyLookupParams {
|
||||||
private static store = new Map<string, IdempotentResponse>();
|
key: string;
|
||||||
private static readonly TTL_MS = 5 * 60 * 1000; // 5 minutes
|
operation: string;
|
||||||
|
giveawayId?: string;
|
||||||
|
requestPayload?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdempotencySaveParams extends IdempotencyLookupParams {
|
||||||
|
statusCode: number;
|
||||||
|
body: any;
|
||||||
|
ttlMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIdempotencyStore {
|
||||||
|
get(params: IdempotencyLookupParams): IdempotentResponse | null;
|
||||||
|
set(params: IdempotencySaveParams): void;
|
||||||
|
clear(): void;
|
||||||
|
size(): number;
|
||||||
|
cleanupExpired(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryIdempotencyStore implements IIdempotencyStore {
|
||||||
|
private store = new Map<string, IdempotentResponse>();
|
||||||
|
private readonly defaultTtlMs: number;
|
||||||
|
private readonly maxKeyLength: number;
|
||||||
|
private readonly maxEntries: number;
|
||||||
|
private opCounter = 0;
|
||||||
|
|
||||||
|
constructor(options?: { defaultTtlMs?: number; maxKeyLength?: number; maxEntries?: number }) {
|
||||||
|
this.defaultTtlMs = options?.defaultTtlMs ?? 5 * 60 * 1000; // 5 minutes default
|
||||||
|
this.maxKeyLength = options?.maxKeyLength ?? 128;
|
||||||
|
this.maxEntries = options?.maxEntries ?? 10000;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' && process.env.ALLOW_MEMORY_IDEMPOTENCY !== 'true') {
|
||||||
|
console.warn(
|
||||||
|
'[SECURITY WARNING] MemoryIdempotencyStore is active in production. ' +
|
||||||
|
'In multi-instance deployments, use a shared distributed store (e.g., Redis/KV) to prevent race conditions.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateKey(key: string): void {
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
throw new ValidationError('Idempotency-Key must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (key.length > this.maxKeyLength) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Idempotency-Key length exceeds maximum allowed (${this.maxKeyLength} characters)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCompositeKey(operation: string, giveawayId?: string, key?: string): string {
|
||||||
|
return `${operation}:${giveawayId || 'global'}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeFingerprint(payload: any): string {
|
||||||
|
return sha256(canonicalStringify(payload ?? {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(params: IdempotencyLookupParams): IdempotentResponse | null {
|
||||||
|
this.validateKey(params.key);
|
||||||
|
const compositeKey = this.buildCompositeKey(params.operation, params.giveawayId, params.key);
|
||||||
|
const cached = this.store.get(compositeKey);
|
||||||
|
|
||||||
public static get(key: string): IdempotentResponse | null {
|
|
||||||
const cached = this.store.get(key);
|
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
|
|
||||||
if (Date.now() - cached.createdAt > this.TTL_MS) {
|
// Check TTL expiration
|
||||||
this.store.delete(key);
|
if (Date.now() > cached.expiresAt) {
|
||||||
|
this.store.delete(compositeKey);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify request fingerprint matches original request payload
|
||||||
|
const currentFingerprint = this.computeFingerprint(params.requestPayload);
|
||||||
|
if (cached.requestFingerprint !== currentFingerprint) {
|
||||||
|
throw new IdempotencyKeyReusedError(
|
||||||
|
`Idempotency key "${params.key}" was previously used for operation "${params.operation}" with different request parameters.`,
|
||||||
|
{
|
||||||
|
key: params.key,
|
||||||
|
operation: params.operation,
|
||||||
|
giveawayId: params.giveawayId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static set(key: string, statusCode: number, body: any): void {
|
public set(params: IdempotencySaveParams): void {
|
||||||
this.store.set(key, {
|
this.validateKey(params.key);
|
||||||
statusCode,
|
const compositeKey = this.buildCompositeKey(params.operation, params.giveawayId, params.key);
|
||||||
body,
|
const fingerprint = this.computeFingerprint(params.requestPayload);
|
||||||
createdAt: Date.now(),
|
const now = Date.now();
|
||||||
|
const ttl = params.ttlMs ?? this.defaultTtlMs;
|
||||||
|
|
||||||
|
// Proactive cleanup
|
||||||
|
this.opCounter++;
|
||||||
|
if (this.opCounter % 100 === 0 || this.store.size >= this.maxEntries) {
|
||||||
|
this.cleanupExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still at capacity after cleanup, evict oldest entry
|
||||||
|
if (this.store.size >= this.maxEntries) {
|
||||||
|
const oldestKey = this.store.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.store.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.set(compositeKey, {
|
||||||
|
statusCode: params.statusCode,
|
||||||
|
body: params.body,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + ttl,
|
||||||
|
requestFingerprint: fingerprint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static clear(): void {
|
public cleanupExpired(): number {
|
||||||
|
const now = Date.now();
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
for (const [k, v] of this.store.entries()) {
|
||||||
|
if (now > v.expiresAt) {
|
||||||
|
this.store.delete(k);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
|
this.opCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public size(): number {
|
||||||
|
return this.store.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global Singleton Instance
|
||||||
|
export const IdempotencyStore: IIdempotencyStore = new MemoryIdempotencyStore();
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,57 @@ import { RateLimitError } from '../core/errors/http-errors';
|
||||||
|
|
||||||
interface RateLimitRecord {
|
interface RateLimitRecord {
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
|
lastAccessed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RateLimiterOptions {
|
export interface RateLimiterOptions {
|
||||||
windowMs: number;
|
windowMs: number;
|
||||||
maxRequests: number;
|
maxRequests: number;
|
||||||
|
maxBuckets?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SlidingWindowRateLimiter {
|
export class SlidingWindowRateLimiter {
|
||||||
private records = new Map<string, RateLimitRecord>();
|
private records = new Map<string, RateLimitRecord>();
|
||||||
private readonly windowMs: number;
|
private readonly windowMs: number;
|
||||||
private readonly maxRequests: number;
|
private readonly maxRequests: number;
|
||||||
|
private readonly maxBuckets: number;
|
||||||
|
private opCounter = 0;
|
||||||
|
|
||||||
constructor(options: RateLimiterOptions) {
|
constructor(options: RateLimiterOptions) {
|
||||||
this.windowMs = options.windowMs;
|
this.windowMs = options.windowMs;
|
||||||
this.maxRequests = options.maxRequests;
|
this.maxRequests = options.maxRequests;
|
||||||
|
this.maxBuckets = options.maxBuckets ?? 50000;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' && process.env.ALLOW_MEMORY_RATE_LIMITER !== 'true') {
|
||||||
|
console.warn(
|
||||||
|
'[SECURITY WARNING] SlidingWindowRateLimiter (In-Memory) is active in production. ' +
|
||||||
|
'In multi-instance deployments, use a distributed edge/Redis limiter.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public check(key: string): { allowed: boolean; remaining: number; resetInMs: number } {
|
public check(key: string): { allowed: boolean; remaining: number; resetInMs: number } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowStart = now - this.windowMs;
|
const windowStart = now - this.windowMs;
|
||||||
|
|
||||||
|
this.opCounter++;
|
||||||
|
if (this.opCounter % 200 === 0 || this.records.size >= this.maxBuckets) {
|
||||||
|
this.cleanupExpired();
|
||||||
|
}
|
||||||
|
|
||||||
let record = this.records.get(key);
|
let record = this.records.get(key);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
record = { timestamps: [] };
|
if (this.records.size >= this.maxBuckets) {
|
||||||
|
const oldestKey = this.records.keys().next().value;
|
||||||
|
if (oldestKey) this.records.delete(oldestKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
record = { timestamps: [], lastAccessed: now };
|
||||||
this.records.set(key, record);
|
this.records.set(key, record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record.lastAccessed = now;
|
||||||
|
|
||||||
// Purge timestamps outside current window
|
// Purge timestamps outside current window
|
||||||
record.timestamps = record.timestamps.filter(ts => ts > windowStart);
|
record.timestamps = record.timestamps.filter(ts => ts > windowStart);
|
||||||
|
|
||||||
|
|
@ -53,8 +77,29 @@ export class SlidingWindowRateLimiter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public cleanupExpired(): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - this.windowMs;
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
for (const [k, v] of this.records.entries()) {
|
||||||
|
v.timestamps = v.timestamps.filter(ts => ts > windowStart);
|
||||||
|
if (v.timestamps.length === 0 && now - v.lastAccessed > this.windowMs) {
|
||||||
|
this.records.delete(k);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.records.clear();
|
this.records.clear();
|
||||||
|
this.opCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public size(): number {
|
||||||
|
return this.records.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
85
tests/client-ip-resolver.test.ts
Normal file
85
tests/client-ip-resolver.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { resolveClientIp, normalizeIp } from '../src/lib/client-ip';
|
||||||
|
import { SlidingWindowRateLimiter } from '../src/lib/rate-limiter';
|
||||||
|
|
||||||
|
describe('Client IP Resolution & Rate Limiter Identity', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly ignores forged X-Forwarded-For when TRUST_PROXY is not enabled', () => {
|
||||||
|
const req = new NextRequest('http://localhost/api/test', {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '198.51.100.25',
|
||||||
|
'x-real-ip': '203.0.113.195',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = resolveClientIp(req);
|
||||||
|
expect(ip).not.toBe('198.51.100.25');
|
||||||
|
expect(ip).not.toBe('203.0.113.195');
|
||||||
|
expect(ip).toBe('direct-client');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts and normalizes client IP when TRUST_PROXY=true is set', () => {
|
||||||
|
process.env.TRUST_PROXY = 'true';
|
||||||
|
|
||||||
|
const req = new NextRequest('http://localhost/api/test', {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '203.0.113.50, 198.51.100.1, 192.0.2.1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = resolveClientIp(req);
|
||||||
|
expect(ip).toBe('203.0.113.50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes IPv6 and IPv4-mapped IPv6 correctly', () => {
|
||||||
|
expect(normalizeIp('::1')).toBe('127.0.0.1');
|
||||||
|
expect(normalizeIp('[::1]:8080')).toBe('127.0.0.1');
|
||||||
|
expect(normalizeIp('::ffff:192.168.1.10')).toBe('192.168.1.10');
|
||||||
|
expect(normalizeIp('192.168.1.1:3000')).toBe('192.168.1.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized headers when TRUST_PROXY=true', () => {
|
||||||
|
process.env.TRUST_PROXY = 'true';
|
||||||
|
|
||||||
|
const oversizedHeader = '1.1.1.1, ' + 'a'.repeat(1100);
|
||||||
|
const req = new NextRequest('http://localhost/api/test', {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': oversizedHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = resolveClientIp(req);
|
||||||
|
expect(ip).toBe('malformed-oversized-ip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate limiter proactively cleans up expired buckets without unbounded growth', async () => {
|
||||||
|
const limiter = new SlidingWindowRateLimiter({
|
||||||
|
windowMs: 30,
|
||||||
|
maxRequests: 5,
|
||||||
|
maxBuckets: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
limiter.check(`ip-${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(limiter.size()).toBe(40);
|
||||||
|
|
||||||
|
// Wait for window to expire
|
||||||
|
await new Promise(r => setTimeout(r, 45));
|
||||||
|
|
||||||
|
const cleaned = limiter.cleanupExpired();
|
||||||
|
expect(cleaned).toBeGreaterThanOrEqual(40);
|
||||||
|
expect(limiter.size()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
136
tests/concurrency-draw-100.test.ts
Normal file
136
tests/concurrency-draw-100.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||||
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||||
|
import { FilteredParticipant } from '../src/core/types/participant';
|
||||||
|
import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic';
|
||||||
|
import { ConflictError } from '../src/core/errors/http-errors';
|
||||||
|
|
||||||
|
describe('100-Draw Concurrency Regression & Mixed Race', () => {
|
||||||
|
const participants: FilteredParticipant[] = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
platformUserId: `user_${1000 + i}`,
|
||||||
|
firstName: 'User',
|
||||||
|
lastName: `${i + 1}`,
|
||||||
|
source: 'LIKES',
|
||||||
|
liked: true,
|
||||||
|
commented: false,
|
||||||
|
commentsCount: 0,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: true,
|
||||||
|
eligible: true,
|
||||||
|
exclusionReason: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('100 concurrent draw attempts result in exactly 1 success and 99 conflicts with 1 DrawResult', async () => {
|
||||||
|
const repo = new MemoryGiveawayRepository();
|
||||||
|
|
||||||
|
const gw = await repo.createGiveaway({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_100',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '100',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_100',
|
||||||
|
title: '100 Concurrency Test',
|
||||||
|
likesCount: 50,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
winnersCount: 3,
|
||||||
|
reserveWinnersCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES);
|
||||||
|
|
||||||
|
// Launch 100 concurrent draw requests
|
||||||
|
const drawPromises = Array.from({ length: 100 }, async (_, index) => {
|
||||||
|
try {
|
||||||
|
const seed = `seed-concurrent-100-${index}`;
|
||||||
|
const drawResult = executeDeterministicDrawV1({
|
||||||
|
giveawayId: gw.id,
|
||||||
|
snapshot,
|
||||||
|
totalLoadedCount: 50,
|
||||||
|
winnersCount: 3,
|
||||||
|
reserveWinnersCount: 1,
|
||||||
|
seed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult);
|
||||||
|
return { status: 200, result: saved };
|
||||||
|
} catch (err: any) {
|
||||||
|
if (
|
||||||
|
err instanceof ConflictError ||
|
||||||
|
err?.message?.includes('already been drawn') ||
|
||||||
|
err?.message?.includes('SNAPSHOT_LOCKED')
|
||||||
|
) {
|
||||||
|
return { status: 409, error: err.message };
|
||||||
|
}
|
||||||
|
return { status: 500, error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(drawPromises);
|
||||||
|
|
||||||
|
const successCount = results.filter(r => r.status === 200).length;
|
||||||
|
const conflictCount = results.filter(r => r.status === 409).length;
|
||||||
|
const errorCount = results.filter(r => r.status === 500).length;
|
||||||
|
|
||||||
|
expect(successCount).toBe(1);
|
||||||
|
expect(conflictCount).toBe(99);
|
||||||
|
expect(errorCount).toBe(0);
|
||||||
|
|
||||||
|
const finalized = await repo.getGiveawayById(gw.id);
|
||||||
|
expect(finalized?.status).toBe('DRAWN');
|
||||||
|
expect(finalized?.drawResult).toBeDefined();
|
||||||
|
expect(finalized?.drawResult?.winners.length).toBe(3);
|
||||||
|
expect(finalized?.drawResult?.reserveWinners.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed race between participant mutation, snapshot locking, and draw preserves terminal integrity', async () => {
|
||||||
|
const repo = new MemoryGiveawayRepository();
|
||||||
|
|
||||||
|
const gw = await repo.createGiveaway({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_200',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '200',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_200',
|
||||||
|
title: 'Mixed Race Test',
|
||||||
|
likesCount: 50,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
winnersCount: 1,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.saveParticipants(gw.id, participants);
|
||||||
|
|
||||||
|
// Launch mixed simultaneous actions
|
||||||
|
const actions = [
|
||||||
|
repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES).catch(e => ({ error: e.message })),
|
||||||
|
repo.saveParticipants(gw.id, participants.slice(0, 10)).catch(e => ({ error: e.message })),
|
||||||
|
(async () => {
|
||||||
|
const snap = await repo.getLatestSnapshot(gw.id);
|
||||||
|
if (!snap) return { skipped: true };
|
||||||
|
const drawResult = executeDeterministicDrawV1({
|
||||||
|
giveawayId: gw.id,
|
||||||
|
snapshot: snap,
|
||||||
|
totalLoadedCount: 50,
|
||||||
|
winnersCount: 1,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
seed: 'seed-mixed',
|
||||||
|
});
|
||||||
|
return repo.saveDrawResultAndAudit(gw.id, snap.id, drawResult).catch(e => ({ error: e.message }));
|
||||||
|
})(),
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.allSettled(actions);
|
||||||
|
|
||||||
|
const finalState = await repo.getGiveawayById(gw.id);
|
||||||
|
expect(finalState).not.toBeNull();
|
||||||
|
expect(['READY', 'SNAPSHOT_LOCKED', 'DRAWN']).toContain(finalState?.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
144
tests/idempotency-hardening.test.ts
Normal file
144
tests/idempotency-hardening.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { MemoryIdempotencyStore } from '../src/lib/idempotency';
|
||||||
|
import { IdempotencyKeyReusedError, ValidationError } from '../src/core/errors/http-errors';
|
||||||
|
|
||||||
|
describe('Idempotency Hardening & Request Fingerprinting', () => {
|
||||||
|
let store: MemoryIdempotencyStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new MemoryIdempotencyStore({ defaultTtlMs: 1000, maxKeyLength: 128, maxEntries: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached response for same key and identical request payload', () => {
|
||||||
|
const key = 'test-key-1';
|
||||||
|
const payload = { sourceUrl: 'https://vk.com/wall-1_1', winnersCount: 1 };
|
||||||
|
const responseBody = { success: true, giveawayId: 'gw-1' };
|
||||||
|
|
||||||
|
store.set({
|
||||||
|
key,
|
||||||
|
operation: 'create-giveaway',
|
||||||
|
giveawayId: 'gw-1',
|
||||||
|
requestPayload: payload,
|
||||||
|
statusCode: 201,
|
||||||
|
body: responseBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = store.get({
|
||||||
|
key,
|
||||||
|
operation: 'create-giveaway',
|
||||||
|
giveawayId: 'gw-1',
|
||||||
|
requestPayload: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cached).not.toBeNull();
|
||||||
|
expect(cached?.statusCode).toBe(201);
|
||||||
|
expect(cached?.body).toEqual(responseBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws IdempotencyKeyReusedError (409) when same key is used with different payload', () => {
|
||||||
|
const key = 'test-key-reused';
|
||||||
|
const originalPayload = { sourceUrl: 'https://vk.com/wall-1_1', winnersCount: 1 };
|
||||||
|
const modifiedPayload = { sourceUrl: 'https://vk.com/wall-1_1', winnersCount: 5 }; // Changed!
|
||||||
|
|
||||||
|
store.set({
|
||||||
|
key,
|
||||||
|
operation: 'create-giveaway',
|
||||||
|
giveawayId: 'gw-1',
|
||||||
|
requestPayload: originalPayload,
|
||||||
|
statusCode: 201,
|
||||||
|
body: { success: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
store.get({
|
||||||
|
key,
|
||||||
|
operation: 'create-giveaway',
|
||||||
|
giveawayId: 'gw-1',
|
||||||
|
requestPayload: modifiedPayload,
|
||||||
|
})
|
||||||
|
).toThrow(IdempotencyKeyReusedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents collision across different operations or giveaways with identical key', () => {
|
||||||
|
const key = 'shared-key-id';
|
||||||
|
const payload = { test: 123 };
|
||||||
|
|
||||||
|
store.set({
|
||||||
|
key,
|
||||||
|
operation: 'operation-A',
|
||||||
|
giveawayId: 'gw-1',
|
||||||
|
requestPayload: payload,
|
||||||
|
statusCode: 200,
|
||||||
|
body: { op: 'A' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lookup under operation-B with same key must return null
|
||||||
|
const resB = store.get({
|
||||||
|
key,
|
||||||
|
operation: 'operation-B',
|
||||||
|
giveawayId: 'gw-1',
|
||||||
|
requestPayload: payload,
|
||||||
|
});
|
||||||
|
expect(resB).toBeNull();
|
||||||
|
|
||||||
|
// Lookup under different giveaway must return null
|
||||||
|
const resGw2 = store.get({
|
||||||
|
key,
|
||||||
|
operation: 'operation-A',
|
||||||
|
giveawayId: 'gw-2',
|
||||||
|
requestPayload: payload,
|
||||||
|
});
|
||||||
|
expect(resGw2).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects keys exceeding maximum allowed length', () => {
|
||||||
|
const oversizedKey = 'a'.repeat(129);
|
||||||
|
expect(() =>
|
||||||
|
store.get({
|
||||||
|
key: oversizedKey,
|
||||||
|
operation: 'op',
|
||||||
|
requestPayload: {},
|
||||||
|
})
|
||||||
|
).toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up expired entries proactively', async () => {
|
||||||
|
const shortTtlStore = new MemoryIdempotencyStore({ defaultTtlMs: 20 });
|
||||||
|
shortTtlStore.set({
|
||||||
|
key: 'expiring-key',
|
||||||
|
operation: 'op',
|
||||||
|
requestPayload: { a: 1 },
|
||||||
|
statusCode: 200,
|
||||||
|
body: { ok: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shortTtlStore.size()).toBe(1);
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 40));
|
||||||
|
|
||||||
|
const res = shortTtlStore.get({
|
||||||
|
key: 'expiring-key',
|
||||||
|
operation: 'op',
|
||||||
|
requestPayload: { a: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles bounded capacity with synthetic keys without memory leak', () => {
|
||||||
|
const boundedStore = new MemoryIdempotencyStore({ maxEntries: 50, defaultTtlMs: 10000 });
|
||||||
|
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
boundedStore.set({
|
||||||
|
key: `synth-key-${i}`,
|
||||||
|
operation: 'op',
|
||||||
|
requestPayload: { index: i },
|
||||||
|
statusCode: 200,
|
||||||
|
body: { i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity must not exceed maxEntries
|
||||||
|
expect(boundedStore.size()).toBeLessThanOrEqual(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,15 +4,32 @@ import { IdempotencyStore } from '../src/lib/idempotency';
|
||||||
describe('Idempotency Key Store', () => {
|
describe('Idempotency Key Store', () => {
|
||||||
it('should store and return cached idempotent response', () => {
|
it('should store and return cached idempotent response', () => {
|
||||||
const key = 'test-idemp-key-1';
|
const key = 'test-idemp-key-1';
|
||||||
const payload = { result: 'ok', id: '123' };
|
const requestPayload = { result: 'ok', id: '123' };
|
||||||
|
const responsePayload = { success: true, createdId: '123' };
|
||||||
|
|
||||||
expect(IdempotencyStore.get(key)).toBeNull();
|
expect(
|
||||||
|
IdempotencyStore.get({
|
||||||
|
key,
|
||||||
|
operation: 'test-op',
|
||||||
|
requestPayload,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
IdempotencyStore.set(key, 201, payload);
|
IdempotencyStore.set({
|
||||||
|
key,
|
||||||
|
operation: 'test-op',
|
||||||
|
requestPayload,
|
||||||
|
statusCode: 201,
|
||||||
|
body: responsePayload,
|
||||||
|
});
|
||||||
|
|
||||||
const cached = IdempotencyStore.get(key);
|
const cached = IdempotencyStore.get({
|
||||||
|
key,
|
||||||
|
operation: 'test-op',
|
||||||
|
requestPayload,
|
||||||
|
});
|
||||||
expect(cached).not.toBeNull();
|
expect(cached).not.toBeNull();
|
||||||
expect(cached?.statusCode).toBe(201);
|
expect(cached?.statusCode).toBe(201);
|
||||||
expect(cached?.body).toEqual(payload);
|
expect(cached?.body).toEqual(responsePayload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -131,22 +131,35 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
|
||||||
expect(uniqueIds.size).toBe(10);
|
expect(uniqueIds.size).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle small pool sizes gracefully', () => {
|
it('should strictly enforce winner count contract (never silently reduce winners count)', () => {
|
||||||
const snapshot = createMockSnapshot(2);
|
const snapshot = createMockSnapshot(2);
|
||||||
const seed = 'small-pool-seed';
|
const seed = 'small-pool-seed';
|
||||||
|
|
||||||
const draw = executeDeterministicDrawV1({
|
// Requesting 5 winners on 2 eligible participants must throw
|
||||||
giveawayId: 'gw-small',
|
expect(() =>
|
||||||
|
executeDeterministicDrawV1({
|
||||||
|
giveawayId: 'gw-small',
|
||||||
|
snapshot,
|
||||||
|
totalLoadedCount: 2,
|
||||||
|
winnersCount: 5,
|
||||||
|
reserveWinnersCount: 3,
|
||||||
|
seed,
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
})
|
||||||
|
).toThrow(/exceeds eligible participants count/i);
|
||||||
|
|
||||||
|
// Requesting exactly 2 winners on 2 eligible participants succeeds
|
||||||
|
const validDraw = executeDeterministicDrawV1({
|
||||||
|
giveawayId: 'gw-small-valid',
|
||||||
snapshot,
|
snapshot,
|
||||||
totalLoadedCount: 2,
|
totalLoadedCount: 2,
|
||||||
winnersCount: 5,
|
winnersCount: 2,
|
||||||
reserveWinnersCount: 3,
|
reserveWinnersCount: 0,
|
||||||
seed,
|
seed,
|
||||||
filterRules: DEFAULT_FILTER_RULES,
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
});
|
});
|
||||||
|
expect(validDraw.winners.length).toBe(2);
|
||||||
expect(draw.winners.length).toBe(2);
|
expect(validDraw.reserveWinners.length).toBe(0);
|
||||||
expect(draw.reserveWinners.length).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow third-party audit replay verification via verifyDrawResult', () => {
|
it('should allow third-party audit replay verification via verifyDrawResult', () => {
|
||||||
|
|
|
||||||
169
tests/winner-count-contract.test.ts
Normal file
169
tests/winner-count-contract.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||||
|
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||||
|
import { ProviderRegistry } from '../src/providers/registry';
|
||||||
|
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
||||||
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||||
|
import { FilteredParticipant } from '../src/core/types/participant';
|
||||||
|
import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic';
|
||||||
|
|
||||||
|
describe('Winner Count Contract & Draw Retry Invariants', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||||
|
ProviderRegistry.useMockVk();
|
||||||
|
});
|
||||||
|
|
||||||
|
const threeParticipants: FilteredParticipant[] = Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
platformUserId: `user_${i + 1}`,
|
||||||
|
firstName: 'User',
|
||||||
|
lastName: `${i + 1}`,
|
||||||
|
source: 'LIKES',
|
||||||
|
liked: true,
|
||||||
|
commented: false,
|
||||||
|
commentsCount: 0,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: true,
|
||||||
|
eligible: true,
|
||||||
|
exclusionReason: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('eligible=3, winners=3, reserve=0 -> success', async () => {
|
||||||
|
const gw = await GiveawayStore.create({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_1',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '1',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_1',
|
||||||
|
title: 'Title',
|
||||||
|
likesCount: 3,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
winnersCount: 3,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||||
|
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES);
|
||||||
|
|
||||||
|
const result = executeDeterministicDrawV1({
|
||||||
|
giveawayId: gw.id,
|
||||||
|
snapshot,
|
||||||
|
totalLoadedCount: 3,
|
||||||
|
winnersCount: 3,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
seed: 'test-seed-3-3-0',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.winners.length).toBe(3);
|
||||||
|
expect(result.reserveWinners.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eligible=3, winners=4, reserve=0 -> error (never silently reduces winners count)', async () => {
|
||||||
|
const gw = await GiveawayStore.create({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_2',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '2',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_2',
|
||||||
|
title: 'Title',
|
||||||
|
likesCount: 3,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
winnersCount: 4,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||||
|
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
executeDeterministicDrawV1({
|
||||||
|
giveawayId: gw.id,
|
||||||
|
snapshot,
|
||||||
|
totalLoadedCount: 3,
|
||||||
|
winnersCount: 4,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
seed: 'test-seed-3-4-0',
|
||||||
|
})
|
||||||
|
).toThrow(/exceeds eligible participants count/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eligible=3, winners=3, reserve=3 -> error (total 6 exceeds pool of 3)', async () => {
|
||||||
|
const gw = await GiveawayStore.create({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_3',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '3',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_3',
|
||||||
|
title: 'Title',
|
||||||
|
likesCount: 3,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
winnersCount: 3,
|
||||||
|
reserveWinnersCount: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||||
|
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
executeDeterministicDrawV1({
|
||||||
|
giveawayId: gw.id,
|
||||||
|
snapshot,
|
||||||
|
totalLoadedCount: 3,
|
||||||
|
winnersCount: 3,
|
||||||
|
reserveWinnersCount: 3,
|
||||||
|
seed: 'test-seed-3-3-3',
|
||||||
|
})
|
||||||
|
).toThrow(/exceeds eligible participants count/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeat draw on already DRAWN giveaway returns 409 DRAW_ALREADY_COMPLETED', async () => {
|
||||||
|
const gw = await GiveawayStore.create({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_4',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '4',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_4',
|
||||||
|
title: 'Title',
|
||||||
|
likesCount: 3,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
});
|
||||||
|
|
||||||
|
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||||
|
await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES);
|
||||||
|
|
||||||
|
// First draw
|
||||||
|
const req1 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||||
|
});
|
||||||
|
const res1 = await drawPost(req1, { params: { id: gw.id } });
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
|
||||||
|
// Second draw -> MUST return 409 DRAW_ALREADY_COMPLETED
|
||||||
|
const req2 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||||
|
});
|
||||||
|
const res2 = await drawPost(req2, { params: { id: gw.id } });
|
||||||
|
expect(res2.status).toBe(409);
|
||||||
|
|
||||||
|
const body = await res2.json();
|
||||||
|
expect(body.error?.code).toBe('DRAW_ALREADY_COMPLETED');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue