<!-- PART OF: ARCHITECTURE.md — The 20-Minute Hero Complete Architecture -->
<!-- DOCUMENT: 08-security-moderation-testing.md -->
<!-- CONTENTS: Rate Limiting (§26), Content Moderation (§27), Deployment Strategy (§28), Testing Strategy (§29) -->
<!-- SPLIT: lines 10474–12583 of original file -->

## Section 26 — API Rate Limiting Strategy

### 26.1 Overview

Rate limiting uses **custom Hono middleware** backed by **ioredis**. Two key strategies:

- **IP-based** — for unauthenticated endpoints (auth, registration)
- **User ID-based** — for authenticated endpoints (prevents account sharing bypass)
- **game_config hot-reload** — limits can be changed at runtime without redeploy

### 26.2 Rate Limit Middleware

```typescript
// apps/api/src/core/rate-limit.ts

import { createMiddleware } from 'hono/factory'
import type { Context } from 'hono'
import { redis } from '../db/redis'
import { getGameConfigValue } from './config'

/**
 * Returns the rate limit key for a request.
 * For authenticated requests: use user_id (prevents IP rotation bypass).
 * For anonymous requests: fall back to IP.
 */
function getRateLimitKey(c: Context): string {
  const user = c.get('user')
  if (user?.id) {
    return `user:${user.id}`
  }
  return c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown'
}

// --- game_config-driven limit loader ---

const _LIMIT_CACHE: Record<string, { max: number; windowSeconds: number }> = {}
const LIMIT_CACHE_TTL_MS = 60_000  // re-read game_config every 60s

export async function loadLimit(
  key: string,
  fallbackMax: number,
  fallbackWindow: number,
): Promise<{ max: number; windowSeconds: number }> {
  /**
   * Asynchronously loads rate limit config from game_config into _LIMIT_CACHE.
   * Call this from the Hono app startup handler for all dynamic limits.
   * key example: "rate_limits.quest_generate"
   * value example: { max: 3, windowSeconds: 3600 }
   */
  const value = await getGameConfigValue(key, { max: fallbackMax, windowSeconds: fallbackWindow })
  _LIMIT_CACHE[key] = value
  setTimeout(() => loadLimit(key, fallbackMax, fallbackWindow), LIMIT_CACHE_TTL_MS)
  return value
}

export function getLimitSync(
  key: string,
  fallbackMax: number,
  fallbackWindow: number,
): { max: number; windowSeconds: number } {
  /**
   * Synchronous cache read — safe to call from middleware factory.
   * The cache must be pre-populated by loadLimit() on app startup.
   * Falls back to provided fallback if key not yet cached.
   */
  return _LIMIT_CACHE[key] ?? { max: fallbackMax, windowSeconds: fallbackWindow }
}

/**
 * Per-user/IP rate limiter middleware using Redis fixed-window counter.
 */
export function rateLimit(key: string, maxRequests: number, windowSeconds: number) {
  return createMiddleware(async (c, next) => {
    const identifier = `ratelimit:${key}:${getRateLimitKey(c)}`

    const [requests] = await redis
      .multi()
      .incr(identifier)
      .expire(identifier, windowSeconds)
      .exec()

    if ((requests as number) > maxRequests) {
      const ttl = await redis.ttl(identifier)
      return c.json(
        {
          type: 'https://hero20.app/errors/rate-limit-exceeded',
          title: 'Too Many Requests',
          status: 429,
          detail: `Rate limit exceeded for ${key}`,
          retry_after: ttl > 0 ? ttl : windowSeconds,
        },
        429,
        { 'Retry-After': String(ttl > 0 ? ttl : windowSeconds) },
      )
    }

    await next()
  })
}
```

### 26.3 Per-Endpoint Limits Table

| Endpoint | Method | Limit | Key | Reason |
|---|---|---|---|---|
| `/auth/firebase` | POST | 10/min | IP | Brute force protection |
| `/auth/refresh` | POST | 20/min | user_id | Token rotation abuse |
| `/quests/nearby` | GET | 60/min | user_id | Map spam |
| `/quests/generate` | POST | 3/hour | user_id | Gemini cost control |
| `/quests/{id}/start` | POST | 10/hour | user_id | Quest farming |
| `/proofs/upload-url` | POST | 10/day | user_id | S3 cost + upload spam |
| `/proofs/{id}/submit` | POST | 10/day | user_id | One proof per attempt |
| `/proofs/{id}/vote` | POST | 50/day | user_id | Voting flood |
| `/characters/me` | GET | 120/min | user_id | Polling (mobile) |
| `/paths/*/advance` | POST | 5/hour | user_id | Path skip attempts |
| `/admin/*` | ALL | 30/min | user_id | Admin safety |

### 26.4 Applying Limits in Routers

```typescript
// apps/api/src/routes/quests.ts

import { Hono } from 'hono'
import { requireAuth } from '../middleware/auth'
import { rateLimit, getLimitSync } from '../core/rate-limit'

const quests = new Hono()

quests.post(
  '/generate',
  requireAuth,
  // getLimitSync() reads from the in-memory cache (_LIMIT_CACHE) which is
  // pre-loaded at app startup and refreshed every 60s via setTimeout.
  // Do NOT call async functions inside middleware factory — keep it synchronous.
  rateLimit('quest_generate', getLimitSync('rate_limits.quest_generate', 3, 3600).max, 3600),
  async (c) => {
    // handler
  },
)

quests.get(
  '/nearby',
  requireAuth,
  rateLimit('quests_nearby', 60, 60),  // static — not configurable (performance critical)
  async (c) => {
    // handler
  },
)

export { quests }
```

> **Note:** Unlike FastAPI/slowapi, Hono middleware does not require a `request` parameter to be declared explicitly — the context `c` provides full request access.

### 26.5 Redis Key Schema

The rate limiter manages keys using this pattern:

```
# Format: ratelimit:<endpoint_key>:<user_id|ip>
# Examples:
ratelimit:quest_generate:user:uuid-1234
ratelimit:quests_nearby:ip:192.168.1.1
ratelimit:proof_upload:user:uuid-5678
```

Manual inspection:
```bash
redis-cli KEYS "ratelimit:*" | head -20
redis-cli TTL "ratelimit:quest_generate:user:uuid-1234"
```

### 26.6 Rate Limit Error Handler Registration in index.ts

```typescript
// apps/api/src/index.ts

import { Hono } from 'hono'
import { secureHeaders } from 'hono/secure-headers'
import { cors } from 'hono/cors'
import { requestId } from 'hono/request-id'

const app = new Hono()

app.use(secureHeaders())
app.use(requestId())
app.use('/api/*', cors({
  origin: ['https://app.20hero.com', 'exp://'],
  allowHeaders: ['Authorization', 'Content-Type'],
}))

// Global error handler — catches all unhandled errors
app.onError((err, c) => {
  if (err instanceof AppError) {
    return c.json(
      {
        type: `https://hero20.app/errors/${err.code}`,
        title: err.title,
        status: err.statusCode,
        detail: err.message,
      },
      err.statusCode as StatusCode,
    )
  }
  console.error(err)
  return c.json({ type: 'https://hero20.app/errors/internal', status: 500 }, 500)
})
```

Custom RFC 7807 error format (replaces FastAPI exception handlers):

```typescript
// apps/api/src/core/errors.ts

export class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    public title: string,
    message: string,
  ) {
    super(message)
  }
}

export class RateLimitError extends AppError {
  constructor(detail: string, retryAfter: number) {
    super(429, 'rate-limit-exceeded', 'Too Many Requests', detail)
    this.retryAfter = retryAfter
  }
  retryAfter: number
}
```

### 26.7 Quest Generation Throttle (Special Case)

Quest generation is the most expensive endpoint (Gemini call + DB write). Beyond middleware rate limiting, add a **user-level DB check**:

```typescript
// Additional guard inside generateQuest() route handler

async function checkQuestGenerationQuota(userId: string, tx: DrizzleTransaction): Promise<void> {
  /**
   * Double-check: user hasn't exceeded daily quest generation quota.
   * Stored in DB (not only Redis) for auditability.
   */
  const dailyLimit = await getGameConfigValue('quests.daily_generate_limit', 5)

  const startOfDay = new Date()
  startOfDay.setUTCHours(0, 0, 0, 0)

  const [{ count }] = await tx
    .select({ count: sql<number>`count(*)` })
    .from(quests)
    .where(
      and(
        eq(quests.creatorId, userId),
        gte(quests.createdAt, startOfDay),
      ),
    )

  if (Number(count) >= dailyLimit) {
    throw new AppError(
      429,
      'daily-limit',
      'Daily Quest Generation Limit Reached',
      `You can generate at most ${dailyLimit} quests per day. Reset at midnight UTC.`,
    )
  }
}
```

### 26.8 game_config Keys for Rate Limits

Seed in `seed.ts`:

```typescript
// packages/db/src/seed.ts — game_config entries for rate limits

const RATE_LIMIT_CONFIGS = [
  { key: 'rate_limits.quest_generate',  value: { max: 3,  windowSeconds: 3600  }, description: 'Quest generation per user per hour' },
  { key: 'rate_limits.proof_upload',    value: { max: 10, windowSeconds: 86400 }, description: 'Proof upload URL requests per user per day' },
  { key: 'rate_limits.proof_submit',    value: { max: 10, windowSeconds: 86400 }, description: 'Proof submissions per user per day' },
  { key: 'rate_limits.vote',            value: { max: 50, windowSeconds: 86400 }, description: 'Community votes per user per day' },
  { key: 'rate_limits.path_advance',    value: { max: 5,  windowSeconds: 3600  }, description: 'Path step advances per user per hour' },
  { key: 'quests.daily_generate_limit', value: 5,                                 description: 'Max quests generated per user per day (DB check)' },
]
```

### 26.9 Mobile Client Handling

Mobile app should handle 429 gracefully:

```typescript
// services/api.ts — axios interceptor

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 429) {
      const retryAfter = parseInt(
        error.response.headers["retry-after"] ?? "60",
        10
      );
      // Show toast: "Przekroczono limit. Spróbuj za X sekund."
      useToastStore.getState().show({
        type: "warning",
        message: `Zbyt wiele prób. Odczekaj ${retryAfter}s.`,
      });
      // Do NOT auto-retry — let user decide
    }
    return Promise.reject(error);
  }
);
```

---

## Section 27 — Content Moderation Pipeline

### 27.1 Overview

Content moderation runs **after** proof verification (Section 4.3) as a separate, non-blocking background job (BullMQ queue). It applies only to visual/audio media (photo, multi_photo, video, audio) — text proofs are checked inline by the proof verification AI which already evaluates appropriateness.

Flow:
```
submitProof() → BullMQ: verifyProof → (PASS) → BullMQ: moderateProof [async, non-blocking]
                                     → update proof.status = "visible" after moderation clears
```

Text proofs skip the pipeline:
```
submitProof(text) → BullMQ: verifyProof → PASS → proof.status = "visible" immediately
```

### 27.2 Moderation Flags Schema (DDL)

```sql
-- Already in quest_proofs (see Section 1):
moderation_flags  JSONB NOT NULL DEFAULT '{}',
-- Example stored value:
-- {"safe": true, "nudity": false, "violence": false,
--  "children_faces": false, "license_plates": true,
--  "documents": false, "personal_data_on_screen": false,
--  "address_visible": false, "flagged_for_review": false}

moderation_status TEXT NOT NULL DEFAULT 'pending'
    CHECK (moderation_status IN ('pending', 'cleared', 'flagged', 'auto_hidden', 'admin_reviewed')),
moderation_note   TEXT DEFAULT NULL,   -- admin note after manual review
```

**Migration 003** (added after baseline 001 and proof expansion 002):

```sql
-- Migration 003: moderation fields
ALTER TABLE quest_proofs
    ADD COLUMN IF NOT EXISTS moderation_status TEXT NOT NULL DEFAULT 'pending'
        CHECK (moderation_status IN ('pending','cleared','flagged','auto_hidden','admin_reviewed')),
    ADD COLUMN IF NOT EXISTS moderation_note TEXT DEFAULT NULL;

-- proof is only shown in community feed after moderation clears
-- proof.visibility is set by user (private/friends/public) — separate from moderation
-- a proof can be visibility=public but moderation_status=auto_hidden → not shown
```

### 27.3 AI Moderation Prompt

Stored in `prompt_templates` under key `"content_moderation"`.

**System Prompt:**

```
Jesteś systemem moderacji treści dla aplikacji mobilnej "The 20-Minute Hero" — gry RPG dla dorosłych.

