<!-- PART OF: ARCHITECTURE.md — The 20-Minute Hero Complete Architecture -->
<!-- DOCUMENT: 02-api-endpoints.md -->
<!-- CONTENTS: All API Endpoints, Zod Domain Models -->
<!-- SPLIT: lines 1277–1905 of original file -->

## 2. All API Endpoints

### Health Check

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | — | Liveness probe (Docker healthcheck, uptime monitoring) |

```typescript
// apps/api/src/routes/health.ts
// Zwraca 200 OK gdy serwis działa; sprawdza połączenie z DB i Redis
import { Hono } from 'hono'
import { db } from '@20hero/db'
import { sql } from 'drizzle-orm'
import { redis } from '../db/redis'

const health = new Hono()

health.get('/', async (c) => {
  await db.execute(sql`SELECT 1`)
  await redis.ping()
  return c.json({ status: 'ok' })
})

export { health as healthRouter }
```

### Authentication (`/api/auth`)

> Auth przez Firebase (Google + Apple Sign-In). Brak email/password. Szczegóły: Sekcja 14.
>
> **Uproszczony model (remediation plan):**
> Aplikacja mobilna używa **Firebase ID tokenu** jako Bearer tokenu do wszystkich requestów
> (TTL 1h, auto-odnawiany przez Firebase SDK). Backend weryfikuje go przez Firebase Admin SDK.
> Własny token sesji (`session_token`) wystawiany tylko dla endpointów webowych (admin panel)
> gdzie Firebase SDK jest niedostępny. **Brak własnego access + refresh tokenu** — zmniejsza
> powierzchnię błędu i eliminuje konieczność zarządzania key rotation i token revocation.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/auth/register` | Firebase ID token | Pierwsza rejestracja — upsert user w DB, zwróć profil |
| POST | `/api/auth/logout` | Firebase ID token | Unieważnienie urządzenia (usuwa wpis z user_devices) |
| POST | `/api/auth/device-token` | Firebase ID token | Rejestracja FCM tokenu push (upsert w user_devices) |
| DELETE | `/api/auth/device-token` | Firebase ID token | Usunięcie FCM tokenu (przy wylogowaniu) |

> **Usunięte endpointy** (względem poprzedniej wersji):
> - `POST /api/auth/firebase` — zastąpiony przez `/register`; ID token weryfikowany w middleware
> - `POST /api/auth/refresh` — nie potrzebny, Firebase SDK sam odnawiia ID token
>
> **Nagłówek autoryzacyjny** (wszystkie chronione endpointy):
> ```
> Authorization: Bearer <Firebase_ID_token>
> ```
> Middleware `requireAuth` weryfikuje przez `firebaseAdmin.auth().verifyIdToken()`,
> ładuje/tworzy user record w DB i ustawia `c.set('user', user)`.

```typescript
// packages/types/src/auth.ts
import { z } from 'zod'

export const RegisterRequestSchema = z.object({
  idToken:     z.string(),          // Firebase ID token (weryfikowany przez backend)
  displayName: z.string().optional(),
  photoUrl:    z.string().url().optional(),
})

export const RegisterResponseSchema = z.object({
  user:        UserSummarySchema,
  isNewUser:   z.boolean(),         // true = pierwszy raz, false = powrót
})

export const DeviceTokenRequestSchema = z.object({
  token:       z.string(),          // FCM token
  platform:    z.enum(['android', 'ios']),
  appVersion:  z.string(),
  deviceModel: z.string().optional(),
})

export type RegisterRequest = z.infer<typeof RegisterRequestSchema>
export type RegisterResponse = z.infer<typeof RegisterResponseSchema>
export type DeviceTokenRequest = z.infer<typeof DeviceTokenRequestSchema>
```

### Onboarding (`/api/onboarding`)

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/onboarding/start` | Start guild recruitment conversation |
| POST | `/api/onboarding/{sessionId}/message` | Send next user message |
| GET | `/api/onboarding/{sessionId}` | Get current session state |
| POST | `/api/onboarding/{sessionId}/finalize` | Force-complete and assign class |

```typescript
// packages/types/src/onboarding.ts
import { z } from 'zod'

// POST /api/onboarding/start
export const OnboardingStartResponseSchema = z.object({
  sessionId:       z.string(),
  greetingMessage: z.string(),   // First AI message
  question:        z.string(),   // First question to user
})

// POST /api/onboarding/{sessionId}/message
export const OnboardingMessageRequestSchema = z.object({
  content: z.string(),           // User's reply
})

export const OnboardingMessageResponseSchema = z.object({
  sessionId:   z.string(),
  aiMessage:   z.string(),       // AI's next message
  isComplete:  z.boolean(),
  character:   CharacterResponseSchema.nullable(),  // Set when isComplete=true
  progressPct: z.number().int(), // 0-100 estimated completion
})
```

