Agent Service
Status
Active
Date
2026-04-28
Owners
- Platform Backend
Last Verified Commit
56362a7a
Runtime
API + standalone worker
Purpose
agent_service owns the agent-facing portal APIs and the legacy compatibility
aliases still required by older agent frontends.
Primary Entry Points
agent_service has an empty api_prefix
(servers_v2/agent_service/app/core/settings/app.py), so routes are
mounted at the top level. There is no /api/v1/ namespace.
New-shape routes (mounted via app/api/routes/api.py):
/agent/*— auth (/agent/login,/agent/forget, …)/agent/home/*— dashboard/agent/withdraw/*— withdrawals (/agent/withdraw/init,/agent/withdraw/add)/agent/player/*— downstream-player views/agent/sub/*— sub-agent management/agent/bank/*— bank cards/agent/messages/*— messages/agent/coupon/*— coupons (/agent/coupon/grant,/agent/coupon/recycle)/agent/provider/*— game providers/agent/common/*— shared endpoints/agent/recovery/*— SMS-driven password reset
Legacy aliases (mounted from app/api/routes/legacy_routes.py,
include_in_schema=False) preserve top-level paths the older agent
frontend already calls:
/user/login,/user/refresh_token,/user/forget,/user/top_info,/user/info,/user/edit/password,/user/edit/bank/*,/user/edit/phone/*/home/*/withdraw/*/player/*/provider/*(list / percentage / sub/percentage / income)/messages/*(list /{id}/ read / unread/count)/bank/list,/common/agent_min_withdrawal_limit/coupon/*(grant / recycle / logs / recycle/logs)/agent/*legacy reporting endpoints (/agent/agentStatistic,/agent/agentStatisticDaily,/agent/statisticDaily) — separate from the new/agent/sub/*namespace above, distinguished by suffix
For the authoritative current list, regenerate the OpenAPI snapshot
with python3 tools/openapi/export_openapi.py and inspect
docs/reference/openapi/agent_service.json.
Dependencies
- PostgreSQL
- Redis
- JWT secret
- AES and password-salt config
Reserved configuration
ADMIN_SERVER_HOST— Reserved (no current runtime calls; placeholder for future cross-service hooks).agent_servicedoes not issue HTTP requests toadmin_servicetoday; seeservers_v2/agent_service/CLAUDE.md.
Background Work
agent_worker publishes agent outbox rows to Redis Streams.
Owned Data
- agent portal operational flows
- agent-owned outbox rows
- agent compatibility semantics for legacy clients
Events
Emits:
- agent-domain outbox events published by
agent_worker
Consumes:
- no required upstream stream consumer is currently documented
Health
- API
/healthchecks DB and Redis agent_workeruses heartbeat-backed supervision for outbox publishing
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_num/card_numberPII columns onagentandagent_withdraw. 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 with an empty value in a prod-grade env. T1-D-C3: previously the_encrypt_piifallback wrote plaintext toagent.phone_num/agent.bank_numwhen this was unset.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="agent_service"}gauge and theagent_brandallow-list reject decision inAgentBrandResolutionMiddleware.JWT_PRIVATE_KEY— required in production; RS256 private key. Held only byplayer_serviceandagent_service.JWT_KID— currently-activekid.RECOVERY_TOKEN_TTL_MINUTES— TTL for agent recovery short codes / reset tokens.RECOVERY_EXPOSE_TOKEN_FOR_TESTS— test-only escape hatch. Same boot-guard semantics as onplayer_service(Codex P1-#5).— not used byBRAND_SIGNING_KEYagent_service.agent_servicehas no outboundwallet_clientand signs no wallet writes, so it does not load a brand-signing key. The 6 signing callers aregateway,admin_service,game_service,promotion_service,recon_service,rolling_service(seedocs/runbooks/multi-brand/multi-brand-isolation-rollout.md"Stage A" caller list and theBRAND_SIGNING_KEYrotation section).INTERNAL_SERVICE_TOKEN_AGENT— shipped in the shared&internal-service-envcompose anchor and therefore visible to every consumer (wallet/rolling/promotion/etc.) as the inbound credential they would accept from a future caller identifying itself asX-Caller-Service: agent.agent_serviceitself does NOT emit this header today (no outbound/internal/*HTTP client), so the env var exists as future-proofing rather than as an active runtime requirement.PER_CALLER_TOKEN_REQUIRED—onis the Phase 16 target; activates the legacy-token hard-reject (T4-D-I2) on inbound internal calls.SMS_URLENABLE_OUTBOX_POLLERINTERNAL_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. Same shape as player_service — production-grade env with any of AES_KEY/AES_IV/PASSWORD_SALT empty raises AesPiiKeysMisconfigured, and the guard validates AES_KEY is 32 bytes plus AES_IV is 16 bytes. Non-prod runs are a no-op; runtime fallbacks emit pii_aes_unconfigured_total{service="agent_service",op} + WARN logs. Versioned GCM ciphertext that fails AEAD authentication raises in production regardless of AES_STRICT_DECRYPT; the flag now applies only to ambiguous legacy base64-shaped CBC/plaintext compatibility reads.
Multi-Brand Constraints
Per ADR-009:
- the
agentaggregate is brand-global; agent rows do not carrybrand_id agent_brandis a join aggregate of(agent_id, brand_id, status)and is the only place where brand membership for an agent is recordedadmin_servicewritesagent_brand(which brands an agent may serve);agent_servicereads it and enforces it on every agent-facing routeagent_settingis keyed per(agent_id, brand_id)so a single agent can hold different registration modes per brandagent_domainkeeps its surrogateguid BIGINTPK; a compositeUNIQUE(agent_id, brand_id, domain)is added on top of the existingUNIQUE(domain); a domain cannot be reused across brands or across agents- the agent frontend resolves brand from its request domain (per
ADR-009); a request whose resolved brand is not in the authenticated agent'sagent_brandallow list is checked perMULTI_BRAND_ENFORCEMENT(observelogs + counts;enforcerejects) agent_servicestrips any inboundX-Brand-Idheader on its agent-frontend edge before injecting the resolved value (mirrors the gateway strip; prevents external header spoofing)- agent-domain outbox events include
brand_idin payload (envelope field onDomainEvent,schema_version=2) agent_brandreads cached per process with 60s TTL; invalidated byAGENT_BRAND_CHANGEDRedis pub/sub channel fromadmin_service. Active JWT survivesagent_brandrevocation; the next request after revocation is rejected by allow-list check- internal calls to downstream services use per-caller token
INTERNAL_SERVICE_TOKEN_AGENT; brand-scoped wallet write commands carryX-Brand-Signatureper ADR-009 - agent recovery flow follows the same brand-precedence contract as player recovery (per spec
### Recovery flow brand precedence): token-embeddedbrand_idwins over request domain, fall back to domain when token lacks the claim, hard-reject when the resolved brand is not enabled inagent_brandregardless ofMULTI_BRAND_ENFORCEMENTmode, and unknown-account responses are shape-identical to known-account responses - agent recovery flows write
recovery_audit(alembic 0033) per ADR-009 audit guarantees withsubject_type='agent': 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). RESET writes the audit row and the password UPDATE in one transaction; an audit failure rolls back the password change - agent 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="agent_service"}orrecovery_email_unprovisioned_total{service="agent_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) - legacy
auth.pyagent flows (/agent/forget,/agent/edit/phone,/agent/edit/bank, and the matchingsend-smsendpoints) follow the same no-PII-in-logs invariant (Codex T1-D-C4). Previously these wrotelogger.warning(f"... SMS code for agent {agent_id}: {code}")whenever the SMS provider was unprovisioned (very common in staging / dev), exposing OTPs that gate/edit/phoneand/edit/bank-- log read = full agent takeover including changing payout banks. Redacted tocontact_hash=<sha256_first_8_hex>only and surfaced viarecovery_sms_delivery_failed_total{service="agent_service"}. Audit coverage:/agent/forgetis brand-scoped through the resolved request brand plusagent_brand; every post-resolutionrecovery_auditrow carries that resolvedbrand_id(only the missing-brand-context branch usesbrand_id=0because no tenant was resolved)./agent/edit/phone,/agent/edit/bankand theirsend-smssiblings write anadmin_auditrow withoperator_id='agent:<guid>',resource='agent', action one ofAGENT_EDIT_PHONE/AGENT_EDIT_BANK/AGENT_EDIT_PHONE_SEND_SMS/AGENT_EDIT_BANK_SEND_SMS. The UPDATE-side audit rows commit in the SAME transaction as the agent-row UPDATE so a payout-account swap cannot proceed unaudited. All SMS OTPs in the active agent auth and legacy compatibility surfaces are generated with CSPRNG (secrets.randbelow) rather thanrandom.randint; new-value payloads carrycontact_hash/bank_idonly -- never the raw phone, raw card, or OTP. Test escape hatch (RECOVERY_EXPOSE_TOKEN_FOR_TESTS=on) is identical to the recovery-path semantics
Tests
cd servers_v2/agent_service && uv run pytest- key suites:
tests/test_agent_integration.pytests/test_outbox_poller.pytests/test_events_health.py