Oceniasz przesłane przez użytkowników dowody (zdjęcia, wideo, audio) pod kątem bezpieczeństwa.

TWOJE ZADANIE:
Zwróć JSON z oceną każdej kategorii. Bądź precyzyjny — fałszywe alarmy psują UX, ale niedopatrzenia narażają platformę.

ZASADY OCENY:
- nudity: nagość genitaliów, eksplicytny seksualny content. NIE: nagość artystyczna, sportowa (np. bieganie bez koszulki).
- violence: graficzne sceny przemocy, krew, gore. NIE: ogólne zdjęcia z siłowni, sporty kontaktowe.
- children_faces: wyraźnie rozpoznawalne twarze dzieci (do ~12 lat). Niewyraźne/odwrócone — NIE.
- license_plates: czytelne numery rejestracyjne. Rozmyte/częściowe — NIE.
- documents: dokumenty tożsamości (dowód, paszport, PESEL widoczny). NIE: kartki z notatkami.
- personal_data_on_screen: dane osobowe (imię+adres, nr telefonu, PESEL). NIE: ogólne tablice.
- address_visible: pełny adres domowy widoczny na drzwiach/skrzynce.
- safe: true jeśli żadna z powyższych flag nie jest true.
```

**User Template** (`content_moderation` template):

```
## DOWÓD DO OCENY

**Typ:** {{ proof_type }}
**ID próby:** {{ attempt_id }}

{% if proof_type in ["photo", "multi_photo", "video"] %}
[Obrazy/wideo dołączone do wiadomości]
{% elif proof_type == "audio" %}
[Audio dołączone do wiadomości]
{% endif %}

Oceń przesłany materiał i zwróć JSON zgodny ze schematem.
```

**Response Schema:**

```typescript
// packages/shared/src/ai/schemas.ts

import { z } from 'zod'

export const ModerationResultSchema = z.object({
  safe: z.boolean(),
  nudity: z.boolean().default(false),
  violence: z.boolean().default(false),
  children_faces: z.boolean().default(false),
  license_plates: z.boolean().default(false),
  documents: z.boolean().default(false),
  personal_data_on_screen: z.boolean().default(false),
  address_visible: z.boolean().default(false),
  confidence: z.number().min(0).max(1),       // AI's self-assessed confidence
  flagged_for_review: z.boolean().default(false),  // AI recommends human review
  moderation_note: z.string().max(300).nullable().default(null),  // AI reasoning
})

export type ModerationResult = z.infer<typeof ModerationResultSchema>
```

### 27.4 Auto-Action Rules

```typescript
// apps/api/src/core/moderation.ts

import type { ModerationResult } from '@20hero/shared'

// These flags trigger AUTO-HIDE (no human needed)
const AUTO_HIDE_FLAGS = new Set<keyof ModerationResult>(['nudity', 'violence'])

// These flags trigger FLAGGED_FOR_REVIEW (human admin reviews)
const REVIEW_FLAGS = new Set<keyof ModerationResult>([
  'children_faces',
  'documents',
  'personal_data_on_screen',
  'address_visible',
])

// These flags are logged but no action (low risk, high false-positive rate)
const LOG_ONLY_FLAGS = new Set<keyof ModerationResult>(['license_plates'])

const MIN_CONFIDENCE = 0.7  // from game_config "moderation.min_confidence"

export function determineAction(
  result: ModerationResult,
): 'cleared' | 'auto_hidden' | 'flagged' {
  /**
   * Returns: "cleared" | "auto_hidden" | "flagged"
   *
   * Decision order:
   * 1. Auto-hide if serious flags (nudity, violence) — regardless of confidence
   * 2. Flag for human review if confidence too low (AI not certain enough)
   * 3. Flag for human review if review-worthy flags or AI flagged_for_review
   * 4. Clear (safe content, high confidence)
   */
  for (const flag of AUTO_HIDE_FLAGS) {
    if (result[flag] === true) return 'auto_hidden'
  }

  if (result.confidence < MIN_CONFIDENCE) {
    return 'flagged'  // Low confidence → always human review (Section 27.11)
  }

  for (const flag of REVIEW_FLAGS) {
    if (result[flag] === true) return 'flagged'
  }

  if (result.flagged_for_review) return 'flagged'

  return 'cleared'
}
```

### 27.5 BullMQ Job: moderateProof

```typescript
// apps/api/src/jobs/moderation.ts

import { Worker, Job } from 'bullmq'
import * as Sentry from '@sentry/node'
import { runModeration } from '../ai/moderation'
import { determineAction } from '../core/moderation'
import { db } from '@20hero/db'
import { proofs, proofMedia } from '@20hero/db/schema'
import { eq, asc } from 'drizzle-orm'
import { s3Client } from '../services/s3'
import { notifyAdminFlagged } from '../services/push'

const MIME_TYPES: Record<string, string> = {
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  png: 'image/png',
  mp4: 'video/mp4',
  webm: 'video/webm',
  m4a: 'audio/m4a',
  mp3: 'audio/mpeg',
}

export const moderationWorker = new Worker(
  'moderation',
  async (job: Job<{ proofId: string }>) => {
    const { proofId } = job.data

    const proof = await db.query.proofs.findFirst({
      where: eq(proofs.id, proofId),
    })

    if (!proof) {
      return { status: 'proof_not_found' }
    }

    let mediaBuffer: Buffer | null = null
    let mediaMime: string | null = null
    let mediaItems: Buffer[] | null = null

    if (proof.proofType === 'multi_photo') {
      // Multi-photo: keys stored in proof_media table (not on proof directly)
      const mediaRecords = await db.query.proofMedia.findMany({
        where: eq(proofMedia.proofId, proofId),
        orderBy: [asc(proofMedia.sortOrder)],
      })
      mediaItems = await Promise.all(
        mediaRecords.map((record) => s3Client.getObject(record.s3Key)),
      )
    } else if (['photo', 'video', 'audio'].includes(proof.proofType)) {
      mediaBuffer = await s3Client.getObject(proof.s3Key!)
      const ext = proof.s3Key!.split('.').pop() ?? ''
      mediaMime = MIME_TYPES[ext] ?? 'application/octet-stream'
    } else {
      // text proof — skip moderation
      return { status: 'skipped', reason: 'text_proof' }
    }

    let result
    try {
      result = await runModeration({
        proofType: proof.proofType,
        mediaBuffer,
        mediaMime,
        mediaItems,
      })
    } catch (err) {
      Sentry.captureException(err)
      throw err  // BullMQ will retry based on job options
    }

    const action = determineAction(result)

    await db
      .update(proofs)
      .set({
        moderationFlags: result,
        moderationStatus: action,
        moderationNote: action !== 'cleared' ? result.moderation_note : null,
      })
      .where(eq(proofs.id, proofId))

    // Notify admin if flagged
    if (action === 'flagged') {
      await notifyAdminFlagged(proofId, result)
    }

    return { status: action, proofId }
  },
  {
    connection: redis,
    concurrency: 8,
    defaultJobOptions: {
      attempts: 3,
      backoff: { type: 'fixed', delay: 30_000 },
    },
  },
)
```

### 27.6 runModeration() — AI Call

```typescript
// apps/api/src/ai/moderation.ts

import { GoogleGenAI } from '@google/genai'
import { z } from 'zod'
import { ModerationResultSchema, type ModerationResult } from '@20hero/shared'
import { promptService } from './prompt-loader'
import { config } from '../core/config'

interface ModerationInput {
  proofType: string
  mediaBuffer: Buffer | null
  mediaMime: string | null
  mediaItems: Buffer[] | null
}

export async function runModeration(input: ModerationInput): Promise<ModerationResult> {
  const { proofType, mediaBuffer, mediaMime, mediaItems } = input

  const ai = new GoogleGenAI({ apiKey: config.GEMINI_API_KEY })

  // promptService.render() renders the user template with context
  const userText = await promptService.render('content_moderation', { proofType })
  const systemInstruction = await promptService.getSystem('content_moderation')

  const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [
    { text: userText },
  ]

  if (proofType === 'multi_photo' && mediaItems) {
    for (const imgBuffer of mediaItems) {
      parts.push({
        inlineData: {
          mimeType: 'image/jpeg',
          data: imgBuffer.toString('base64'),
        },
      })
    }
  } else if (mediaBuffer && mediaMime) {
    parts.push({
      inlineData: {
        mimeType: mediaMime,
        data: mediaBuffer.toString('base64'),
      },
    })
  }

  const response = await ai.models.generateContent({
    model: 'gemini-2.0-flash',
    contents: [{ role: 'user', parts }],
    config: {
      systemInstruction,
      responseMimeType: 'application/json',
      temperature: 0.1,  // low temp — deterministic safety decisions
    },
  })

  return ModerationResultSchema.parse(JSON.parse(response.text ?? '{}'))
}
```

### 27.7 Triggering from verifyProof Job

```typescript
// apps/api/src/jobs/proofs.ts — inside verifyProof BullMQ worker, after successful verification

import { moderationQueue } from './moderation'

// Inside verifyProof job handler, after result.decision === 'PASS':
if (result.decision === 'PASS' && proof.proofType !== 'text') {
  // Fire-and-forget moderation — does NOT block proof from becoming visible
  // Proof shows immediately after PASS; hidden if moderation finds issues
  await db
    .update(proofs)
    .set({ moderationStatus: 'pending' })
    .where(eq(proofs.id, proofId))

  await moderationQueue.add(
    'moderate-proof',
    { proofId },
    { delay: 2000 },  // 2s delay — let verifyProof DB write settle
  )
} else if (result.decision === 'PASS' && proof.proofType === 'text') {
  // Text proofs skip moderation — mark cleared immediately
  await db
    .update(proofs)
    .set({ moderationStatus: 'cleared' })
    .where(eq(proofs.id, proofId))
}
```

### 27.8 Visibility Logic

A proof is shown in community feeds only when **both** conditions are true:

```typescript
// packages/db/src/queries/proofs.ts

export function proofIsCommunityVisible(proof: Proof): boolean {
  /**
   * Proof is shown in community feed when:
   * - user set visibility to 'friends' or 'public'
   * - moderation has not hidden it
   */
  return (
    ['friends', 'public'].includes(proof.visibility) &&
    !['auto_hidden', 'flagged'].includes(proof.moderationStatus)
  )
}
```

SQL equivalent (for feed queries):

```sql
WHERE p.visibility IN ('friends', 'public')
  AND p.moderation_status NOT IN ('auto_hidden', 'flagged')
```

### 27.9 Admin Review Endpoints

```typescript
// apps/api/src/routes/admin.ts

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { requireAdmin } from '../middleware/auth'
import { db } from '@20hero/db'
import { proofs } from '@20hero/db/schema'
import { eq, asc } from 'drizzle-orm'

const admin = new Hono()

admin.get('/moderation/queue', requireAdmin, async (c) => {
  /**
   * List proofs awaiting human moderation review.
   */
  const status = (c.req.query('status') as 'flagged' | 'auto_hidden') ?? 'flagged'
  const limit = Math.min(Number(c.req.query('limit') ?? 20), 100)
  const offset = Number(c.req.query('offset') ?? 0)

  const flaggedProofs = await db.query.proofs.findMany({
    where: eq(proofs.moderationStatus, status),
    orderBy: [asc(proofs.createdAt)],  // oldest first — FIFO queue
    limit,
    offset,
  })

  return c.json(flaggedProofs)
})

const AdminDecisionSchema = z.object({
  action: z.enum(['clear', 'hide', 'delete']),
  note: z.string().optional(),
})

admin.post(
  '/moderation/:proofId/decision',
  requireAdmin,
  zValidator('json', AdminDecisionSchema),
  async (c) => {
    /**
     * Admin manually reviews a flagged proof.
     * payload.action: "clear" | "hide" | "delete"
     * payload.note: optional admin note
     */
    const proofId = c.req.param('proofId')
    const { action, note } = c.req.valid('json')

    const proof = await db.query.proofs.findFirst({ where: eq(proofs.id, proofId) })
    if (!proof) {
      return c.json({ error: 'Proof not found' }, 404)
    }

    let newStatus: string

    switch (action) {
      case 'clear':
        newStatus = 'admin_reviewed'  // shown in feed
        break
      case 'hide':
        newStatus = 'auto_hidden'     // stays hidden
        break
      case 'delete':
        // Soft delete — mark as deleted, clean S3 async
        await db
          .update(proofs)
          .set({ deletedAt: new Date(), moderationStatus: 'auto_hidden' })
          .where(eq(proofs.id, proofId))
        // TODO: schedule S3 cleanup job
        return c.json({ proofId, newStatus: 'auto_hidden' })
      default:
        return c.json({ error: 'Invalid action' }, 400)
    }

    await db
      .update(proofs)
      .set({ moderationStatus: newStatus, moderationNote: note ?? null })
      .where(eq(proofs.id, proofId))

    return c.json({ proofId, newStatus })
  },
)