### Characters (`/api/characters`)

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/characters/me` | Get current user's active character |
| GET | `/api/characters/{id}` | Get character by ID (public profile) |
| PATCH | `/api/characters/me` | Update character name/avatar |
| GET | `/api/characters/me/stats` | Detailed stats (XP history, quest count) |
| GET | `/api/characters/me/milestones` | List unlocked milestones |

```typescript
// packages/types/src/character.ts
import { z } from 'zod'

export const AttributeSetSchema = z.object({
  sila:      z.number().int(),
  intelekt:  z.number().int(),
  charyzma:  z.number().int(),
  zrecznosc: z.number().int(),
  percepcja: z.number().int(),
})

export const CharacterResponseSchema = z.object({
  id:               z.string().uuid(),
  name:             z.string(),
  classKey:         z.string(),
  classDisplay:     z.string(),          // "Mag Logistyki"
  title:            z.string(),
  backstory:        z.string(),
  level:            z.number().int(),
  xpTotal:          z.number().int(),
  xpToNext:         z.number().int(),
  xpProgressPct:    z.number(),
  attributes:       AttributeSetSchema,
  equipment:        z.record(z.unknown()),
  personalityTraits: z.string().array(),
  questsCompleted:  z.number().int(),
  createdAt:        z.string().datetime(),
})

export type AttributeSet = z.infer<typeof AttributeSetSchema>
export type CharacterResponse = z.infer<typeof CharacterResponseSchema>
```

### Quest Engine (`/api/quests`)

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/quests/generate` | Generate new quest for current location |
| GET | `/api/quests/active` | Get current active/accepted quest |
| GET | `/api/quests/{id}` | Get quest details |
| POST | `/api/quests/{id}/accept` | Accept a generated quest |
| POST | `/api/quests/{id}/abandon` | Abandon active quest (cooldown penalty) |
| GET | `/api/quests/history` | Paginated quest history |
| GET | `/api/quests/nearby` | Get quests completed near GPS point (social) |

```typescript
// packages/types/src/quest.ts
import { z } from 'zod'

// POST /api/quests/generate
// Pełna definicja niżej — lat/lng opcjonalne (user może odmówić GPS)
// Patrz pełny schemat QuestGenerateRequest w sekcji Rate Limiting (Section 26)

export const QuestGenerateResponseSchema = z.object({
  questId:         z.string().uuid(),
  title:           z.string(),
  description:     z.string(),
  objective:       z.string(),
  completionHint:  z.string(),
  loreBlurb:       z.string().nullable(),
  difficulty:      z.string(),
  xpBase:          z.number().int(),    // base XP (matches DDL: quests.xp_base)
  xpBonusMax:      z.number().int(),    // max community vote bonus (matches DDL: quests.xp_bonus_max)
  estimatedMinutes: z.number().int(),
  locationContext: z.record(z.unknown()), // {place_name, location_type, weather_summary}
  expiresInSeconds: z.number().int(),   // After acceptance
})

// POST /api/quests/{id}/accept
export const QuestAcceptResponseSchema = z.object({
  questId:    z.string().uuid(),
  acceptedAt: z.string().datetime(),
  expiresAt:  z.string().datetime(),
  status:     z.string(),
})
```

### Proof Submission (`/api/proofs`)

> Pełny flow: Sekcja 6b (upload) + Sekcja 4.3 (weryfikacja) + Sekcja 8b (voting).

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/proofs/{attemptId}/upload-url` | ✓ | Presigned S3 PUT URL(s) (TTL 10 min) |
| POST | `/api/proofs/{attemptId}/submit` | ✓ | Potwierdź upload i uruchom weryfikację |
| GET | `/api/proofs/{proofId}/status` | ✓ | Status weryfikacji + wynik |
| GET | `/api/proofs/feed` | ✓ | Publiczny feed (z filtrami) |

```
Flows per proof_type:

── photo / video / audio ──────────────────────────────────────────────
  1. POST /api/proofs/{attemptId}/upload-url
     Body: { proofType: "photo", fileExtension: "jpg", fileSizeBytes: 4200000 }
     → { uploadUrl, s3Key, expiresIn: 600 }

  2. App: PUT {uploadUrl}  (bezpośrednio do S3, backend nie przetwarza bajtów)

  3. POST /api/proofs/{attemptId}/submit
     Body: { proofType: "photo", s3Key: "proofs/...", userCaption: "...", visibility: "public" }
     → { proofId, status: "processing" }

