Player Service
Status
Active
Date
2026-04-28
Owners
- Platform Backend
Last Verified Commit
56362a7a
Runtime
API + dedicated player_worker (the outbox loop runs in the worker;
the API process runs with ENABLE_OUTBOX_POLLER: "false")
Purpose
player_service owns player authentication, registration helpers, profile data,
messages, and public/common player-side data that back the gateway player flows.
Primary Entry Points
/internal/players/*/internal/common/*
Main route groups:
- auth and registration helpers
- player profile and account info
- player messages
- common notices, banners, captcha, and site settings
Protection model:
/internal/players/*and/internal/common/*requireX-Internal-Service-Tokengatewayis the intended caller for player-web traffic
Dependencies
- PostgreSQL
- Redis
- JWT secret
- AES and password-salt config
- optional SMS provider config
Background Work
player_service runs its outbox poller in a dedicated player_worker.
That means:
- the API process runs with
ENABLE_OUTBOX_POLLER: "false"; the standaloneplayer_workerruns it with"true" - the API
/healthchecks DB and Redis;player_workerexposes its own heartbeat/health (WORKER_HEARTBEAT_FILE)
Owned Data
- player auth and profile state
- player registration semantics
- player-owned outbox rows
- message/common projections served to
gateway
Events
Emits:
- player-domain outbox events such as
PLAYER_REGISTERED
Consumes:
- no cross-service stream is currently documented as a required input
Health
- API
/healthchecks:- DB
- Redis
- outbox loop status
Key Env Vars
DATABASE_URLREDIS_URLSECRET_KEYPASSWORD_SALT— required in production; protects the password-hash pipeline. Empty in a prod-grade env crashes boot viaassert_aes_pii_keys_configured.AES_KEY— required in production; 32-byte AES-256 key forphone_num/bank_numPII columns. New writes use versioned AES-256-GCM with a random nonce (aesgcm:v1:prefix), and reads keep legacy AES-256-CBC compatibility for historical rows. Boot guard refuses to start if empty inRGB_ENV/APP_ENV/ENVIRONMENT∈ {staging,production,prod}. Without this guard the_encrypt_piihelpers historically wrote plaintext (T1-D-C3).AES_IV— required in production until the legacy CBC backfill is complete; 16-byte IV used only to decrypt historical AES-256-CBC rows. New writes do not reuse a static IV.MULTI_BRAND_ENFORCEMENT— required; one ofoff/observe/enforce. Production target isenforcepost-Phase-16. Drivesmulti_brand_enforcement_mode{service="player_service"}gauge.JWT_PRIVATE_KEY— required in production; RS256 private key. Held only byplayer_serviceandagent_service(not on any verifier-only service). Rotation procedure documented in the rollout runbook under "JWT RS256 key rotation".JWT_KID— currently-activekidstamped onto every freshly-minted JWT.RECOVERY_TOKEN_TTL_MINUTES— TTL for recovery short codes / reset tokens.RECOVERY_EXPOSE_TOKEN_FOR_TESTS— test-only escape hatch. Whenon, recovery routes log rawtel=<...>/code=<...>lines for integration-test inspection.assert_recovery_token_exposure_safe(Codex P1-#5) refuses to allow this when ANY ofRGB_ENV/APP_ENV/ENVIRONMENTresolves tostaging/production/prod. Production deploy templates MUST leave this unset.SMS_URLSMS_PLATFORMCOOLSMS_API_KEYCOOLSMS_API_SECRETCOOLSMS_FROMPER_CALLER_TOKEN_REQUIRED—onis the Phase 16 target; activates the legacy-token hard-reject (T4-D-I2) for inbound/internal/players/*calls.INTERNAL_SERVICE_TOKEN_GATEWAY— required in production; per-caller token accepted on inbound calls fromgateway.INTERNAL_SERVICE_TOKEN— legacy single-shared-token; deprecated. Phase 16 release gate requires the bare variant to be absent.
Boot guard: AES PII keys
app/main.py calls rgb_contracts.infra.aes_guard.assert_aes_pii_keys_configured() before the FastAPI app starts. In a production-grade runtime env (per is_production_runtime_env — any of RGB_ENV/APP_ENV/ENVIRONMENT set to staging/production/prod), an empty or whitespace-only AES_KEY, AES_IV, or PASSWORD_SALT raises AesPiiKeysMisconfigured and the service refuses to start. The same guard validates AES_KEY is 32 bytes and AES_IV is 16 bytes. Outside production the guard is a no-op so dev fixtures with empty keys keep working; the runtime helpers still emit pii_aes_unconfigured_total{service,op} + WARN logs in that branch so an accidental misconfiguration is visible on dashboards. Versioned GCM ciphertext that fails AEAD authentication raises in production regardless of AES_STRICT_DECRYPT; the AES_STRICT_DECRYPT flag now applies only to ambiguous legacy base64-shaped CBC/plaintext compatibility reads.
Multi-Brand Constraints
Per ADR-009:
- every player aggregate row carries
brand_id; uniqueness onplayerbecomes(brand_id, account)so the same account string may register independently in two brands - registration resolves
brand_idfrom the request domain and rejects any client-supplied brand override agent_settingis keyed by(agent_id, brand_id)so a single agent may carry different registration modes per brandagent_domainkeeps its surrogateguid BIGINTPK; a compositeUNIQUE(agent_id, brand_id, domain)is added on top of the existingUNIQUE(domain)global constraint, so adomainvalue cannot appear twice across any(agent_id, brand_id)combination -- a domain belongs to exactly one brand and one agent- the
domain:agent:{host}anddomain:level:{host}Redis maps now resolve to values carryingbrand_id - internal player routes require
X-Brand-Id; mismatch behavior is gated byMULTI_BRAND_ENFORCEMENT(the same flag asgateway):observelogs + counts viabrand_resolution_failed_total{reason="player_brand_mismatch",service="player_service"}and proceeds using the request brand;enforcerejects with the documented error envelope - player-owned outbox events include
brand_idin payload - recovery flow brand precedence (per spec
### Recovery flow brand precedence): recovery tokens issued after Phase 5 carrybrand_idas a signed claim. Token-embeddedbrand_idwins over request domain; if the token lacksbrand_id(issued before Phase 5), fall back to request domain; if the resolved brand does not match the underlying player'sbrand_id, the request is rejected hard regardless ofMULTI_BRAND_ENFORCEMENTmode (recovery is too sensitive for observe-mode tolerance). Recovery responses for unknown accounts produce the same response shape as known accounts to prevent existence-oracle leakage; the same email or phone registered in two brands recovers each brand independently with brand-scoped tokens - recovery flows write
recovery_audit(alembic 0033) per ADR-009 audit guarantees: every observable outcome (REQUEST / VERIFY / RESET success or rejection, plus RATE_LIMITED / TOKEN_INVALID / BRAND_MISMATCH typed rejections) inserts one append-only row carryingbrand_id, SHA-256 hash of contact, SHA-256 hash of source IP, and a per-action JSONB metadata blob (NEVER the contact, token, or password). The audit row commits in the same DB transaction as the side effect being audited (e.g. password UPDATE on /reset) so an audit-side failure rolls back the recovery write - recovery code & raw contact are NEVER logged in production (Codex P1-#6): the SMS-success / SMS-not-sent / SMS-exception / email-only paths log
contact_hash=<sha256_first_8_hex>only, and emitrecovery_sms_delivery_failed_total{service="player_service"}orrecovery_email_unprovisioned_total{service="player_service"}for operator visibility. The rawtel=<...> code=<...>lines remain ONLY behindRECOVERY_EXPOSE_TOKEN_FOR_TESTS=on, an integration-test escape hatch thatassert_recovery_token_exposure_saferefuses to allow when ANY ofRGB_ENV/APP_ENV/ENVIRONMENTresolves tostaging/production/prod(Codex P1-#5: previously the guard checkedENVIRONMENTonly, soAPP_ENV=productioncould silently bypass it) - the same no-PII-in-logs invariant covers the legacy registration SMS path
/internal/players/register/sendSMS(Codex T1-D-C4). Previously the unprovisioned-provider fallback wrotelogger.info(f"SMS code for {tel}: {sms_code}")and the hard-failure path wrotelogger.error(f"SMS send failed for {tel}"), both leaking the registration OTP + raw phone into centralised logs. Redacted tocontact_hash=<sha256_first_8_hex>; the unprovisioned + hard-failure branches both incrementrecovery_sms_delivery_failed_total{service="player_service"}; raw values still log behindRECOVERY_EXPOSE_TOKEN_FOR_TESTS=on
Tests
cd servers_v2/player_service && uv run pytest- key suites:
tests/test_auth_contracts.pytests/test_profile_contracts.pytests/test_message_contracts.pytests/test_outbox_poller.pytests/test_internal_auth.py