export { admin }
```

**Admin View Schema:**

```typescript
// packages/shared/src/schemas/admin.ts

import { z } from 'zod'

export const FlaggedProofAdminViewSchema = z.object({
  proofId: z.string().uuid(),
  attemptId: z.string().uuid(),
  userId: z.string().uuid(),
  proofType: z.string(),
  mediaUrl: z.string().url().nullable(),          // presigned S3 URL for admin to view
  mediaUrls: z.array(z.string().url()).nullable(), // for multi_photo
  userCaption: z.string().nullable(),
  moderationFlags: z.record(z.unknown()),
  moderationStatus: z.string(),
  moderationNote: z.string().nullable(),
  createdAt: z.date(),
  flaggedAt: z.date().nullable(),
})

export type FlaggedProofAdminView = z.infer<typeof FlaggedProofAdminViewSchema>
```

### 27.10 Admin Notification

```typescript
// apps/api/src/jobs/moderation.ts — called from moderationWorker

async function notifyAdminFlagged(proofId: string, result: ModerationResult): Promise<void> {
  /**
   * Send FCM push to users with role='admin' when a proof needs review.
   * Uses the same FCM infrastructure as user notifications (Section 9).
   */
  const triggeredFlags = Object.entries(result)
    .filter(([key, val]) => typeof val === 'boolean' && val === true && key !== 'safe')
    .map(([key]) => key)

  await sendAdminPush({
    title: 'Moderacja: Nowe zgłoszenie',
    body: `Dowód wymaga przeglądu: ${triggeredFlags.join(', ')}`,
    data: { proofId, screen: 'AdminModerationDetail' },
  })
}
```

### 27.11 game_config Keys for Moderation

```typescript
// Seed entries:
const MODERATION_CONFIGS = [
  { key: 'moderation.enabled',         value: true,  description: 'Enable content moderation pipeline' },
  { key: 'moderation.text_skip',        value: true,  description: 'Skip moderation for text proofs' },
  { key: 'moderation.auto_hide_delay',  value: 0,     description: 'Seconds to delay auto-hide (0=immediate)' },
  { key: 'moderation.notify_admin',     value: true,  description: 'Send FCM push to admins on flagged content' },
  { key: 'moderation.min_confidence',   value: 0.7,   description: 'Minimum AI confidence to trust moderation result' },
]
```

Low-confidence results (`confidence < 0.7`) are automatically sent to human review regardless of flags.

---

## Section 28 — Deployment Strategy

### 28.1 Overview

MVP deployment targets a single VPS/LXC host using **Docker Compose**. The stack is designed to scale horizontally when needed — API and BullMQ workers can be replicated without code changes.

```
Internet
    │
    ▼
[Caddy / Nginx]  ← TLS termination, static asset serving
    │
    ├──► [Hono API]  (Node.js, clustered via PM2 or --workers flag)
    │        │
    │        ├──► [PostgreSQL + PostGIS]
    │        ├──► [Redis]
    │        └──► [BullMQ → Redis]
    │
    └──► [BullMQ Worker]  (proof verification, avatar gen, moderation)
              │
              └──► [BullMQ Scheduler]  (scheduled tasks: cleanup, stats)
```

### 28.2 Project File Structure (Backend)

```
/srv/20hero/
├── apps/
│   └── api/
│       ├── src/
│       │   ├── index.ts
│       │   ├── core/
│       │   │   ├── config.ts          # zod-env config
│       │   │   ├── auth.ts            # JWT + Firebase verify
│       │   │   ├── rate-limit.ts      # custom Hono middleware
│       │   │   └── errors.ts
│       │   ├── routes/
│       │   │   ├── auth.ts
│       │   │   ├── quests.ts
│       │   │   ├── proofs.ts
│       │   │   ├── characters.ts
│       │   │   ├── social.ts
│       │   │   ├── paths.ts
│       │   │   └── admin.ts
│       │   ├── db/
│       │   │   └── redis.ts           # ioredis client
│       │   ├── ai/
│       │   │   ├── prompt-loader.ts
│       │   │   ├── quest-gen.ts
│       │   │   ├── proof-verify.ts
│       │   │   ├── moderation.ts
│       │   │   ├── avatar-gen.ts
│       │   │   └── completion-feedback.ts
│       │   ├── jobs/
│       │   │   ├── proofs.ts          # verifyProof, moderateProof
│       │   │   ├── avatar-jobs.ts     # generateAvatarTier, generateVisualAnchor
│       │   │   └── cleanup.ts         # scheduled maintenance
│       │   └── services/
│       │       ├── quest-service.ts
│       │       ├── proof-service.ts
│       │       ├── character-service.ts
│       │       ├── path-service.ts
│       │       ├── weather-service.ts
│       │       ├── push-service.ts
│       │       └── s3-service.ts
│       ├── Dockerfile
│       ├── package.json
│       └── .env.example
├── packages/
│   ├── db/
│   │   ├── src/
│   │   │   ├── schema/
│   │   │   ├── migrations/
│   │   │   │   ├── 0001_baseline.sql
│   │   │   │   ├── 0002_proof_expansion.sql
│   │   │   │   └── 0003_moderation_fields.sql
│   │   │   └── seed.ts
│   │   └── drizzle.config.ts
│   └── shared/
│       └── src/
│           ├── ai/schemas.ts
│           └── schemas/
├── docker-compose.yml
├── docker-compose.override.yml   # local dev overrides
├── Caddyfile
├── pnpm-workspace.yaml
└── .github/
    └── workflows/
        └── deploy.yml
```

### 28.3 Dockerfile (API)

```dockerfile
# apps/api/Dockerfile

# ─── Build stage ───────────────────────────────────────────────
FROM node:22-alpine AS builder

WORKDIR /app

RUN corepack enable && corepack prepare pnpm@latest --activate

COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/api/package.json ./apps/api/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/

RUN pnpm install --frozen-lockfile

COPY . .

RUN pnpm --filter @20hero/shared build
RUN pnpm --filter @20hero/db build
RUN pnpm --filter @20hero/api build

# ─── Runtime stage ─────────────────────────────────────────────
FROM node:22-alpine

WORKDIR /app

# PostGIS client libs not needed for Node.js — pg driver uses JS protocol

RUN corepack enable && corepack prepare pnpm@latest --activate

COPY --from=builder /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml ./
COPY --from=builder /app/apps/api/package.json ./apps/api/
COPY --from=builder /app/packages/db/package.json ./packages/db/
COPY --from=builder /app/packages/shared/package.json ./packages/shared/

RUN pnpm install --frozen-lockfile --prod

COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/packages/db/dist ./packages/db/dist
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist

# Non-root user
RUN addgroup -S hero && adduser -S -G hero -u 1001 hero
USER hero

EXPOSE 3000

# Default: API server
# BullMQ worker uses CMD override in docker-compose
CMD ["node", "apps/api/dist/index.js"]
```

### 28.4 docker-compose.yml

```yaml
# docker-compose.yml

services:
  # ─── PostgreSQL + PostGIS ────────────────────────────────────
  db:
    image: postgis/postgis:16-3.4-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: hero20
      POSTGRES_USER: hero
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U hero -d hero20"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  # ─── Redis ───────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - internal

  # ─── Drizzle migrations (runs once, then exits) ───────────────
  migrate:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
    image: hero20-api
    command: node -e "require('./packages/db/dist/migrate').runMigrations()"
    environment:
      DATABASE_URL: postgresql://hero:${DB_PASSWORD}@db:5432/hero20
    depends_on:
      db:
        condition: service_healthy
    networks:
      - internal
    restart: "no"

  # ─── Hono API ─────────────────────────────────────────────────
  api:
    image: hero20-api
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://hero:${DB_PASSWORD}@db:5432/hero20
      REDIS_URL: redis://redis:6379/0
      GEMINI_API_KEY: ${GEMINI_API_KEY}
      FIREBASE_CREDENTIALS_JSON: ${FIREBASE_CREDENTIALS_JSON}
      JWT_SECRET: ${JWT_SECRET}
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
      S3_BUCKET: ${S3_BUCKET}
      S3_REGION: ${S3_REGION}
      SENTRY_DSN: ${SENTRY_DSN}
      ENVIRONMENT: production
      PORT: "3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s
    networks:
      - internal
      - external

  # ─── BullMQ Worker ───────────────────────────────────────────
  worker:
    image: hero20-api
    command: node apps/api/dist/worker.js
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://hero:${DB_PASSWORD}@db:5432/hero20
      REDIS_URL: redis://redis:6379/0
      GEMINI_API_KEY: ${GEMINI_API_KEY}
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
      S3_BUCKET: ${S3_BUCKET}
      S3_REGION: ${S3_REGION}
      SENTRY_DSN: ${SENTRY_DSN}
      ENVIRONMENT: production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e", "require('./apps/api/dist/worker-health').check()"]
      interval: 60s
      timeout: 20s
      retries: 3
      start_period: 30s
    networks:
      - internal

  # ─── BullMQ Scheduler (scheduled tasks) ──────────────────────
  scheduler:
    image: hero20-api
    command: node apps/api/dist/scheduler.js
    restart: unless-stopped
    environment:
      REDIS_URL: redis://redis:6379/0
      DATABASE_URL: postgresql://hero:${DB_PASSWORD}@db:5432/hero20
    depends_on:
      redis:
        condition: service_healthy
    networks:
      - internal

  # ─── Bull Board (BullMQ monitoring, internal only) ───────────
  bull-board:
    image: hero20-api
    command: node apps/api/dist/bull-board.js
    restart: unless-stopped
    environment:
      REDIS_URL: redis://redis:6379/0
      BULL_BOARD_PORT: "5555"
      BULL_BOARD_USERNAME: admin
      BULL_BOARD_PASSWORD: ${BULL_BOARD_PASSWORD}
    depends_on:
      - redis
    networks:
      - internal   # NOT on external — accessed via Caddy auth proxy if needed

volumes:
  db_data:
  redis_data:

networks:
  internal:
    driver: bridge
  external:
    driver: bridge
```

### 28.5 docker-compose.override.yml (Local Dev)

```yaml
# docker-compose.override.yml — NOT committed if contains secrets

services:
  api:
    command: node --watch apps/api/dist/index.js
    volumes:
      - ./apps/api/src:/app/apps/api/src   # source mount for tsx --watch
    environment:
      ENVIRONMENT: development
      LOG_LEVEL: debug

  worker:
    command: node --watch apps/api/dist/worker.js

  db:
    ports:
      - "5432:5432"   # expose for local DB clients

  redis:
    ports:
      - "6379:6379"
```

### 28.6 Caddyfile (Reverse Proxy)

```caddyfile
# Caddyfile

hero20.app {
    reverse_proxy api:3000 {
        health_uri     /health
        health_interval 30s
    }

    # Rate limit at proxy level (coarse, before app-level middleware)
    rate_limit {
        zone dynamic_zone {
            key {remote_host}
            events 200
            window 1m
        }
    }

    encode gzip

    log {
        output file /var/log/caddy/access.log
        format json
    }
}

# Bull Board admin — password protected
bull-board.hero20.app {
    basicauth {
        admin {env.BULL_BOARD_PASSWORD_HASH}
    }
    reverse_proxy bull-board:5555
}
```

### 28.7 Health Check Endpoint

```typescript
// apps/api/src/index.ts — /health endpoint

import { db } from '@20hero/db'
import { redis } from './db/redis'
import { sql } from 'drizzle-orm'
import { config } from './core/config'

app.get('/health', async (c) => {
  /**
   * Liveness + readiness check.
   * Returns 200 only when DB and Redis are reachable.
   * Used by Docker HEALTHCHECK and Caddy health probe.
   */
  const checks: Record<string, string> = {}

  // DB check
  try {
    await db.execute(sql`SELECT 1`)
    checks['db'] = 'ok'
  } catch (e) {
    checks['db'] = `error: ${e}`
  }

  // Redis check
  try {
    await redis.ping()
    checks['redis'] = 'ok'
  } catch (e) {
    checks['redis'] = `error: ${e}`
  }

  const allOk = Object.values(checks).every((v) => v === 'ok')

  return c.json(
    {
      status: allOk ? 'ok' : 'degraded',
      checks,
      version: config.APP_VERSION,
    },
    allOk ? 200 : 503,
  )
})
```

### 28.8 .env.example

```bash
# apps/api/.env.example — copy to .env, fill secrets