── multi_photo ─────────────────────────────────────────────────────────
  1. POST /api/proofs/{attemptId}/upload-url
     Body: { proofType: "multi_photo", photoCount: 3, fileExtension: "jpg",
             fileSizesBytes: [3200000, 2800000, 1900000] }
     → { uploadUrls: [{uploadUrl, s3Key}, ...], expiresIn: 600 }

  2. App: PUT każdy uploadUrl równolegle (3 oddzielne S3 PUT)

  3. POST /api/proofs/{attemptId}/submit
     Body: { proofType: "multi_photo", s3Keys: ["proofs/1.jpg", "proofs/2.jpg", "proofs/3.jpg"],
             userCaption: "...", visibility: "public" }
     → { proofId, status: "processing" }

── text ────────────────────────────────────────────────────────────────
  1. (Brak upload — brak pliku S3)

  2. POST /api/proofs/{attemptId}/submit
     Body: { proofType: "text", userText: "...", visibility: "public" }
     → { proofId, status: "processing" }

── status polling ──────────────────────────────────────────────────────
  4. GET /api/proofs/{proofId}/status  (polling co 3s lub czekaj na push)
     → { status, verdict?, xpAwarded?, tokensAwarded?, userFeedback?, completionFeedback? }
```

```typescript
// packages/types/src/proof.ts
import { z } from 'zod'

// ── Upload URL ──────────────────────────────────────────────────────────

const ALLOWED_EXTENSIONS: Record<string, Set<string>> = {
  photo:       new Set(['jpg', 'jpeg', 'png', 'heic']),
  multi_photo: new Set(['jpg', 'jpeg', 'png', 'heic']),
  video:       new Set(['mp4', 'mov', 'webm']),
  audio:       new Set(['mp3', 'm4a', 'ogg', 'wav']),
}

const MAX_SIZE_BYTES: Record<string, number> = {
  photo:       20 * 1024 * 1024,  // 20 MB
  multi_photo: 20 * 1024 * 1024,  // per photo
  video:       200 * 1024 * 1024, // 200 MB
  audio:       30 * 1024 * 1024,  // 30 MB
}

export const UploadUrlRequestSchema = z.object({
  // NOTE: "text" proof_type is intentionally excluded — text proofs bypass S3 upload
  // and go directly to POST /proofs/{id}/submit with userText in the body.
  proofType:      z.enum(['photo', 'multi_photo', 'video', 'audio']),
  fileExtension:  z.string(),          // ext bez kropki: "jpg", "mp4", "mp3"
  fileSizeBytes:  z.number().int(),    // walidacja przed presigned URL (single file)
  // Dla multi_photo:
  photoCount:     z.number().int().min(2).max(3).nullable().optional(),
  fileSizesBytes: z.number().int().array().nullable().optional(),
}).superRefine((data, ctx) => {
  if (data.proofType === 'multi_photo') {
    if (!data.photoCount || !data.fileSizesBytes) {
      ctx.addIssue({ code: 'custom', message: 'multi_photo wymaga photoCount i fileSizesBytes' })
    } else if (data.fileSizesBytes.length !== data.photoCount) {
      ctx.addIssue({ code: 'custom', message: 'fileSizesBytes musi mieć tyle elementów co photoCount' })
    }
  }
  const allowed = ALLOWED_EXTENSIONS[data.proofType]
  if (allowed && !allowed.has(data.fileExtension)) {
    ctx.addIssue({ code: 'custom', message: `Niedozwolone rozszerzenie "${data.fileExtension}" dla ${data.proofType}` })
  }
})

export const SingleUploadUrlSchema = z.object({
  uploadUrl: z.string(),  // S3 presigned PUT, wygasa po 10 min
  s3Key:     z.string(),
})

export const UploadUrlResponseSchema = z.object({
  // Dla photo/video/audio:
  uploadUrl:  z.string().nullable().optional(),
  s3Key:      z.string().nullable().optional(),
  // Dla multi_photo:
  uploadUrls: SingleUploadUrlSchema.array().nullable().optional(),
  expiresIn:  z.number().int().default(600),
})

// ── Submit ────────────────────────────────────────────────────────────

export const ProofSubmitRequestSchema = z.object({
  proofType:   z.enum(['photo', 'multi_photo', 'video', 'audio', 'text']),
  visibility:  z.enum(['private', 'friends', 'public']).default('public'),

  // photo / video / audio — jeden plik
  s3Key:       z.string().nullable().optional(),
  userCaption: z.string().max(300).nullable().optional(),

  // multi_photo — lista kluczy (2-3)
  s3Keys:      z.string().array().min(2).max(3).nullable().optional(),

  // text — treść bezpośrednio
  userText:    z.string().min(50).max(1000).nullable().optional(),
}).superRefine((data, ctx) => {
  if (data.proofType === 'text') {
    if (!data.userText) ctx.addIssue({ code: 'custom', message: 'text proof wymaga userText' })
  } else if (data.proofType === 'multi_photo') {
    if (!data.s3Keys) ctx.addIssue({ code: 'custom', message: 'multi_photo wymaga s3Keys' })
  } else {
    if (!data.s3Key) ctx.addIssue({ code: 'custom', message: `${data.proofType} proof wymaga s3Key` })
  }
})

