Přeskočit na obsah

Gamification Consumer

Gamification Consumer je jednoúčelový RabbitMQ consumer (F4.GAM.02). Odebírá zprávy z topic exchange gamification.events a je jediným writerem Strapi content-types user-rank/gamification-event (F4.GAM.01). Nikdy nedůvěřuje XP hodnotě z příchozí zprávy — XP se vždy dopočítá server-side z src/xpRules.ts.

Pro pohled na hodnosti, XP a odznaky z pohledu koncového uživatele viz Hodnosti, XP a odměny.

  • Port: 3800 (jen /health — tato služba nemá jiný HTTP povrch)
  • Tech: TypeScript 5 (strict: true), Node.js ≥20, amqplib ^0.10, Axios, logger Pino (kořenový CLAUDE.md pinuje Pino pro nové služby)
  • RabbitMQ: odebírá gamification.events (topic), routing keys xp.granted / badge.check / streak.tick / easter-egg.roll
  • Single-writer invariant: žádný jiný kód nesmí zapisovat do user-rank/gamification-event — obě content-types mají route/policy úroveň zamčenou jen na X-Service-Secret (sencai.space’s src/policies/is-self-or-sencai-admin-user-rank.ts)
producenti (cloud-instance lifecycle, runbook-execution, opsloop-consumer, …)
→ gamification.events (topic exchange, durable)
routing keys: xp.granted | badge.check | streak.tick | easter-egg.roll
→ gamification-consumer-queue (DLQ: gamification-consumer-queue.dlq, bez TTL)
→ gamification-consumer (src/index.ts → src/messageHandler.ts)
→ sencai.space (X-Service-Secret): user-ranks, gamification-events,
badge-definitions, validate-user
→ re-publish: rank.up | badge.awarded | easter-egg.triggered
(na STEJNÝ exchange — nikdy je sám nekonzumuje, aby nevznikla smyčka)
→ audit.events (audit.write) pro rank-up/badge-award/easter-egg mutace

user-rank nemá relaci organisation — hodnost je přenositelná mezi organizacemi (design F4.GAM.01). Tento consumer nikdy nescopuje podle org; organisation_id v příchozí zprávě je jen informativní kontext pro gamification-event.organisation.

Bezpečnostní model — co consumer nesmí předpokládat

Section titled “Bezpečnostní model — co consumer nesmí předpokládat”

gamification.events je topic exchange bez kryptografického ověření původce zprávy (stejný charakter jako platform.events fanout u event-store-consumer). Z toho plynou designová rozhodnutí, která je třeba respektovat při jakékoli budoucí úpravě:

  1. XP se nikdy nečte z payloadu. src/messageHandler.ts’s stripForgedXp() zaloguje warning a zahodí xp_granted, pokud přijde — skutečná hodnota vždy pochází z XP_MAP v src/xpRules.ts (computeXpForAction()). Neodstraňovat, ani “pro pohodlí testování” nezavádět fallback na payload hodnotu.
  2. user_id se ověřuje před každým zápisem. strapiClient.userExists() volá GET /api/user-ranks/validate-user/:id (service-only endpoint přidaný spolu s touto službou). Neexistující/podvržený user_idRejectMessageError → zpráva jde do DLQ, nikdy se nezapíše.
  3. manual_admin_grant dává 0 XP automaticky. Tento consumer nemá žádný způsob, jak ověřit, že volající RabbitMQ zprávy byl skutečně sencai-admin — honorovat libovolnou XP hodnotu pro tento action_type by znovu otevřelo přesně tu injection díru, kterou bod 1 uzavírá. Manuální grant se řeší přímo v Strapi admin UI editací user-rank záznamu.
  4. Easter egg — stochastický, s tvrdým per-user ročním capem. src/easterEgg.ts’s rollEasterEgg() — cap (EASTER_EGG_MAX_PER_USER_PER_YEAR) se vynucuje bezpodmínečně, nezávisle na tom, jak je vyladěná pravděpodobnost.
  5. Idempotency = SHA-256(user_id:action_type:source_event_id). Duplicitní doručení stejné zprávy → Strapi vrátí 400 na unique idempotency_key → consumer to bere jako no-op (ne chybu, ne druhý XP grant).

action_type whitelist — musí odpovídat Strapi enumu

Section titled “action_type whitelist — musí odpovídat Strapi enumu”

src/xpRules.ts’s ACTION_TYPES musí být v lockstepu s sencai.space’s content-type gamification-event enumem action_type. Přidání nové hodnoty vyžaduje změnu na obou místech — jinak zprávy s novým typem tiše skončí zamítnuté (DLQ), i když by validně měly projít.