# Database
DATABASE_URL=postgresql://hero:changeme@localhost:5432/hero20

# Redis
REDIS_URL=redis://localhost:6379/0

# Gemini AI
GEMINI_API_KEY=your-gemini-api-key

# Firebase (paste JSON as single line or path to credentials file)
FIREBASE_CREDENTIALS_JSON={"type":"service_account",...}

# JWT
JWT_SECRET=at-least-32-random-characters-here
JWT_ACCESS_EXPIRE_MINUTES=15
JWT_REFRESH_EXPIRE_DAYS=30

# AWS S3
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
S3_BUCKET=hero20-proofs-prod
S3_REGION=eu-central-1

# Sentry
SENTRY_DSN=https://...@sentry.io/...

# FCM (Firebase Cloud Messaging — same credentials as Firebase Auth)
# No extra key needed — uses FIREBASE_CREDENTIALS_JSON

# Bull Board
BULL_BOARD_PASSWORD=admin-password-here

# App
APP_VERSION=0.1.0
ENVIRONMENT=production
LOG_LEVEL=info
PORT=3000
```

### 28.9 CI/CD — GitHub Actions

```yaml
# .github/workflows/deploy.yml

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE: ${{ github.repository }}/hero20-api

jobs:
  # ─── 1. Tests ──────────────────────────────────────────────
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgis/postgis:16-3.4-alpine
        env:
          POSTGRES_DB: hero20_test
          POSTGRES_USER: hero
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v3
        with:
          version: latest

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run tests
        env:
          DATABASE_URL: postgresql://hero:test@localhost:5432/hero20_test
          REDIS_URL: redis://localhost:6379/0
          GEMINI_API_KEY: test-key    # tests mock Gemini calls
          JWT_SECRET: test-secret-at-least-32-chars-long
        run: pnpm test

      - name: Typecheck
        run: pnpm typecheck

  # ─── 2. Build & Push Docker image ─────────────────────────
  build:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.version }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: apps/api/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ─── 3. Deploy to VPS ─────────────────────────────────────
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: deploy
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /srv/20hero
            docker compose pull api worker scheduler
            docker compose up -d --no-deps api worker scheduler
            docker compose run --rm migrate   # drizzle migrate
            docker image prune -f
```

### 28.10 Scheduled Tasks (BullMQ Scheduler)

```typescript
// apps/api/src/scheduler.ts — repeatable BullMQ jobs

import { Queue } from 'bullmq'
import { redis } from './db/redis'

const cleanupQueue = new Queue('cleanup', { connection: redis })
const statsQueue = new Queue('stats', { connection: redis })
const avatarQueue = new Queue('avatars', { connection: redis })

async function registerSchedules() {
  // Daily cleanup: delete expired quest_attempts (status=abandoned, >7 days)
  await cleanupQueue.add(
    'cleanup-abandoned-attempts',
    {},
    {
      repeat: { pattern: '0 3 * * *', tz: 'UTC' },  // 03:00 UTC daily
      removeOnComplete: true,
    },
  )

  // Daily leaderboard snapshot (for weekly rankings)
  await statsQueue.add(
    'snapshot-leaderboard',
    {},
    {
      repeat: { pattern: '5 0 * * *', tz: 'UTC' },  // 00:05 UTC daily
      removeOnComplete: true,
    },
  )

  // Hourly: retry failed avatar generations
  await avatarQueue.add(
    'retry-failed-avatars',
    {},
    {
      repeat: { pattern: '30 * * * *', tz: 'UTC' },  // every hour at :30
      removeOnComplete: true,
    },
  )

  // Weekly: purge soft-deleted proofs from S3
  await cleanupQueue.add(
    'purge-deleted-proofs-s3',
    {},
    {
      repeat: { pattern: '0 4 * * 0', tz: 'UTC' },  // Sunday 04:00 UTC
      removeOnComplete: true,
    },
  )
}

registerSchedules().catch(console.error)
```

### 28.11 BullMQ Queue Configuration

```typescript
// apps/api/src/worker.ts

import { Worker } from 'bullmq'
import { redis } from './db/redis'
import { verifyProofWorker } from './jobs/proofs'
import { moderationWorker } from './jobs/moderation'
import { avatarWorker } from './jobs/avatar-jobs'
import { cleanupWorker } from './jobs/cleanup'

// Queue names match routing in job producers
const QUEUES = {
  proofs: verifyProofWorker,
  moderation: moderationWorker,
  avatars: avatarWorker,
  cleanup: cleanupWorker,
}

// BullMQ workers are already created with:
//   connection: redis
//   concurrency: 8  (cooperative, uses Node.js event loop — no threading needed)

// Task routing — producers specify queue name when adding jobs:
// proofQueue.add('verify-proof', { proofId })
// moderationQueue.add('moderate-proof', { proofId })
// avatarQueue.add('generate-avatar-tier', { characterId, tier })
// cleanupQueue.add('cleanup-abandoned-attempts', {})

// Retry on connection errors: BullMQ handles Redis reconnection automatically
// task_acks_late equivalent: lockDuration + stalledInterval in BullMQ options

console.log('BullMQ workers started:', Object.keys(QUEUES))
```

### 28.12 Scaling Notes

| Component | MVP (single VPS) | Scale-out |
|---|---|---|
| Hono API | Single Node.js process | Multiple replicas behind load balancer; or Node cluster |
| BullMQ workers | 1 instance, `concurrency: 8` | Separate worker processes per queue (`proofs`, `avatars`) |
| PostgreSQL | Single instance | Read replica for analytics queries |
| Redis | Single instance | Redis Cluster or Sentinel for HA |
| S3 | Shared bucket | Add CloudFront CDN for proof media delivery |
| Gemini API | Shared rate limit | Quota increase request as MAU grows |

**Bottleneck priority:** Gemini calls (avatar gen ~10s, proof verify ~3s) → BullMQ worker concurrency is the main scaling lever. Each avatar generation job occupies a concurrency slot for ~10s including retries.

---

## Section 29 — Testing Strategy

### 29.1 Overview

Three layers of tests:

| Layer | Scope | Speed | Dependencies |
|---|---|---|---|
| **Unit** | Single function / AI prompt parsing | Fast (<1s) | No I/O — all mocked |
| **Integration** | Router → DB → Redis flow | Medium (2–10s) | Real PostgreSQL + Redis (Docker) |
| **E2E smoke** | Full happy-path via HTTP | Slow (>10s) | Full stack running |

MVP focus: **integration tests** covering all API routes. Unit tests for AI response parsing and business logic. E2E smoke runs in CI only on `main` branch pushes.

### 29.2 Test Stack

```json
// apps/api/package.json — devDependencies

{
  "devDependencies": {
    "vitest": "^2.0",
    "@vitest/coverage-v8": "^2.0",
    "hono": "^4.0",
    "@faker-js/faker": "^8.0",
    "msw": "^2.0",
    "@anatine/zod-mock": "^3.0"
  }
}
```

```typescript
// vitest.config.ts

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/__tests__/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      include: ['src/**'],
      exclude: ['src/db/migrations/**', 'src/seed.ts'],
      thresholds: {
        lines: 75,   // MVP target
      },
    },
    setupFiles: ['src/__tests__/setup.ts'],
  },
})
```

### 29.3 Test Directory Structure

```
apps/api/src/__tests__/
├── setup.ts                 # Global beforeAll/afterAll (DB, redis, mocks)
├── factories.ts             # @faker-js/faker test factories
├── unit/
│   ├── ai-schemas.test.ts   # Zod schema validation
│   ├── prompt-render.test.ts # Template rendering
│   ├── moderation-rules.test.ts  # determineAction() logic
│   ├── rate-limit-key.test.ts    # getRateLimitKey()
│   └── jwt.test.ts          # token creation/validation/expiry
├── integration/
│   ├── auth.test.ts         # Firebase login, refresh, logout
│   ├── quests.test.ts       # CRUD, nearby, generate
│   ├── proofs.test.ts       # upload-url, submit (all 5 types), vote
│   ├── characters.test.ts   # onboarding, XP, tier-up
│   ├── paths.test.ts        # path progress, advance_step
│   └── admin.test.ts        # moderation queue, decision
└── smoke/
    └── happy-path.test.ts   # Full quest completion flow
```

### 29.4 setup.ts — Core Test Fixtures

```typescript
// apps/api/src/__tests__/setup.ts

import { beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
import { testClient } from 'hono/testing'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import Redis from 'ioredis'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import * as schema from '@20hero/db/schema'
import { app } from '../index'

const TEST_DATABASE_URL = 'postgresql://hero:test@localhost:5432/hero20_test'
const TEST_REDIS_URL = 'redis://localhost:6379/15'  // DB 15 = test isolation

// ─── Database ────────────────────────────────────────────────────────────

let pool: Pool
let testDb: ReturnType<typeof drizzle>

beforeAll(async () => {
  pool = new Pool({ connectionString: TEST_DATABASE_URL })
  testDb = drizzle(pool, { schema })
  await migrate(testDb, { migrationsFolder: '../../../packages/db/drizzle' })
})

afterAll(async () => {
  await pool.end()
})

// Each test gets a transaction that is rolled back after the test.
// No data bleeds between tests; much faster than recreating tables.
export let tx: typeof testDb

beforeEach(async () => {
  // Override db in app with transaction
  vi.mock('@20hero/db', () => ({ db: testDb }))
})

afterEach(async () => {
  // Cleanup inserted rows via DELETE (or use savepoints for full rollback)
  vi.clearAllMocks()
})

// ─── Redis ───────────────────────────────────────────────────────────────

export let redisClient: Redis

beforeAll(async () => {
  redisClient = new Redis(TEST_REDIS_URL)
})

afterAll(async () => {
  await redisClient.quit()
})

afterEach(async () => {
  await redisClient.flushdb()  // clean after each test
})

// ─── Hono test client ────────────────────────────────────────────────────

export const client = testClient(app)

// ─── Auth helpers ────────────────────────────────────────────────────────

export function makeAuthHeaders(userId: string): Record<string, string> {
  const { createAccessToken } = await import('../core/auth')
  const token = createAccessToken({ sub: userId })
  return { Authorization: `Bearer ${token}` }
}
```

### 29.5 factories.ts — Test Data Factories

```typescript
// apps/api/src/__tests__/factories.ts

import { faker } from '@faker-js/faker'
import { db } from '@20hero/db'
import { users, characters, quests, questAttempts, proofs } from '@20hero/db/schema'

// Test factory (replaces factory-boy) — uses @faker-js/faker

export function createUserData(overrides: Partial<typeof users.$inferInsert> = {}) {
  return {
    id: faker.string.uuid(),
    firebaseUid: faker.string.alphanumeric(28),
    email: faker.internet.email(),
    displayName: faker.internet.username(),
    isAdmin: false,                       // users table uses is_admin bool, not role TEXT
    createdAt: new Date(),
    ...overrides,
  }
}

export async function insertUser(overrides = {}) {
  const data = createUserData(overrides)
  await db.insert(users).values(data)
  return data
}

export function createCharacterData(overrides: Partial<typeof characters.$inferInsert> = {}) {
  return {
    id: faker.string.uuid(),
    userId: faker.string.uuid(),
    classKey: 'techno_mag',              // column name is class_key (not character_class)
    name: 'Testowy Bohater',             // column name is name (not display_name)
    xpTotal: 0,                          // column name is xp_total (not xp)
    level: 1,
    // tier is derived from level via avatar_tiers config — not a DB column
    // tokens are in token_balances table — not on characters
    // visual_anchor IS NULL for new chars (onboarding_complete equiv: visual_anchor IS NOT NULL)
    ...overrides,
  }
}

export async function insertCharacter(overrides = {}) {
  const data = createCharacterData(overrides)
  await db.insert(characters).values(data)
  return data
}

export function createQuestData(overrides: Partial<typeof quests.$inferInsert> = {}) {
  return {
    id: faker.string.uuid(),
    title: faker.lorem.sentence(),
    description: 'Testowy quest',
    objective: 'Zrób coś',
    proofType: 'photo' as const,
    difficulty: 'easy' as const,
    xpBase: 100,           // matches DDL: quests.xp_base
    xpBonusMax: 50,        // matches DDL: quests.xp_bonus_max
    // token_reward NOT stored in quests — computed from game_config("token_rewards") at award time
    estimatedMinutes: 20,
    location: 'SRID=4326;POINT(21.0 52.0)',   // Warsaw
    isActive: true,
    ...overrides,
  }
}

export async function insertQuest(overrides = {}) {
  const data = createQuestData(overrides)
  await db.insert(quests).values(data)
  return data
}

export function createQuestAttemptData(overrides: Partial<typeof questAttempts.$inferInsert> = {}) {
  return {
    id: faker.string.uuid(),
    status: 'in_progress' as const,
    chosenProofType: 'photo' as const,
    startedAt: new Date(),
    ...overrides,
  }
}

export async function insertQuestAttempt(overrides = {}) {
  const data = createQuestAttemptData(overrides)
  await db.insert(questAttempts).values(data)
  return data
}
```

### 29.6 Unit Tests — AI Schema Parsing

```typescript
// apps/api/src/__tests__/unit/ai-schemas.test.ts

import { describe, it, expect } from 'vitest'
import { QuestAIOutputSchema, ProofVerificationResultSchema, ModerationResultSchema } from '@20hero/shared'

describe('QuestAIOutput', () => {
  it('validates valid quest output', () => {
    // QuestAIOutputSchema has NO xp_reward — XP is set by backend from game_config(difficulty)
    const raw = {
      title: 'Wyprawa do parku',
      description: 'Oddech natury...',
      objective: 'Spędź 20 minut w parku',
      completion_hint: 'Zrób zdjęcie drzewa',
      difficulty: 'easy',
      estimated_minutes: 20,
      quest_tags: ['natura', 'oddech'],
      energy_required: 'low',
      needed_items: [],
      companion_mode: 'solo',
      budget_level: 'free',
      suggested_proof_type: 'photo',
      ai_reasoning: 'Park spacer to dobry quest na spokojny wieczór.',
    }
    const quest = QuestAIOutputSchema.parse(raw)
    expect(quest.difficulty).toBe('easy')
    expect(quest.quest_tags).toHaveLength(2)
  })

  it('rejects estimated_minutes out of range', () => {
    const raw = { ...VALID_QUEST_OUTPUT_FIXTURE, estimated_minutes: 999 }
    expect(() => QuestAIOutputSchema.parse(raw)).toThrow()
  })
})

describe('ModerationResult', () => {
  it('sets safe=false when any flag is true', () => {
    // Validator should set safe=false when any bad flag is true
    const result = ModerationResultSchema.parse({
      safe: true,   // will be overridden by validator
      nudity: true,
      confidence: 0.95,
    })
    expect(result.safe).toBe(false)
  })
})

describe('ProofVerificationResult', () => {
  it('rejects invalid verdict when required fields are missing', () => {
    expect(() =>
      ProofVerificationResultSchema.parse({
        verdict: 'pass',
        looks_authentic: false,
        confidence: 0.9,
      }),
    ).toThrow()
  })
})
```

### 29.7 Unit Tests — Moderation Rules

```typescript
// apps/api/src/__tests__/unit/moderation-rules.test.ts

import { describe, it, expect } from 'vitest'
import { determineAction } from '../../core/moderation'
import type { ModerationResult } from '@20hero/shared'

function makeResult(overrides: Partial<ModerationResult>): ModerationResult {
  return {
    safe: true,
    nudity: false,
    violence: false,
    children_faces: false,
    license_plates: false,
    documents: false,
    personal_data_on_screen: false,
    address_visible: false,
    confidence: 0.95,
    flagged_for_review: false,
    moderation_note: null,
    ...overrides,
  }
}

describe('determineAction', () => {
  it('nudity triggers auto-hide', () => {
    const r = makeResult({ safe: false, nudity: true, confidence: 0.95 })
    expect(determineAction(r)).toBe('auto_hidden')
  })

  it('violence triggers auto-hide', () => {
    const r = makeResult({ safe: false, violence: true, confidence: 0.92 })
    expect(determineAction(r)).toBe('auto_hidden')
  })

  it('children_faces triggers review', () => {
    const r = makeResult({ safe: false, children_faces: true, confidence: 0.88 })
    expect(determineAction(r)).toBe('flagged')
  })

  it('low confidence triggers review', () => {
    const r = makeResult({ safe: true, confidence: 0.5 })
    expect(determineAction(r)).toBe('flagged')  // AI not confident enough
  })

  it('license plates alone are cleared', () => {
    // License plates → just log, don't flag
    const r = makeResult({ safe: false, license_plates: true, confidence: 0.90 })
    expect(determineAction(r)).toBe('cleared')
  })

  it('clean content is cleared', () => {
    const r = makeResult({ safe: true, confidence: 0.99 })
    expect(determineAction(r)).toBe('cleared')
  })
})
```

### 29.8 Unit Tests — JWT

```typescript
// apps/api/src/__tests__/unit/jwt.test.ts

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createAccessToken, verifyAccessToken } from '../../core/auth'

describe('JWT', () => {
  it('creates and verifies a valid token', () => {
    const token = createAccessToken({ sub: 'user-123' })
    const payload = verifyAccessToken(token)
    expect(payload.sub).toBe('user-123')
  })

  it('rejects an expired token', () => {
    // Travel forward 16 minutes (expire=15min)
    const now = Date.now()
    vi.setSystemTime(now)
    const token = createAccessToken({ sub: 'user-123' })
    vi.setSystemTime(now + 16 * 60 * 1000)
    expect(() => verifyAccessToken(token)).toThrow(/expired/)
    vi.useRealTimers()
  })

  it('rejects a tampered token', () => {
    const token = createAccessToken({ sub: 'user-123' })
    const badToken = token.slice(0, -5) + 'XXXXX'
    expect(() => verifyAccessToken(badToken)).toThrow(/invalid/)
  })
})
```

### 29.9 Integration Tests — Auth

```typescript
// apps/api/src/__tests__/integration/auth.test.ts

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { testClient } from 'hono/testing'
import { app } from '../../index'
import { insertUser } from '../factories'
import * as firebaseAdmin from 'firebase-admin'

vi.mock('firebase-admin')

const client = testClient(app)