export const ProofSubmitResponseSchema = z.object({
  proofId: z.string().uuid(),
  status:  z.literal('processing'),
})

// ── Status ────────────────────────────────────────────────────────────

export const ProofStatusResponseSchema = z.object({
  status:             z.string(),      // "processing"|"ai_verified"|"community_voting"|"rejected"
  verdict:            z.string().nullable(),
  xpAwarded:          z.number().int().nullable(),
  tokensAwarded:      z.number().int().nullable(),
  userFeedback:       z.string().nullable(),   // Z weryfikacji (neutralna/negatywna)
  completionFeedback: z.string().nullable(),   // Z Section 4d (pozytywna, po sukcesie)
})

// ── VoteQueueItem — aktualizacja dla non-media proofs ─────────────────

export const VoteQueueItemSchema = z.object({
  proofId:        z.string().uuid(),
  questTitle:     z.string(),
  questObjective: z.string(),
  difficulty:     z.string(),
  proofType:      z.string(),              // "photo"|"multi_photo"|"video"|"audio"|"text"
  mediaUrl:       z.string().nullable(),   // NULL dla text proofs
  mediaUrls:      z.string().array().nullable(), // multi_photo — lista URLs
  userText:       z.string().nullable(),   // Dla text proofs — tekst do oceny
  userCaption:    z.string().nullable(),
  votingClosesAt: z.string().datetime(),
  distanceM:      z.number().int().nullable(),
})
```

### Voting (`/api/voting`)

> Szczegóły: Sekcja 8b. Głosy są anonimowe, liczniki ukryte przed oddaniem głosu (anty-bandwagon).

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/proofs/vote-queue` | ✓ | Proofs do głosowania (posortowane po pilności) |
| POST | `/api/proofs/{proofId}/vote` | ✓ | Oddaj głos (approve/reject) |

```typescript
// packages/types/src/voting.ts
import { z } from 'zod'

// Pełna definicja VoteQueueItem w Sekcji 8b (voting service).
// UWAGA: brak currentApprove/currentReject — celowo (anty-bandwagon, Sekcja 8b)
// Liczniki widoczne dopiero po oddaniu własnego głosu

export const CastVoteRequestSchema = z.object({
  vote: z.enum(['approve', 'reject']),
})

export const CastVoteResponseSchema = z.object({
  xpEarned:       z.number().int(),  // Natychmiastowe +5 XP
  votesRemaining: z.number().int(),  // Ile głosów może oddać dziś (do XP cap)
})
```

### Social / Groups (`/api/groups`)

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/groups` | List public groups |
| POST | `/api/groups` | Create group |
| GET | `/api/groups/{slug}` | Get group details |
| POST | `/api/groups/{slug}/join` | Join public group |
| POST | `/api/groups/{slug}/leave` | Leave group |
| GET | `/api/groups/{slug}/members` | List members with stats |
| GET | `/api/groups/{slug}/feed` | Group activity feed |
| GET | `/api/groups/{slug}/leaderboard` | Group XP leaderboard |

### Leaderboard (`/api/leaderboard`)

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/leaderboard/global` | Global top characters |
| GET | `/api/leaderboard/groups` | Top groups by total XP |
| GET | `/api/leaderboard/weekly` | Weekly quest completions |
| GET | `/api/leaderboard/class/{classKey}` | Top within a class |

### Users (`/api/users`)

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/users/me` | ✓ | Profil zalogowanego użytkownika |
| PATCH | `/api/users/me` | ✓ | Aktualizacja username |
| DELETE | `/api/users/me` | ✓ | **Usunięcie konta** (wymagane przez App Store + Google Play) |
| POST | `/api/users/me/location` | ✓ | Aktualizacja GPS (nie historii — RODO) |
| GET | `/api/users/me/notification-prefs` | ✓ | Preferencje powiadomień |
| PATCH | `/api/users/me/notification-prefs` | ✓ | Zmiana preferencji powiadomień |
| GET | `/api/users/{id}` | ✓ | Publiczny profil użytkownika |

> **App Store / Google Play requirement (od 2022):** Aplikacje z logowaniem muszą oferować usunięcie konta z poziomu aplikacji. `DELETE /api/users/me` ustawia `users.deleted_at = NOW()` (soft delete) i usuwa dane PII (email, display_name, photo_url → NULL) oraz unieważnia wszystkie refresh_tokeny. Dane gameplay (XP, questy) mogą pozostać zanonimizowane zgodnie z RODO Art. 17.

### Social (`/api/social`)

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/social/follow/{userId}` | ✓ | Obserwuj użytkownika (asymetryczne) |
| DELETE | `/api/social/follow/{userId}` | ✓ | Przestań obserwować |
| GET | `/api/social/followers` | ✓ | Lista obserwujących mnie |
| GET | `/api/social/following` | ✓ | Lista obserwowanych przeze mnie |
| POST | `/api/social/proofs/{proofId}/react` | ✓ | Dodaj reakcję 👍 |
| DELETE | `/api/social/proofs/{proofId}/react` | ✓ | Usuń reakcję |
| GET | `/api/social/proofs/{proofId}/comments` | ✓ | Komentarze pod proofem |
| POST | `/api/social/proofs/{proofId}/comments` | ✓ | Dodaj komentarz |
| DELETE | `/api/social/comments/{commentId}` | ✓ | Usuń swój komentarz |
| GET | `/api/social/feed` | ✓ | Feed (global/following/local) |

