randomayzer/docs/PRODUCTION_GUARDS.md

7.5 KiB

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

Client Identity Scoping Architecture

  • Authenticated Routes (/api/giveaways*): Rate limits are keyed strictly by trusted server-side sessionUser.id (e.g. draw-execute:${sessionUser.id}:${id}, giveaways-list:${sessionUser.id}) after session authentication and ownership checks. This ensures:
    1. Different organizers have isolated rate limit buckets and never block each other, even when req.ip is unpopulated.
    2. Unauthenticated attackers receive 401 Unauthorized before reaching the rate limiter and cannot drain any organizer's quota.
  • Anonymous / Hybrid Routes (/api/posts/preview, /api/auth/vk/start, /api/giveaways/[id]/verify):
    • POST /api/posts/preview: Uses post-preview:user:${sessionUser.id} if a valid session exists, and post-preview:anon:${clientIp} if anonymous. An anonymous attacker consuming the IP limit cannot affect authenticated organizers.
    • GET /api/auth/vk/start: Rate-limited per resolved client IP (oauth-start:${clientIp}).
    • GET /api/giveaways/[id]/verify: Rate-limited per resolved client IP and giveaway (verify-get:${clientIp}:${id}).

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 req.ip is used.
    • Production Behavior: If NODE_ENV=production, TRUST_PROXY !== 'true', and direct req.ip is unavailable (e.g. in self-hosted Node.js / next start behind a reverse proxy), the server emits a [SECURITY CONFIGURATION WARNING] and falls back to 'direct-client'. For production deployments behind reverse proxies, setting TRUST_PROXY=true is required.
  • 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.

Supported production ingress profiles

The stage-one Docker deployment uses tools/serve-production.cjs as its production entry point. It checks the actual Node socket peer before forwarding a request to Next, removes client-supplied proxy identity headers, and rebuilds Host and X-Forwarded-* from the configured canonical origin. In the strict domain profile, forwarded client IP and HTTPS scheme are accepted only when socket.remoteAddress is the configured VPS peer 10.77.0.1; the reverse proxy must overwrite those headers. The local local-single-user profile accepts only HTTP loopback origins and emits a non-Secure session cookie only for that explicit profile. All other production profiles still require HTTPS and Secure cookies.

This socket-aware boundary is specific to tools/serve-production.cjs and the supplied Compose profiles. The generic TRUST_PROXY=true behavior described above parses forwarded chains and is safe only when an upstream proxy already sanitizes them. Do not run next start with TRUST_PROXY=true as a substitute for this entry point, and do not weaken the HTTPS or cookie checks globally. A deployment using another entry point must separately establish and test its trusted socket boundary.

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:
    {
      "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 in development/test; always forbidden in production
VK_SERVICE_TOKEN string undefined VK Application Service Access Token