src/automationClassifier.ts klasifikuje akci xp.granted jako automatizovanou vs. manuální a zapisuje verdikt do gamification-event.metadata.is_automated při zápisu (handleXpGranted v messageHandler.ts). sencai.space’s GET /api/organisations/:id/automation-score (F4.GAM.07) toto pole pouze agreguje — klasifikaci samu nikdy neodvozuje.

Jen tři hodnoty action_type jsou pro tuto metriku relevantní (isAutomationScoreEligible()) — přesně ty, které F4.GAM.03 producenti opatřují polem organisation:

  • provisioning_completed — čte metadata.source (mirror cloud-instance.source): 'sencai' = automatizováno (přes vlastní cloud-connector automatizaci, ADR-001), 'terraform' = importováno z existujícího tfstate (F3.IMPORT.01) — není to platformní automatizace. Chybějící hodnota defaultuje na automatizováno (běžný případ 'sencai').
  • runbook_success — čte metadata.triggered_by (mirror runbook-execution.triggered_by): 'alert' = automatizováno, cokoliv jiného/chybějící = manuální.
  • incident_resolved — vždy manuální. Ověřeno přímo proti opsloop-consumer/src/services/*.ts — žádný automatizovaný flow nikdy sám nenastaví incident na resolved, jen spustí runbook (zachyceno zvlášť jako runbook_success) nebo otevře change-request.

Ostatní action_type (bookkeeping — login_streak, badge_awarded, easter_egg_triggered, automation_score_snapshot, manual_admin_grant) isAutomationScoreEligible() vrací falseclassifyAutomation() se pro ně vůbec nevolá, jejich metadata zůstává nedotčené.

Všechny tři akce, které tato služba potřebuje (gamification.rank.up, gamification.badge.awarded, gamification.easter_egg.triggered), už jsou v kanonickém AuditAction union (packages/audit/src/types.ts), doplnila je konsolidační vlna Fáze 4. Tato služba přesto používá vlastní malý, volně typovaný (action: string) publisher v src/audit.ts místo vendoringu @sencai/audit — stejný vzor, jaký si pro identickou situaci zvolil forum-connector. Vendoring je teď odemčený (blokující důvod pominul), jen ještě neproveden.

Terminál
PORT=3800
RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672
STRAPI_URL=http://backend:1337
SERVICE_SECRET= # sdílený s sencai.space's bare SERVICE_SECRET, ne vlastní GAMIFICATION_SERVICE_SECRET
EASTER_EGG_ANNUAL_QUALIFYING_ACTIONS=50000 # odhad kvalifikujících akcí/rok pro tuning pravděpodobnosti
EASTER_EGG_TARGET_TRIGGERS_PER_YEAR=12 # cílový počet triggerů/rok, platform-wide
EASTER_EGG_MAX_PER_USER_PER_YEAR=3 # tvrdý per-user cap

Q: GET /api/badge-definitions vrací 403 pro service-secret volání

A: Opraveno v sencai.space (F4.GAM.02) — global::hybrid-auth kontrolovalo jen ctx.state.user (KC JWT nebo Strapi API token), nikdy X-Service-Secret. find/findOne teď mají vlastní isAuthenticatedOrService() check v controlleru místo policy.

Q: Zprávy končící v DLQ

A: Neznámý action_type, neexistující user_id, malformed JSON, nebo neočekávaná Strapi/network chyba. Zkontrolovat gamification-consumer-queue.dlq (bez TTL, manuální recovery) přes RabbitMQ management UI (port 15672/15673 dle profilu).

Q: easter-egg.roll/streak.tick/badge.check nemají v produkci žádného producenta

A: F4.GAM.03 (hotovo 2026-07-05) navázalo producenty jen na xp.granted (provisioning/runbook/incident) — pro tyto tři routing keys producent nikdy nebyl v scope. Testovat manuálně přes rabbitmqadmin publish nebo RabbitMQ management UI.

Q: Duplicitní XP i přes idempotency guard

A: source_event_id chybí v payloadu nebo se liší mezi logicky stejnými zprávami. Producent musí posílat stabilní source_event_id — consumer to nemůže odhalit/opravit na své straně (stejný trade-off jako event-store-consumer’s timestamp-based fallback).

  • sencai.space — cílové úložiště (content-types user-rank, gamification-event, badge-definition, F4.GAM.01)
  • F4.GAM.03 producenti — skuteční producenti XP eventů (cloud-instance lifecycle, runbook-execution, opsloop-consumer), navázaní na xp.granted
  • F4.GAM.04/.05 — frontend rank widget + notifikace, konzumuje rank.up/badge.awarded z gamification.events
  • Architektura — širší service map