### Avatars (`/api/characters/me/avatar`)

> Szczegóły: Sekcja 12.5.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/characters/me/avatar` | ✓ | Aktualny avatar + status generowania |
| GET | `/api/characters/me/avatar/history` | ✓ | Historia wszystkich tierów avatara |
| POST | `/api/characters/me/avatar/regenerate` | ✓ | Wymuś regenerację (admin lub po zmianie) |

### Tokens (`/api/tokens`)

> MVP: saldo widoczne, wypłaty i sklep wyłączone (`enabled=false` w game_config).

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/tokens/balance` | ✓ | Saldo HERO tokenów |
| GET | `/api/tokens/ledger` | ✓ | Historia transakcji (paginacja) |
| GET | `/api/tokens/shop` | ✓ | Dostępne przedmioty kosmetyczne |
| POST | `/api/tokens/shop/purchase` | ✓ | Zakup kosmetyczny (blokuje pay-to-win) |

```typescript
// packages/types/src/tokens.ts
import { z } from 'zod'

// POST /api/tokens/shop/purchase — request/response schemas
export const TokenShopPurchaseRequestSchema = z.object({
  itemKey: z.string(),   // e.g. "avatar_frame_gold", "banner_neon"
  // quantity intentionally omitted — all cosmetics are single-purchase (idempotent)
})

export const TokenShopPurchaseResponseSchema = z.object({
  itemKey:      z.string(),
  itemCategory: z.string(),          // e.g. "avatar_frames", "banners"
  pricePaid:    z.number().int(),    // HERO tokens deducted
  newBalance:   z.number(),          // remaining HERO balance
  activatedAt:  z.string().datetime(), // when the cosmetic becomes active
})

// Error cases:
// 402 InsufficientBalance — saldo < cena
// 409 AlreadyOwned — itemKey already in cosmetic_purchases for this user
// 503 ShopDisabled — game_config("token_shop.enabled") == false
```


### Admin (`/api/admin`)

> Wymaga `users.is_admin = TRUE`. Szczegóły: Sekcja 17.

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/admin/config` | Lista wszystkich kluczy game_config |
| GET | `/api/admin/config/{key}` | Szczegóły + historia zmian |
| PATCH | `/api/admin/config/{key}` | Aktualizacja wartości + inwalidacja Redis |
| POST | `/api/admin/config` | Nowy klucz (rzadko) |
| GET | `/api/admin/prompts` | Lista szablonów AI (podgląd) |
| GET | `/api/admin/prompts/{key}` | Pełna treść szablonu |
| PUT | `/api/admin/prompts/{key}` | Aktualizacja + walidacja szablonu |
| GET | `/api/admin/audit-log` | Historia zmian (filtrowana) |

### Quest Generation Context (`/api/quests/generate`)

> Request body dla `POST /api/quests/generate` — wszystkie pola opcjonalne poza lokalizacją.

```typescript
// packages/types/src/quest.ts (continued)
export const QuestGenerateRequestSchema = z.object({
  availableMinutes:     z.number().int().min(5).max(120).default(20),
  mood:                 z.string().nullable().optional(),  // z game_config("quest_generation.mood_options")
  budget:               z.string().default('free'),        // z game_config("quest_generation.budget_options")
  companionMode:        z.string().default('solo'),        // z game_config("quest_generation.companion_options")
  lat:                  z.number().nullable().optional(),
  lng:                  z.number().nullable().optional(),
  // Wymagane gdy brak GPS lub user odmówił dostępu do lokalizacji
  locationTypeOverride: z.string().nullable().optional(),
})