describe('Auth', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('firebase login creates a new user record', async () => {
    vi.mocked(firebaseAdmin.auth().verifyIdToken).mockResolvedValue({
      uid: 'new-firebase-uid',
      email: 'new@test.com',
      name: 'New Hero',
    } as any)

    const res = await client.auth.firebase.$post({
      json: { id_token: 'fake-firebase-token' },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data).toHaveProperty('access_token')
    expect(data).toHaveProperty('refresh_token')
    expect(data.is_new_user).toBe(true)
  })

  it('firebase login with existing uid returns is_new_user=false', async () => {
    const user = await insertUser({ firebaseUid: 'existing-uid' })

    vi.mocked(firebaseAdmin.auth().verifyIdToken).mockResolvedValue({
      uid: user.firebaseUid,
      email: user.email,
      name: user.displayName,
    } as any)

    const res = await client.auth.firebase.$post({
      json: { id_token: 'fake-token' },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data.is_new_user).toBe(false)
  })

  it('refresh token rotation invalidates old refresh token', async () => {
    const user = await insertUser()
    vi.mocked(firebaseAdmin.auth().verifyIdToken).mockResolvedValue({
      uid: user.firebaseUid, email: user.email, name: '',
    } as any)

    const loginRes = await client.auth.firebase.$post({ json: { id_token: 't' } })
    const { refresh_token: refreshToken } = await loginRes.json()

    // Use refresh token
    const refreshRes = await client.auth.refresh.$post({ json: { refresh_token: refreshToken } })
    expect(refreshRes.status).toBe(200)

    // Old refresh token now invalid
    const repeatRes = await client.auth.refresh.$post({ json: { refresh_token: refreshToken } })
    expect(repeatRes.status).toBe(401)
  })
})
```

### 29.10 Integration Tests — Quest Generation

```typescript
// apps/api/src/__tests__/integration/quests.test.ts

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { testClient } from 'hono/testing'
import { faker } from '@faker-js/faker'
import { app } from '../../index'
import { insertUser, insertQuest } from '../factories'
import * as questGen from '../../ai/quest-gen'

vi.mock('../../ai/quest-gen')

const client = testClient(app)

const MOCK_QUEST_OUTPUT = {
  // QuestAIOutput schema — no xp_reward field, XP set by backend from game_config
  title: 'Spacer po parku',
  description: 'Wyjdź na świeże powietrze',
  objective: 'Spędź 20 minut na świeżym powietrzu',
  suggested_proof_type: 'photo',
  completion_hint: 'Zrób zdjęcie nieba',
  difficulty: 'easy',
  estimated_minutes: 20,
  quest_tags: ['natura'],
  energy_required: 'low',
  needed_items: [],
  companion_mode: 'solo',
  budget_level: 'free',
  ai_reasoning: 'Spacer to doskonały quest na poprawę nastroju.',
  // token_reward is NOT in QuestAIOutput — set by backend from game_config
}

describe('Quest generation', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('generates a quest for authenticated user', async () => {
    const user = await insertUser()
    vi.mocked(questGen.generateQuestAi).mockResolvedValue(MOCK_QUEST_OUTPUT as any)

    const res = await client.quests.generate.$post({
      json: { lat: 52.2, lon: 21.0 },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data.title).toBe('Spacer po parku')
    expect(data.proof_type).toBe('photo')
  })

  it('enforces rate limit of 3 per hour — 4th call returns 429', async () => {
    const user = await insertUser()
    vi.mocked(questGen.generateQuestAi).mockResolvedValue(MOCK_QUEST_OUTPUT as any)
    const headers = { Authorization: `Bearer ${makeToken(user.id)}` }

    for (let i = 0; i < 3; i++) {
      await client.quests.generate.$post({ json: { lat: 52.2, lon: 21.0 }, headers })
    }
    const res = await client.quests.generate.$post({ json: { lat: 52.2, lon: 21.0 }, headers })

    expect(res.status).toBe(429)
  })

  it('returns quests within 1km radius', async () => {
    const user = await insertUser()
    await insertQuest({ location: 'SRID=4326;POINT(21.001 52.001)' })  // ~100m
    await insertQuest({ location: 'SRID=4326;POINT(21.1 52.1)' })     // ~10km away

    const res = await client.quests.nearby.$get({
      query: { lat: '52.0', lon: '21.0', radius_m: '1000' },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data).toHaveLength(1)
  })

  it('starts a quest and returns in_progress status', async () => {
    const user = await insertUser()
    const quest = await insertQuest()

    const res = await client.quests[':id'].start.$post({
      param: { id: quest.id },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(200)
    expect((await res.json()).status).toBe('in_progress')
  })
})
```

### 29.11 Integration Tests — Proof Submission

```typescript
// apps/api/src/__tests__/integration/proofs.test.ts

import { describe, it, expect, vi } from 'vitest'
import { testClient } from 'hono/testing'
import { app } from '../../index'
import { insertUser, insertQuestAttempt, insertQuest } from '../factories'
import * as s3Service from '../../services/s3-service'

vi.mock('../../services/s3-service')

const client = testClient(app)

describe('Proof submission', () => {
  it('returns upload URL for photo proof', async () => {
    const user = await insertUser()
    const attempt = await insertQuestAttempt({ chosenProofType: 'photo' })
    vi.mocked(s3Service.generatePresignedUrl).mockResolvedValue('https://s3.example.com/presigned-url')

    const res = await client.proofs[':attemptId']['upload-url'].$post({
      param: { attemptId: attempt.id },
      json: { proof_type: 'photo', file_extension: 'jpg', file_size_bytes: 512_000 },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data).toHaveProperty('upload_url')
    expect(data.upload_url).toMatch(/^https:\/\//)
    expect(data).toHaveProperty('s3_key')
  })

  it('returns multiple upload URLs for multi_photo proof', async () => {
    const user = await insertUser()
    const attempt = await insertQuestAttempt({ chosenProofType: 'multi_photo' })
    vi.mocked(s3Service.generatePresignedUrl).mockResolvedValue('https://s3.example.com/presigned-url')

    const res = await client.proofs[':attemptId']['upload-url'].$post({
      param: { attemptId: attempt.id },
      json: {
        proof_type: 'multi_photo',
        file_extension: 'jpg',
        file_size_bytes: 0,
        photo_count: 3,
        file_sizes_bytes: [500_000, 700_000, 400_000],
      },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data.upload_urls).toHaveLength(3)
  })

  it('accepts text proof and queues for verification', async () => {
    const user = await insertUser()
    const attempt = await insertQuestAttempt({ chosenProofType: 'text' })

    const res = await client.proofs[':attemptId'].submit.$post({
      param: { attemptId: attempt.id },
      json: {
        proof_type: 'text',
        user_text: 'Spędziłem 20 minut medytując w parku Skaryszewskim. '.repeat(3),
        visibility: 'public',
      },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(202)  // accepted, async processing
    const data = await res.json()
    expect(['pending_verification', 'processing']).toContain(data.status)
  })

  it('rejects text proof that is too short', async () => {
    const user = await insertUser()
    const attempt = await insertQuestAttempt({ chosenProofType: 'text' })

    const res = await client.proofs[':attemptId'].submit.$post({
      param: { attemptId: attempt.id },
      json: { proof_type: 'text', user_text: 'Za krótkie.', visibility: 'public' },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(422)  // validation error
  })

  it('allows voting on another user proof', async () => {
    const user = await insertUser()
    const otherUser = await insertUser()
    const proof = await insertProof({ userId: otherUser.id, status: 'verified_pass' })

    const res = await client.proofs[':id'].vote.$post({
      param: { id: proof.id },
      json: { vote: 'up' },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data.your_vote).toBe('up')
    // Vote counts hidden before own vote (anti-bandwagon) — already revealed since we voted
    expect(data).toHaveProperty('upvotes')
  })

  it('rejects voting on own proof', async () => {
    const user = await insertUser()
    const proof = await insertProof({ userId: user.id, status: 'verified_pass' })

    const res = await client.proofs[':id'].vote.$post({
      param: { id: proof.id },
      json: { vote: 'up' },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(403)
  })
})
```

### 29.12 Integration Tests — Character Onboarding

```typescript
// apps/api/src/__tests__/integration/characters.test.ts

import { describe, it, expect, vi } from 'vitest'
import { testClient } from 'hono/testing'
import { app } from '../../index'
import { insertUser, insertCharacter, insertQuest, insertQuestAttempt } from '../factories'
import { db } from '@20hero/db'
import { characters } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'

vi.mock('../../ai/quest-gen')

const client = testClient(app)

describe('Character', () => {
  it('onboarding creates character and persists preferences', async () => {
    const user = await insertUser()
    const { generateOnboardingQuests } = await import('../../ai/quest-gen')
    vi.mocked(generateOnboardingQuests).mockResolvedValue([])  // skip quest generation in test

    const res = await client.characters.onboarding.$post({
      json: {
        character_class: 'techno_mag',
        display_name: 'SparkyMage',
        preferred_activities: ['park', 'miasto'],
        disliked_activities: ['woda'],
        max_walk_minutes: 10,
        budget_level: 'free',
        companion: 'solo',
      },
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data.class_key).toBe('techno_mag')    // column is class_key, not character_class
    expect(data.level).toBe(1)                    // tier is derived; level is the DB column
    expect(data.visual_anchor).not.toBeNull()     // onboarding_complete ≡ visual_anchor IS NOT NULL
  })

  it('awards XP to character after completing a quest', async () => {
    const user = await insertUser()
    const char = await insertCharacter({ userId: user.id })
    const quest = await insertQuest({ xpBase: 150, xpBonusMax: 50 })
    const attempt = await insertQuestAttempt({ userId: user.id, questId: quest.id })

    // Simulate successful proof verification (normally done by BullMQ)
    // In real code: xpService.awardQuestXp() writes to xp_ledger and updates xp_total
    await db
      .update(characters)
      .set({ xpTotal: 150 })
      .where(eq(characters.id, char.id))

    const res = await client.characters.me.$get({
      headers: { Authorization: `Bearer ${makeToken(user.id)}` },
    })

    const data = await res.json()
    expect(data.xp).toBe(150)
  })
})
```

### 29.13 Mocking External Services

```typescript
// apps/api/src/__tests__/setup.ts — shared mocks for expensive external calls

import { vi, beforeEach } from 'vitest'

// By default, all tests use a mock Gemini client.
// Tests that need real Gemini behavior should use a specific vi.unmock() call.
vi.mock('@google/genai', () => ({
  GoogleGenAI: vi.fn().mockImplementation(() => ({
    models: {
      generateContent: vi.fn().mockResolvedValue({
        text: JSON.stringify({ decision: 'PASS', confidence: 0.95 }),
      }),
    },
  })),
}))

// Mock S3 presigned URL generation
vi.mock('../services/s3-service', () => ({
  generatePresignedUrl: vi.fn().mockResolvedValue('https://s3.example.com/presigned-url'),
  getObject: vi.fn().mockResolvedValue(Buffer.from('fake-image-bytes')),
}))

// Mock Firebase Cloud Messaging — no real pushes in tests
vi.mock('../services/push-service', () => ({
  sendPush: vi.fn().mockResolvedValue({ success: true }),
  sendAdminPush: vi.fn().mockResolvedValue({ success: true }),
}))

// Mock Open-Meteo API with msw
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

export const server = setupServer(
  http.get('https://api.open-meteo.com/v1/forecast', () =>
    HttpResponse.json({
      current: { temperature_2m: 18.5, precipitation: 0.0, wind_speed_10m: 5.0 },
    }),
  ),
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
```

### 29.14 Smoke Test — Full Happy Path

```typescript
// apps/api/src/__tests__/smoke/happy-path.test.ts
// Runs against full stack (docker-compose up) — CI only on main branch

import { describe, it, expect } from 'vitest'

const BASE_URL = 'http://localhost:3000'  // running stack

describe.skipIf(!process.env.SMOKE_TEST)('Full happy path', () => {
  it('register → onboarding → generate quest → start → submit proof → verify → XP', async () => {
    /**
     * End-to-end: full quest completion flow.
     * Uses real HTTP calls against running stack.
     */

    // 1. Auth (use test Firebase token from env)
    const authRes = await fetch(`${BASE_URL}/auth/firebase`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id_token: process.env.TEST_FIREBASE_TOKEN }),
    })
    expect(authRes.status).toBe(200)
    const { access_token: accessToken } = await authRes.json()
    const headers = {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    }

    // 2. Onboarding
    const onboardRes = await fetch(`${BASE_URL}/characters/onboarding`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        character_class: 'techno_mag',
        display_name: 'SmokeTestHero',
        preferred_activities: ['park'],
        disliked_activities: [],
        max_walk_minutes: 20,
        budget_level: 'free',
        companion: 'solo',
      }),
    })
    expect(onboardRes.status).toBe(200)

    // 3. Generate quest
    const questRes = await fetch(`${BASE_URL}/quests/generate`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ lat: 52.2297, lon: 21.0122 }),  // Warsaw center
    })
    expect(questRes.status).toBe(200)
    const { id: questId } = await questRes.json()

    // 4. Start quest
    const startRes = await fetch(`${BASE_URL}/quests/${questId}/start`, { method: 'POST', headers })
    expect(startRes.status).toBe(200)
    const { attempt_id: attemptId } = await startRes.json()

    // 5. Submit text proof (no real file needed for smoke test)
    const submitRes = await fetch(`${BASE_URL}/proofs/${attemptId}/submit`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        proof_type: 'text',
        user_text: 'Odwiedziłem Park Skaryszewski i spędziłem tam 20 minut obserwując naturę. '.repeat(4),
        visibility: 'private',
      }),
    })
    expect(submitRes.status).toBe(202)
    const { proof_id: proofId } = await submitRes.json()

    // 6. Poll for verification result (max 30s)
    let finalStatus = ''
    for (let i = 0; i < 15; i++) {
      await new Promise((r) => setTimeout(r, 2000))
      const statusRes = await fetch(`${BASE_URL}/proofs/${proofId}/status`, { headers })
      const statusData = await statusRes.json()
      if (statusData.status !== 'pending_verification') {
        finalStatus = statusData.status
        break
      }
    }

    expect(['verified_pass', 'verified_fail']).toContain(finalStatus)

    // 7. Check XP was awarded (if PASS)
    if (finalStatus === 'verified_pass') {
      const charRes = await fetch(`${BASE_URL}/characters/me`, { headers })
      const charData = await charRes.json()
      expect(charData.xp).toBeGreaterThan(0)
    }
  }, 60_000)  // 60s timeout for full stack flow
})
```

### 29.15 Coverage Targets (MVP)

| Module | Target | Notes |
|---|---|---|
| `src/routes/` | >= 80% | All happy paths + main error cases |
| `src/core/auth.ts` | >= 90% | Security-critical |
| `src/core/moderation.ts` | >= 95% | Auto-action rules must be fully tested |
| `packages/shared/src/ai/schemas.ts` | >= 90% | Zod validators |
| `src/services/` | >= 70% | Integration-covered via router tests |
| `src/jobs/` | >= 60% | BullMQ jobs tested via direct function calls |
| **Overall** | **>= 75%** | `thresholds.lines: 75` in vitest.config.ts |

Run coverage locally:
```bash
pnpm test --coverage
# or for specific layers:
pnpm vitest run src/__tests__/unit src/__tests__/integration --coverage
open coverage/index.html
```

---

## Section 30 — GPS Anti-Spoofing (3-Layer Defense)

> **Kontekst (remediation plan P1)**: GPS spoofing to główne ryzyko oszustw w grze opartej
> na lokalizacji. Żadna pojedyncza metoda nie wystarczy — stosujemy 3 wzajemnie uzupełniające warstwy.

### 30.1 Warstwa 1 — Behavioral Plausibility (backend)

Sprawdzana **przed** zapisaniem proof, na podstawie danych historycznych z DB.

```typescript
// apps/api/src/core/gps-antispoof.ts

export interface GpsPlausibilityResult {
  plausible:   boolean
  confidence:  number         // 0.0 - 1.0
  flags:       string[]       // 'impossible_speed' | 'teleport' | 'known_spoof_pattern'
  riskScore:   number         // 0.0 (safe) - 1.0 (high risk)
}

export async function checkGpsPlausibility(params: {
  userId:    string
  lat:       number
  lng:       number
  timestamp: Date
  questId:   string
}): Promise<GpsPlausibilityResult> {
  const { userId, lat, lng, timestamp } = params
  const flags: string[] = []

  // 1. Sprawdź ostatnią znana lokalizację usera
  const lastLocation = await locationHistoryRepo.getLastKnown(userId)

  if (lastLocation) {
    const distanceM   = haversineMeters(lastLocation, { lat, lng })
    const elapsedMs   = timestamp.getTime() - lastLocation.timestamp.getTime()
    const elapsedSec  = elapsedMs / 1000
    const speedKmh    = (distanceM / 1000) / (elapsedSec / 3600)

    // Niemożliwa prędkość — teleport wykryty
    // 150 km/h = absolutne max dla samochodu miejskiego (taksówka)
    if (speedKmh > 150 && elapsedSec > 30) {
      flags.push('impossible_speed')
    }

    // Teleport — duży dystans w bardzo krótkim czasie
    if (distanceM > 5000 && elapsedSec < 60) {
      flags.push('teleport')
    }
  }

  // 2. Znane wzorce spoofingu — dokładne koordynaty z popularnych emulatorów GPS
  const KNOWN_SPOOF_COORDS = [
    { lat: 0.0, lng: 0.0 },        // Null Island — domyślny punkt wielu emulatorów
    { lat: 37.4219983, lng: -122.084 },  // Google HQ — często używane w testach
  ]
  const isSpoofCoord = KNOWN_SPOOF_COORDS.some(
    pt => Math.abs(pt.lat - lat) < 0.001 && Math.abs(pt.lng - lng) < 0.001
  )
  if (isSpoofCoord) flags.push('known_spoof_pattern')

  const riskScore = Math.min(flags.length * 0.4, 1.0)

  return {
    plausible:  flags.length === 0,
    confidence: 1.0 - riskScore,
    flags,
    riskScore,
  }
}
```

### 30.2 Warstwa 2 — Perceptual Hash Dedup (background)

Sprawdzana w tle po przyjęciu proof. Wykrywa recycling tego samego zdjęcia do wielu questów.

```typescript
// apps/api/src/core/proof-hash.ts
// Używa sharp + blockhash-core (lub pHash) — uruchamiana w BullMQ 'hashProof' job

export async function computeAndCheckHash(proofId: string): Promise<void> {
  const proof = await proofRepo.findById(proofId)
  if (!proof || proof.mediaType !== 'image') return

  // 1. Pobierz miniaturę z S3
  const imageBuffer = await s3Service.download(proof.thumbnailS3Key ?? proof.s3Key)

  // 2. Oblicz perceptual hash (pHash 64-bit)
  const pHash = await computePHash(imageBuffer)

  // 3. Zapisz w proof_hashes
  await db.insert(proofHashes).values({
    proofId:  proof.id,
    userId:   proof.userId,
    hashType: 'phash',
    hashValue: pHash,
  })

  // 4. Szukaj duplikatów: hamming_distance(hash, pHash) <= 8
  //    (odległość Hamminga <= 8 bitów = "prawie identyczne zdjęcie")
  const duplicates = await db.execute(sql`
    SELECT ph.proof_id, ph.user_id,
           bit_count(${pHash}::bit(64) # ph.hash_value::bit(64)) AS distance
    FROM proof_hashes ph
    WHERE ph.hash_type = 'phash'
      AND ph.proof_id != ${proofId}
      AND ph.user_id = ${proof.userId}           -- sprawdź tylko u tego samego usera
      AND bit_count(${pHash}::bit(64) # ph.hash_value::bit(64)) <= 8
    LIMIT 5
  `)

  if (duplicates.rows.length > 0) {
    // Oznacz jako potencjalny duplikat — trafi do moderacji manualnej
    await proofRepo.setFlag(proofId, 'duplicate_image', {
      similarProofs: duplicates.rows.map(r => r.proof_id),
    })
    // Nie odrzucaj automatycznie — może być zbieg okoliczności (zdjęcie z tego samego miejsca)
    logger.warn({ proofId, duplicates: duplicates.rows }, 'Duplicate proof image detected')
  }
}
```

### 30.3 Warstwa 3 — Device Attestation (mobile → backend)

Używa **Google Play Integrity API** (Android) i **Apple App Attest** (iOS).
Weryfikuje że aplikacja działa na prawdziwym urządzeniu, nie na emulatorze z mock GPS.

```typescript
// Backend: apps/api/src/core/device-attestation.ts

export async function verifyAttestation(params: {
  platform:       'android' | 'ios'
  attestationToken: string     // z Google Play Integrity / Apple App Attest SDK
  proofId:        string
  nonce:          string       // losowe, wygenerowane przez backend przed uploadem
}): Promise<{ valid: boolean; riskSignals: string[] }> {
  if (params.platform === 'android') {
    // Google Play Integrity API
    const result = await googlePlayIntegrity.decodeIntegrityToken(params.attestationToken)
    const signals = result.tokenPayloadExternal?.deviceIntegrity?.deviceRecognitionVerdict ?? []

    // MEETS_DEVICE_INTEGRITY = prawdziwe urządzenie Android
    // MEETS_BASIC_INTEGRITY = mogło być rootowane, ale nie emulator
    const valid = signals.includes('MEETS_DEVICE_INTEGRITY')
    const riskSignals = valid ? [] : ['android_device_not_genuine']
    return { valid, riskSignals }

  } else {
    // Apple App Attest
    const valid = await appleAppAttest.verifyAssertion(
      params.attestationToken,
      params.nonce,
      process.env.APPLE_TEAM_ID!,
      process.env.BUNDLE_ID!,
    )
    return { valid, riskSignals: valid ? [] : ['ios_attestation_failed'] }
  }
}

// Uwaga: attestation jest opcjonalna (MVP może ją pominąć i wprowadzić później)
// Jeśli brak attestation: wyższy próg dla auto-pass (wymaga community vote)
// game_config: { "attestation": { "required_for_auto_pass": false, "downgrade_threshold": 0.97 } }
```

### 30.4 Warstwa 4 — Liveness / Challenge-Response

> **Kontekst (remediation plan P2)**: pHash dedup (§30.2) łapie reużycie dokładnie tego samego
> zdjęcia, ale nie chroni przed zdjęciem zrobionym wcześniej w tej samej lokalizacji ani przed
> zdjęciem pobranym z social media. Liveness dodaje dynamiczny element który dowodzi "teraz".

**MVP (Level 1 — Temporal, zero UX friction):**

Temporal challenge jest automatyczny — nie wymaga żadnego dodatkowego UX od gracza.

```typescript
// Przy quest accept: backend generuje challenge_token
quest.challengeToken     = ulid()          // unikalny per accept, zapisany w DB
quest.challengeExpiresAt = addMinutes(now(), quest.timerMinutes + graceMinutes)

// Przy proof submit: backend weryfikuje:
// 1. upload.challengeToken === quest.challengeToken (token pochodzi z naszej app)
// 2. exif_timestamp ∈ [quest.acceptedAt - 2min, quest.challengeExpiresAt]
// 3. pHash dedup (§30.2) — brak duplikatu z poprzednich proofów tego usera

// Jeśli brak challenge_token (stary klient) → downgrade do community_queue (nie auto_reject)
// Jeśli token ok + EXIF w oknie → sygnał challenge_pass = true w VerificationSignals
```

Dodaj `challenge_pass: boolean` jako sygnał #9 do `VerificationSignals` (§30.2 policy engine).

**Post-MVP (Level 2 — Dynamic code overlay, hard/legendary only):**

App nakłada 4-cyfrowy kod wygenerowany przez backend na podgląd kamery podczas nagrywania.
AI verifier sprawdza obecność kodu jako dodatkowy sygnał:

```typescript
// Przy quest accept (tylko hard/legendary):
quest.challengeCode = String(Math.floor(1000 + Math.random() * 9000))  // 1000-9999
// → przesłany do aplikacji mobilnej, wyświetlany jako overlay na kamerze

// Prompt addition w AI verifier:
`Szukaj w obrazie czterocyfrowego kodu: "${quest.challengeCode}".
 Jeśli kod nie jest wyraźnie widoczny → challenge_code_visible = false`

// challenge_code_visible jako sygnał w policy engine (weight: 0.15 dla hard/legendary)
// Brak kodu → route = community_queue (nie auto_reject — user mógł zasłonić)
```

**Zakres MVP vs roadmap:**

| Mechanizm | MVP | Post-MVP |
|-----------|-----|----------|
| Temporal (EXIF + challenge_token) | ✅ | — |
| pHash dedup (§30.2) | ✅ | — |
| Dynamic code overlay | — | hard/legendary |
| Video liveness / gestural prompt | — | Stage 2 |

---

## Section 31 — Safety Policy Engine (Quest Generation)

> **Kontekst (remediation plan P1)**: AI może wygenerować niebezpieczny quest (np. "wejdź do
> opuszczonego budynku", "poproś obcego o zdjęcie"). Potrzebny post-generation filter.

### 31.1 Overview

Post-generation safety check działa **po** wygenerowaniu questa przez Gemini, **przed** zapisaniem do DB. Jeśli quest nie przejdzie — generujemy ponownie (max 2 retries, potem fallback na bezpieczny default).

```
AI → generateQuest() → [Safety Check] → PASS → save + return
                                       → FAIL → retry (max 2) → fallback
```

### 31.2 Safety Filter

```typescript
// apps/api/src/core/quest-safety.ts

const UNSAFE_PATTERNS: Array<{ pattern: RegExp; category: string }> = [
  { pattern: /opuszczon|zrujnowan|nielegaln|wejdź do .*(budynk|obiekt)/i, category: 'trespass' },
  { pattern: /poproś (obcego|nieznajom|losow)/i,                           category: 'stranger_interaction' },
  { pattern: /nocn|po (ciemku|zmroku|północy)/i,                           category: 'night_only' },
  { pattern: /(dach|balkon|most|tory kolejow)/i,                           category: 'dangerous_location' },
  { pattern: /alkohol|papiero|narkoty|substancj/i,                         category: 'substances' },
  { pattern: /(nagr|sfotografuj|zdję[cć]) (kogoś|osoby|ludzi|tłum)/i,     category: 'photo_people' },
]

export interface SafetyCheckResult {
  safe:     boolean
  flags:    string[]
  blocking: boolean  // false = warning only, true = regenerate
}

export function checkQuestSafety(quest: QuestGenerationOutput): SafetyCheckResult {
  const text = `${quest.title} ${quest.description} ${quest.objective} ${quest.completionHint}`
  const flags: string[] = []

  for (const { pattern, category } of UNSAFE_PATTERNS) {
    if (pattern.test(text)) {
      flags.push(category)
    }
  }

  // Blocking: wymaga regeneracji
  const BLOCKING_CATEGORIES = ['trespass', 'dangerous_location', 'substances', 'photo_people']
  const blocking = flags.some(f => BLOCKING_CATEGORIES.includes(f))

  return { safe: flags.length === 0, flags, blocking }
}

// W quest-service.ts — po generateQuest(), przed zapisem:
export async function generateQuestWithSafetyCheck(context: QuestContext): Promise<Quest> {
  let attempt = 0
  const MAX_RETRIES = 2

  while (attempt <= MAX_RETRIES) {
    const questData = await aiService.generateQuest(context)
    const safety    = checkQuestSafety(questData)

    if (safety.safe || !safety.blocking) {
      if (!safety.safe) {
        logger.warn({ flags: safety.flags, questId: 'not_saved_yet' }, 'Quest has non-blocking safety warnings')
      }
      return await questRepo.create({ ...questData, safetyFlags: safety.flags })
    }

    logger.warn({ attempt, flags: safety.flags }, 'Quest failed safety check, regenerating')
    attempt++
  }

  // Fallback po max retries — bezpieczny domyślny quest (z `game_config.fallback_quest`)
  logger.error({ context }, 'All quest generation attempts failed safety — using fallback')
  const fallback = await configService.get('fallback_quest')
  return await questRepo.create({ ...fallback, isFallback: true, safetyFlags: ['fallback'] })
}
```

### 31.3 First-Quest Safety Net (Cold Start)

> **Kontekst (cold start problem)**: Jeśli pierwszy quest nowego gracza jest odrzucony przez
> AI verifier, gracz odpada z dużym prawdopodobieństwem. First-quest success rate musi być ≥70%.
> Rozwiązanie: łagodniejsze progi weryfikacji + wymuszony easy difficulty dla konta < 24h.

```typescript
// apps/api/src/services/quest-service.ts

// Przy generowaniu questa — sprawdź wiek konta
const isNewAccount = user.createdAt > subHours(now(), 24)

const questContext: QuestContext = {
  ...baseContext,
  // Override dla nowych kont:
  maxDifficulty:        isNewAccount ? 'easy'  : context.requestedDifficulty,
  proofTypePreference:  isNewAccount ? 'photo' : context.proofTypePreference,
}

// W proof-verification — łagodniejszy próg dla pierwszego questa konta
const isFirstQuest = await questRepo.countCompleted(userId) === 0

const thresholds = isFirstQuest
  ? { auto_pass: 0.50, community_queue: 0.25 }   // niższy próg — benefit of doubt
  : await configService.get('verification_thresholds')  // normalne progi z game_config

// Dodaj sygnał is_first_quest do VerificationSignals dla loggingu/audytu
signals.is_first_quest = isFirstQuest
```

Config w `game_config`:
```python
"verification_thresholds": {
    "auto_pass":       0.70,   # Normalny próg
    "community_queue": 0.40,
    "auto_reject":     0.15,
    "first_quest_override": {  # Tylko dla completions_count == 0
        "auto_pass":       0.50,
        "community_queue": 0.25,
    },
},
```

---

## Section 32 — Observability & Metrics

> **Kontekst (remediation plan P2)**: Bez metryk nie wiadomo gdzie proof funnel się sypie,
> ile kosztuje AI, ani kiedy GPS spoof wzrasta. Prometheus + Grafana.

### 32.1 Metryki — co mierzymy

```typescript
// apps/api/src/core/metrics.ts
import { Counter, Histogram, Gauge, register } from 'prom-client'

// ── Proof Funnel ────────────────────────────────────────────────────────────
export const proofSubmitted = new Counter({
  name: 'proof_submitted_total',
  help: 'Total proof submissions',
  labelNames: ['proof_type'],
})

export const proofVerificationResult = new Counter({
  name: 'proof_verification_result_total',
  help: 'Proof verification outcomes',
  labelNames: ['verdict', 'route'],  // route: ai_pass | community | ai_reject
})

export const proofModerationResult = new Counter({
  name: 'proof_moderation_result_total',
  help: 'Content moderation outcomes',
  labelNames: ['action'],  // cleared | auto_hidden | flagged
})

// ── Quest Funnel ────────────────────────────────────────────────────────────
export const questGenerated = new Counter({
  name: 'quest_generated_total',
  help: 'Total quests generated',
  labelNames: ['difficulty', 'location_type', 'source'],  // source: realtime | pregenerated
})

export const questCompleted = new Counter({
  name: 'quest_completed_total',
  help: 'Total quests completed successfully',
  labelNames: ['difficulty'],
})

export const questExpired = new Counter({
  name: 'quest_expired_total',
  help: 'Quests that expired without completion',
  labelNames: ['difficulty'],
})

// ── AI Costs ────────────────────────────────────────────────────────────────
export const aiTokensUsed = new Counter({
  name: 'ai_tokens_total',
  help: 'Total AI tokens used',
  labelNames: ['operation'],  // quest_gen | proof_verify | onboarding | moderation
})

export const aiLatencyMs = new Histogram({
  name: 'ai_latency_ms',
  help: 'AI API call latency in ms',
  labelNames: ['operation'],
  buckets: [100, 500, 1000, 2000, 5000, 10000],
})

// ── GPS Anti-Spoof ──────────────────────────────────────────────────────────
export const gpsSpoofFlags = new Counter({
  name: 'gps_spoof_flag_total',
  help: 'GPS anti-spoof flags raised',
  labelNames: ['flag_type'],  // impossible_speed | teleport | known_spoof_pattern
})

// ── System Health ───────────────────────────────────────────────────────────
export const activeVotingProofs = new Gauge({
  name: 'voting_proofs_active',
  help: 'Number of proofs currently in community_voting',
})

// Endpoint: GET /metrics (tylko dla Prometheus scraper, chroniony przez IP allowlist)
export async function metricsHandler(c: Context) {
  const metrics = await register.metrics()
  return c.text(metrics, 200, { 'Content-Type': register.contentType })
}
```

### 32.2 Integracja — gdzie dodać instrumentację

```typescript
// W proof-worker.ts:
import { proofSubmitted, proofVerificationResult, aiTokensUsed, aiLatencyMs } from '../core/metrics'

// Po submit:
proofSubmitted.inc({ proof_type: proof.mediaType })

// Po weryfikacji AI:
const t0 = Date.now()
const result = await geminiVerify(...)
aiLatencyMs.observe({ operation: 'proof_verify' }, Date.now() - t0)
aiTokensUsed.inc({ operation: 'proof_verify' }, result.tokensUsed ?? 0)
proofVerificationResult.inc({ verdict: result.verdict, route: resolvedRoute })

// W quest-service.ts:
questGenerated.inc({ difficulty: quest.difficulty, location_type: ctx.location.type, source: 'realtime' })
```

### 32.3 Prometheus scrape config

```yaml
# /etc/prometheus/prometheus.yml
scrape_configs:
  - job_name: '20hero_api'
    static_configs:
      - targets: ['api:3000']
    metrics_path: '/metrics'
    scrape_interval: 30s
    # Chroniony: Caddy przepuszcza tylko z sieci wewnętrznej (10.x.x.x)
```

---

## Section 33 — GDPR — Deletion Pipeline

> **Kontekst (remediation plan P2)**: RODO wymaga usunięcia wszystkich danych usera
> w ciągu 30 dni od żądania. Archiwizacja (nie kasowanie) proofów, które przeszły
> przez community voting (integrys głosów), anonimizacja zamiast hard delete.

### 33.1 Flow

```
POST /api/users/me/delete-request
  → Tworzy deletion_requests record (user_id, requested_at, scheduled_for = now + 7 days)
  → Email potwierdzający (opcjonalne 7-dniowe okno na anulowanie)
  → Cron job (raz dziennie) przetwarza scheduled_for <= NOW()
```

### 33.2 Co usuwamy vs. co anonimizujemy

```typescript
// apps/api/src/jobs/gdpr-deletion.ts

export async function processUserDeletion(userId: string): Promise<void> {
  await db.transaction(async (tx) => {
    // 1. HARD DELETE — dane PII
    await tx.delete(users).where(eq(users.id, userId))
    // CASCADE: characters, onboarding_sessions, xp_ledger, user_devices — usuwane automatycznie

    // 2. ANONYMIZE proofów (nie kasujemy — zachowamy integralność głosowań i feedu)
    await tx.execute(sql`
      UPDATE proofs SET
        user_id       = NULL,
        character_id  = NULL,
        user_caption  = '[deleted]',
        cdn_url       = CASE
          WHEN moderation_status IN ('auto_hidden', 'flagged') THEN NULL
          ELSE cdn_url  -- publiczne proofey zostają, ale bez powiązania z userem
        END,
        deleted_at    = NOW()
      WHERE user_id = ${userId}
    `)

    // 3. ANONYMIZE głosów (zachowaj wyniki głosowań, ale bez powiązania z userem)
    await tx.execute(sql`
      UPDATE votes SET voter_user_id = NULL WHERE voter_user_id = ${userId}
    `)

    // 4. Usuń z Firebase Auth
    await admin.auth().deleteUser(firebaseUid)  // firebaseUid pobrany przed transakcją

    // 5. Inwaliduj cache Redis
    await redis.del(`user_summary:${firebaseUid}`)
    await redis.del(`user_summary:${userId}`)

    // 6. Oznacz deletion_request jako completed
    await tx.execute(sql`
      UPDATE deletion_requests SET completed_at = NOW() WHERE user_id = ${userId}
    `)
  })

  // 7. Usuń pliki S3 (poza transakcją — fire and forget z loggingiem)
  const s3Keys = await proofRepo.getS3KeysByUser(userId)  // pobrane przed transakcją
  for (const key of s3Keys) {
    await s3Service.delete(key).catch(err => logger.error({ key, err }, 'S3 deletion failed'))
  }
}
```

### 33.3 DDL — tabela deletion_requests

```sql
CREATE TABLE deletion_requests (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    scheduled_for TIMESTAMPTZ NOT NULL,  -- requested_at + 7 days
    completed_at TIMESTAMPTZ,
    cancelled_at TIMESTAMPTZ
);

CREATE INDEX idx_deletion_requests_scheduled ON deletion_requests(scheduled_for)
  WHERE completed_at IS NULL AND cancelled_at IS NULL;
```

---

## Section 34 — Anti-Fraud Scoring Engine (User Fraud Score)

> **Kontekst (remediation plan P2)**: Obecna architektura ma punktowe kontrole (GPS plausibility,
> pHash dedup, attestation, account age) — ale brak holistic fraud score który śledzi wzorce
> zachowań w czasie. Pojedynczy podejrzany event to błąd; powtarzający się wzorzec to oszust.

### 34.1 User Fraud Score (UFS) — Overview

```typescript
// Persisted per user; aktualizowany po każdej weryfikacji proof + anomalii
interface UserFraudScore {
  userId:      string
  score:       number        // 0–100; wyższy = bardziej podejrzany
  action:      FraudAction   // 'allow' | 'shadow_monitor' | 'manual_review' | 'shadow_ban' | 'ban'
  signals:     FraudSignal[] // historia sygnałów z wagami
  lastUpdated: Date
}

type FraudAction = 'allow' | 'shadow_monitor' | 'manual_review' | 'shadow_ban' | 'ban'
```

**Action thresholds** (konfigurowane w `game_config.fraud_thresholds`):

| Score | Action | Co się dzieje |
|-------|--------|---------------|
| 0–30 | `allow` | Normalny gracz, bez ograniczeń |
| 31–50 | `shadow_monitor` | Pełen dostęp; wszystkie eventy logowane z wyższym priorytetem |
| 51–70 | `manual_review` | Questy idą do ręcznej weryfikacji admina przed naliczeniem XP/HERO |
| 71–90 | `shadow_ban` | Questy "wygasają" cicho; gracz widzi normal UI — brak informacji o banie |
| 91+ | `ban` | Pełna blokada konta + alert admina |

> **Dlaczego shadow_ban?** Gracz w shadow_ban nie wie że jest zbanowany.
> Eliminuje retry przez nowe konto (stary account "działa"), co utrudnia farming przez sybil accounts.

### 34.2 Sygnały — wagi i triggery

```typescript
// apps/api/src/services/fraud-scoring.ts

const FRAUD_SIGNALS: Record<string, { weight: number; ttl_days?: number }> = {
  // GPS
  gps_plausibility_fail:       { weight: 25 },  // §30.1 checkGpsPlausibility()
  known_spoof_coords:          { weight: 30 },  // §30.1 znane koordynaty emulatora

  // Proof integrity
  phash_near_duplicate:        { weight: 20 },  // §30.2 Hamming ≤ 8
  exif_outside_window:         { weight: 15 },  // EXIF > quest_window ± 45min
  challenge_token_missing:     { weight:  8 },  // §30.4 brak tokenu (może = stary klient)
  ai_score_near_zero:          { weight: 10 },  // computeScore() < 0.15

  // Device
  attestation_fail:            { weight: 20 },  // §30.3 Play Integrity / App Attest fail

  // Velocity
  quest_velocity_high:         { weight: 15 },  // > 4 questów / 2h (fizycznie niemożliwe)
  proof_retry_exhausted:       { weight:  8 },  // wielokrotne proof_retry w krótkim czasie

  // Social / voting
  voting_always_majority:      { weight: 10 },  // 95%+ głosów = zawsze z wygraną stroną (sybil)

  // Account
  account_age_new:             { weight:  5, ttl_days: 14 }, // konto < 7 dni — maleje samo

  // Decay (ujemne — reward za clean streak)
  clean_day:                   { weight: -2, ttl_days: 1  }, // każdy dzień bez suspicious activity
}
```

### 34.3 Implementacja — update flow

```typescript
// Fire-and-forget po każdej weryfikacji proof / anomalii
// NIE blokuje response do użytkownika

export async function updateFraudScore(
  userId: string,
  triggeredSignals: string[],
): Promise<void> {
  const current = await fraudScoreRepo.get(userId) ?? { score: 0, signals: [] }

  let delta = 0
  for (const signalKey of triggeredSignals) {
    const def = FRAUD_SIGNALS[signalKey]
    if (def) delta += def.weight
  }

  // Clean day decay (jeśli brak podejrzanych sygnałów dzisiaj)
  const hadSuspiciousToday = triggeredSignals.some(s => FRAUD_SIGNALS[s]?.weight > 0)
  if (!hadSuspiciousToday) delta += FRAUD_SIGNALS.clean_day.weight  // -2

  const newScore = Math.max(0, Math.min(100, current.score + delta))
  const newAction = resolveAction(newScore)

  await fraudScoreRepo.upsert({
    userId,
    score:       newScore,
    action:      newAction,
    signals:     [...current.signals, ...triggeredSignals.map(s => ({ key: s, ts: new Date() }))],
    lastUpdated: new Date(),
  })

  if (newAction !== current.action) {
    logger.warn({ userId, oldAction: current.action, newAction, score: newScore }, 'Fraud action changed')
    if (newAction === 'ban' || newAction === 'shadow_ban') {
      await adminAlertService.send({ type: 'fraud_escalation', userId, score: newScore, action: newAction })
    }
  }
}

function resolveAction(score: number): FraudAction {
  if (score >= 91) return 'ban'
  if (score >= 71) return 'shadow_ban'
  if (score >= 51) return 'manual_review'
  if (score >= 31) return 'shadow_monitor'
  return 'allow'
}
```

### 34.4 DDL — tabela user_fraud_scores

```sql
CREATE TABLE user_fraud_scores (
    user_id      UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    score        SMALLINT NOT NULL DEFAULT 0 CHECK (score BETWEEN 0 AND 100),
    action       TEXT NOT NULL DEFAULT 'allow'
                 CHECK (action IN ('allow','shadow_monitor','manual_review','shadow_ban','ban')),
    signals      JSONB NOT NULL DEFAULT '[]',
    last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_ufs_action ON user_fraud_scores(action) WHERE action != 'allow';
```

### 34.5 Admin Panel — manual_review queue

```
GET /api/admin/fraud/queue?action=manual_review   → lista userów czekających na review
POST /api/admin/fraud/{userId}/resolve
  body: { decision: 'clear' | 'shadow_ban' | 'ban', reason: string }
  → aktualizuje score + action + zapisuje do admin_audit_log
```

Config w `game_config`:
```python
"fraud_thresholds": {
    "shadow_monitor": 31,
    "manual_review":  51,
    "shadow_ban":     71,
    "ban":            91,
},
```