export type QuestGenerateRequest = z.infer<typeof QuestGenerateRequestSchema>
```

### Quest Paths / Ścieżki (`/api/paths`)

> Aktywowane przez `game_config("paths.enabled") = true`. Na MVP endpoints istnieją, zwracają 404 gdy wyłączone.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/paths` | ✓ | Lista dostępnych ścieżek (odblokowane dla użytkownika) |
| GET | `/api/paths/{slug}` | ✓ | Szczegóły ścieżki + postęp użytkownika |
| POST | `/api/paths/{slug}/start` | ✓ | Rozpocznij ścieżkę |
| GET | `/api/paths/me/active` | ✓ | Aktywne ścieżki użytkownika |
| POST | `/api/paths/me/{pathId}/abandon` | ✓ | Porzuć ścieżkę |

### Proof Visibility

> Pole `visibility` na każdym proof, zmienialne po publikacji.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| PATCH | `/api/proofs/{id}/visibility` | ✓ | Zmień widoczność (`private`/`friends`/`public`) |
| GET | `/api/proofs/{id}/media` | ✓ | Lista mediów (dla `multi_photo` proof) |

---

## 3. TypeScript Domain Models

```typescript
// packages/types/src/character.ts

// UWAGA: wartości MUSZĄ być identyczne jak w DDL ENUM character_class (Sekcja 1)
export const CHARACTER_CLASSES = [
  'techno_mag',
  'chrom_paladin',
  'widmo_biegacz',
  'bio_szaman',
  'inzynier_spoleczny',
  'kurier_cieni',
  'architekt_danych',
  'koderyta',
] as const

export type CharacterClass = typeof CHARACTER_CLASSES[number]

export const CLASS_DISPLAY: Record<CharacterClass, string> = {
  techno_mag:         'Techno-Mag',
  chrom_paladin:      'Chrom-Paladyn',
  widmo_biegacz:      'Widmo-Biegacz',
  bio_szaman:         'Bio-Szaman',
  inzynier_spoleczny: 'Inżynier Społeczny',
  kurier_cieni:       'Kurier Cieni',
  architekt_danych:   'Architekt Danych',
  koderyta:           'Koderyta',
}

// Class → primary attribute boosts
export const CLASS_ATTRIBUTE_BOOSTS: Record<CharacterClass, Partial<Record<string, number>>> = {
  techno_mag:         { intelekt: 25, percepcja: 20 },
  chrom_paladin:      { sila: 25, charyzma: 20 },
  widmo_biegacz:      { zrecznosc: 25, percepcja: 20 },
  bio_szaman:         { percepcja: 25, sila: 20 },
  inzynier_spoleczny: { charyzma: 30, intelekt: 15 },
  kurier_cieni:       { zrecznosc: 30, sila: 15 },
  architekt_danych:   { intelekt: 30, charyzma: 15 },
  koderyta:           { charyzma: 20, intelekt: 20, percepcja: 10 },
}


// packages/types/src/character.ts (continued)

export interface Character {
  id:              string
  userId:          string
  name:            string
  characterClass:  CharacterClass
  title:           string
  backstory:       string
  level:           number
  xpTotal:         number
  xpToNext:        number
  attrSila:        number
  attrIntelekt:    number
  attrCharyzma:    number
  attrZrecznosc:   number
  attrPercepcja:   number
  equipment:       Record<string, unknown>
  personalityTraits: string[]
  isActive:        boolean
  createdAt:       Date
  updatedAt:       Date
}

export function getClassDisplay(character: Character): string {
  return CLASS_DISPLAY[character.characterClass] ?? character.characterClass
}

export function getXpProgressPct(character: Character): number {
  if (character.xpToNext === 0) return 100.0
  // xpToNext is the XP span for the current level; formula: totalXpForLevel(N) = coeff * N * (N-1)
  // → xpAtLevelStart(N) = xpToNext * (N-1) / 2
  const xpAtLevelStart = character.xpToNext * (character.level - 1) / 2
  const xpInLevel = character.xpTotal - xpAtLevelStart
  return Math.min(100.0, (xpInLevel / character.xpToNext) * 100)
}


// packages/types/src/quest.ts

export const QUEST_STATUSES = [
  'available',
  'accepted',
  'in_progress',
  'proof_submitted',
  'completed',
  'failed',
  'expired',
] as const

export type QuestStatus = typeof QUEST_STATUSES[number]


export const QUEST_DIFFICULTIES = ['easy', 'medium', 'hard', 'legendary'] as const
export type QuestDifficulty = typeof QUEST_DIFFICULTIES[number]


// Difficulty → base XP seed defaults (used ONLY by seed script to populate game_config)
// Runtime code reads from: await configService.get("xp_per_difficulty")
// Do NOT use DIFFICULTY_XP_SEED_DEFAULTS directly in game logic.
export const DIFFICULTY_XP_SEED_DEFAULTS: Record<QuestDifficulty, number> = {
  easy:      100,
  medium:    200,
  hard:      400,
  legendary: 800,
}

// Difficulty → minimum level to unlock
export const DIFFICULTY_MIN_LEVEL: Record<QuestDifficulty, number> = {
  easy:      1,
  medium:    5,
  hard:      10,
  legendary: 20,
}


export interface Quest {
  id:            string
  characterId:   string
  userId:        string
  title:         string
  description:   string
  objective:     string
  completionHint: string
  loreBlurb:     string | null
  difficulty:    QuestDifficulty
  estimatedMinutes: number
  locationType:  string | null
  weatherAtGen:  Record<string, unknown> | null
  lat:           number | null
  lng:           number | null
  geohash:       string | null
  placeName:     string | null
  xpBase:        number
  xpBonusMax:    number
  status:        QuestStatus
  acceptedAt:    Date | null
  expiresAt:     Date | null
  createdAt:     Date
}
```

---

## Guilds (`/api/guilds`) — §11.6

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/guilds` | Firebase ID token | Utwórz gildię (max 1 aktywna na usera) |
| GET | `/api/guilds?lat&lng&radius_km` | Firebase ID token | Pobierz pobliskie gildie (geohash-based) |
| GET | `/api/guilds/{id}` | Firebase ID token | Szczegóły gildii + członkowie + aktualne GWT |
| POST | `/api/guilds/{id}/join` | Firebase ID token | Dołącz do gildii (wymaga invite_code lub publiczna) |
| DELETE | `/api/guilds/{id}/members/me` | Firebase ID token | Opuść gildię |
| DELETE | `/api/guilds/{id}/members/{uid}` | Firebase ID token + officer | Wyrzuć członka (tylko officer/admin gildii) |
| PATCH | `/api/guilds/{id}/members/{uid}` | Firebase ID token + officer | Zmień rolę członka (member↔officer) |
| GET | `/api/guilds/{id}/challenge` | Firebase ID token | Aktualne Guild Weekly Challenge + progress |
| POST | `/api/guilds/{id}/challenge/claim` | Firebase ID token | Odbierz nagrody GWT (po osiągnięciu targetu) |

```typescript
// packages/types/src/guild.ts
import { z } from 'zod'

export const CreateGuildSchema = z.object({
  name:        z.string().min(3).max(30),
  description: z.string().max(200).optional(),
  visibility:  z.enum(['public', 'invite_only']).default('public'),
  city:        z.string().max(100),            // geohash_4 wyliczany z headquarters
  headquarters_lat: z.number(),
  headquarters_lng: z.number(),
})

export const GuildResponseSchema = z.object({
  id:           z.string().uuid(),
  name:         z.string(),
  description:  z.string().nullable(),
  visibility:   z.enum(['public', 'invite_only']),
  founder_id:   z.string().uuid(),
  member_count: z.number().int(),
  total_xp:     z.number().int(),
  invite_code:  z.string().nullable(),         // null jeśli public
  current_challenge: z.object({
    id:          z.string().uuid(),
    objective:   z.string(),
    target_count: z.number().int(),
    progress:    z.number().int(),
    reward_xp:   z.number().int(),
    deadline:    z.string().datetime(),
    completed:   z.boolean(),
  }).nullable(),
  created_at:   z.string().datetime(),
})
```

---

## Seasons (`/api/seasons`) — §11.7

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/seasons/current` | Firebase ID token | Aktualna pora roku: metadata, pass tier usera, quest flavor |
| GET | `/api/seasons/current/pass` | Firebase ID token | Status season pass usera (free/paid, reward progress) |
| POST | `/api/seasons/current/pass/activate` | Firebase ID token | Aktywuj paid tier (po IAP validation) |
| POST | `/api/seasons/current/rewards/{reward_key}/claim` | Firebase ID token | Odbierz reward z reward track (po osiągnięciu progu) |
| GET | `/api/seasons/current/leaderboard?page` | Firebase ID token | Leaderboard sezonu (top 100 + pozycja usera) |
| GET | `/api/seasons/history` | Firebase ID token | Lista minionych sezonów + wyniki usera |

```typescript
// packages/types/src/season.ts
import { z } from 'zod'

export const SeasonResponseSchema = z.object({
  id:             z.string().uuid(),
  name:           z.string(),
  theme:          z.string(),
  start_at:       z.string().datetime(),
  end_at:         z.string().datetime(),
  quest_flavor:   z.string(),              // np. "Zima w Neon & Mana"
  seasonal_tags:  z.array(z.string()),
  user_pass: z.object({
    tier:               z.enum(['free', 'paid']),
    quests_completed:   z.number().int(),
    rewards_claimed:    z.array(z.string()),
    available_to_claim: z.array(z.string()),
  }),
})

export const ClaimRewardResponseSchema = z.object({
  reward_key:      z.string(),
  reward_type:     z.enum(['title', 'frame', 'prestige_badge', 'hero_tokens']),
  value:           z.union([z.string(), z.number()]),
  claimed_at:      z.string().datetime(),
})
```

---

## Weekly Challenges (`/api/challenges`) — §11.8

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/challenges/weekly` | Firebase ID token | Aktualne 3 wyzwania tygodnia + progress usera |
| GET | `/api/challenges/weekly/{id}` | Firebase ID token | Szczegóły konkretnego wyzwania + leaderboard |
| POST | `/api/challenges/weekly/{id}/claim` | Firebase ID token | Odbierz nagrody po ukończeniu wyzwania |

```typescript
// packages/types/src/challenge.ts
import { z } from 'zod'

export const WeeklyChallengeSchema = z.object({
  id:             z.string().uuid(),
  title:          z.string(),
  description:    z.string(),
  difficulty:     z.enum(['easy', 'medium', 'hard']),
  target_count:   z.number().int(),
  quest_tags:     z.array(z.string()),    // np. ['outdoor', 'photo']
  reward_hero:    z.number(),
  reward_xp:      z.number().int(),
  week_start:     z.string().datetime(),  // poniedziałek 00:00 UTC
  week_end:       z.string().datetime(),
  user_progress: z.object({
    count:     z.number().int(),
    completed: z.boolean(),
    claimed:   z.boolean(),
  }),
})
```

---

## IAP — In-App Purchases (`/api/iap`) — §10 Monetization

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/iap/validate` | Firebase ID token | Zwaliduj receipt Apple/Google → zapisz zakup + przyznaj HERO/pass |
| GET | `/api/iap/purchases` | Firebase ID token | Historia zakupów IAP usera |

```typescript
// packages/types/src/iap.ts
import { z } from 'zod'

export const IAPValidateSchema = z.object({
  platform:    z.enum(['apple', 'google']),
  receipt:     z.string(),       // Apple: receipt-data (base64); Google: purchaseToken
  product_id:  z.string(),       // np. 'com.20hero.tokens.100'
})

export const IAPValidateResponseSchema = z.object({
  purchase_id:         z.string().uuid(),
  product_id:          z.string(),
  hero_tokens_granted: z.number().int(),
  season_pass_activated: z.boolean(),
  new_balance:         z.number(),
})

// Idempotentne: purchase_token UNIQUE — double-tap safe
// Backend weryfikuje receipt z Apple App Store Server API / Google Play Developer API
// przed zapisem do iap_purchases
```

---

## Admin — UFS Fraud Queue (`/api/admin/fraud`) — §34

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/admin/fraud/queue?page` | requireAdmin | Użytkownicy w statusie manual_review — lista |
| GET | `/api/admin/fraud/users/{uid}` | requireAdmin | Pełny UFS + historia sygnałów dla usera |
| POST | `/api/admin/fraud/users/{uid}/action` | requireAdmin | Akcja: warn / shadow_ban / ban / clear |

```typescript
// packages/types/src/admin.ts
import { z } from 'zod'

export const FraudActionSchema = z.object({
  action:  z.enum(['warn', 'shadow_ban', 'ban', 'clear']),
  reason:  z.string().max(500),
  admin_note: z.string().max(1000).optional(),
})

export const FraudUserSchema = z.object({
  user_id:       z.string().uuid(),
  username:      z.string(),
  fraud_score:   z.number().int(),
  current_tier:  z.enum(['allow', 'shadow_monitor', 'manual_review', 'shadow_ban', 'ban']),
  signals:       z.array(z.object({
    key:    z.string(),
    ts:     z.string().datetime(),
  })),
  is_shadow_banned: z.boolean(),
  last_updated:  z.string().datetime(),
})
```

---

## GDPR Deletion (`/api/users/me/deletion`) — §33

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/users/me/deletion` | Firebase ID token | Zgłoś żądanie usunięcia konta (30d grace period) |
| GET | `/api/users/me/deletion` | Firebase ID token | Status żądania usunięcia (pending/cancelled/completed) |
| DELETE | `/api/users/me/deletion` | Firebase ID token | Anuluj żądanie usunięcia (przed upływem 30d) |

```typescript
// packages/types/src/gdpr.ts
import { z } from 'zod'

export const DeletionStatusSchema = z.object({
  status:       z.enum(['pending', 'cancelled', 'completed']),
  requested_at: z.string().datetime().nullable(),
  scheduled_at: z.string().datetime().nullable(),  // requested_at + 30 dni
  reason:       z.string().nullable(),              // opcjonalnie podany przez usera
})
// Po 30d grace period: deletion.worker.ts anonimizuje dane (GDPR art. 17)
// Anonimizacja: users.firebase_uid=null, username='deleted_XXXX', email=null
// Proofs zostają (publiczne, zanonimizowane) — XP ledger zostaje dla integralności leaderboardów
```

---

