Stateless analytical Engine for fantasy baseball: each request carries the current draft/league payload and returns valuations (and related analytics). League draft state is not persisted as a server session—callers send the full context on every call.
Developer portal (public/, same origin as the API in dev): email + password sign-in (PortalUser) with an HTTP-only session cookie, used to manage developer accounts and mint API keys tied to DeveloperAccount (/api/auth/*, /api/account/*). That is separate from product UI in AmethystDraft, which must attach x-api-key only on its server when calling this Engine—see docs/draft-kit-license-runbook.md and docs/amethystdraft-engine-integration.md.
| Layer | Technology |
|---|---|
| Runtime | Node.js 20 + Express 5 |
| Language | TypeScript |
| Database | MongoDB (player data, API keys) |
| Cache | Redis (API key cache, catalog batch-values — not used for POST /valuation/calculate) |
| Deploy | AWS App Runner via GitHub Actions |
MONGO_URI: MongoDB connection string.REDIS_URL: Redis connection string (optional, defaultredis://localhost:6379).API_KEY_PEPPER: Server-side secret used to hash stored API keys. Must be set in production to protect key material.
| Artifact | Role |
|---|---|
| docs/RUBRIC_SUBMISSION_INDEX.md | Grader / reviewer index — links rubric matrices, runbooks, OpenAPI, fixtures, Draft↔Engine integration. |
| ENGINE_AGENT_BRIEF.md | Handoff for agents / new contributors — Draft ↔ Engine HTTP contract, headers, flat valuation body, fixtures, checklist. |
| docs/valuation-dollar-ladder.md | Product mental model — ladder for baseline → adjusted → recommended_bid → team_adjusted → edge, UI primaries, honest positioning vs single-number tools. |
| docs/valuation-response-field-audit.md | Response-field semantics & QA — what each valuation field means, known decomposition gaps, pnpm run audit:valuation-response. |
| docs/valuation-inflation-semantics.md | Inflation contract — inflation_model (global_v1 vs surplus_slots_v1), player_ids as output filter, inflation_raw / cap-floor, aggregates. |
| docs/valuation-module-map.md | Code ownership map — which valuation module owns inflation branch logic, recommended-bid heuristics, and team-adjusted economics. |
| openapi/openapi.yaml | Human-facing API spec — paths, headers, success/error shapes, budget rules, tracing. |
| schemas/valuation-request.v1.schema.json | Machine validation for the flat POST /valuation/calculate body (keep in sync with Draft). |
| schemas/valuation-request-v1.json | Nested { league, draft_state } alternate (fixtures / legacy). |
The Engine accepts the flat body Draft builds for server-to-server calls:
drafted_players: auction picks only (keepers on rosters belong inpre_draft_rosters, not double-listed here, unless you intentionally mirror Draft’s model).pre_draft_rosters: optional map (team_id→ array of rows) or array of{ team_id, players }(same as Draft checkpoints).schema_version/schemaVersion: both optional; if both are sent,schemaVersion(camelCase) wins.inflation_model: optional, defaults toreplacement_slots_v2(slot/position-aware surplus inflation — the official auction-dollar path). Setglobal_v1only when you need legacy full-list rescale.surplus_slots_v1is a lighter single-cutoff surplus model. Remaining-slot math for surplus models usesroster_slots,num_teams,drafted_players, keepers/minors/taxi, and eligibility.player_ids: optional subset of undrafted MLB ids to return invaluations[]; does not shrink the inflation basis (see valuation-inflation-semantics.md).- Responses:
engine_contract_version: "1"on success;X-Request-Idechoed when sent. - Errors: 400 = request validation only, body
{ errors: [{ field, message }] }. 422 = output sanity failure, sameerrorsshape, no prices.
Budget (integration-tested):
budget_by_team_id |
Remaining league dollars |
|---|---|
Absent or {} |
total_budget × num_teams − Σ drafted_players[].paid (missing paid → 0). |
| Present, non-empty | Sum of map values; paid on drafted rows is ignored for that request. |
Fixture paths: CI uses test-fixtures/player-api/checkpoints/. If AmethystDraft is a sibling repo (…/dev/AmethystDraft), tests prefer apps/api/test-fixtures/player-api/checkpoints/pre_draft.json when present so nested checkpoints run too (including league.roster_slots as a slot → count map, normalized server-side to [{ position, count }, …]).
Draft replay analysis (per-pick, full board): pnpm replay-draft-json-analysis replays pre_draft.json + after_pick_130.json (defaults under test-fixtures/player-api/checkpoints/), rewinding drafted_players before each pick and scoring vs fixture paid. pnpm replay-draft-json-analysis:report / :mongo-report write JSON under tmp/ (gitignored); use --out path for a different file. Mongo mode merges fixture ids via src/lib/replayMongoFixtureMerge.ts (MONGO_URI from .env).
pre_draft_rosters (v1 behavior): Accepted as map or array (see above); rows do not remove players from the undrafted pool until those player_ids appear in drafted_players / draft_state. minors / taxi do not affect spend in v1.
Draft economics (400): Each player_id at most once in auction rows; paid ≥ 0; without budget_by_team_id, Σ paid ≤ total_budget × num_teams.
Operational hardening: Request bodies are limited to 1 MB. POST /valuation/calculate is not Redis-cached (avoids stale prices after player sync). Rate limits (per x-api-key, or IP if the key is missing) are tier-aware: free tier has the lowest ceilings, standard uses the baseline env max, premium the highest. Env: RATE_LIMIT_ENABLED, RATE_LIMIT_VALUATION_MAX / RATE_LIMIT_VALUATION_MAX_FREE / RATE_LIMIT_VALUATION_MAX_PREMIUM, RATE_LIMIT_VALUATION_WINDOW_MS, RATE_LIMIT_CATALOG_MAX / RATE_LIMIT_CATALOG_MAX_FREE / RATE_LIMIT_CATALOG_MAX_PREMIUM, RATE_LIMIT_CATALOG_WINDOW_MS. Licensed routes also require an API key scope matching the mount (valuation, catalog, scarcity, simulation, signals). Optional IP gate: set ENGINE_IP_ALLOWLIST to a comma-separated list of allowed client IPs; then set TRUST_PROXY=1 (or rely on allowlist enabling trust proxy in code) so req.ip matches the caller behind App Runner / ALB. Limits are off under Vitest unless RATE_LIMIT_ENABLED=1.
Logging: JSON logs via Pino; set LOG_LEVEL (debug, info, warn, error, silent). Defaults to silent in tests unless LOG_LEVEL is set.
Health: GET /api/health — liveness. GET /api/health/ready — MongoDB + optional Redis ping; 503 if Mongo is not connected. Set HEALTHCHECK_REDIS=0 to skip the Redis check (status stays ready if Mongo is up).
The course diagram expects: ingest league + player state → choose Rotisserie vs points scoring → per-player projections/surplus → auction dollars → validate reasonableness (retry loop in your design doc).
This service today
| Diagram step | Where it lives now | Your “own model” (course) |
|---|---|---|
| League + draft state | Request body → parseValuationRequest / NormalizedValuationInput |
Draft/fixtures build this; not outsourced. |
| Eligible player bios & history | Mongo players (stats = last completed season, projection = 5:3:2 three-year blend from sync, value, tier, adp, optional injurySeverity, …) |
Populated by pnpm sync-players + optional ops fields — not a third-party valuation API. |
| Filter drafted / scope | calculateInflation + league_scope |
Same. |
| Scoring branch (Roto vs points) | resolveScoringMode in src/services/valuationWorkflow.ts — logged as scoring_mode; v1 math still uses stored value for both |
Next increment: compute or rescale value per scoring_format + scoring_categories before inflation. |
| Surplus / scarcity / market | Baseline value + inflation vs remaining budget + Steal/Reach vs ADP |
Extend inflationEngine / scarcity as you add SABR-style signals. |
| Auction dollars | auction_value (canonical; equals adjusted_value) on each row |
Same inflation math; use auction_value for external evaluation. |
| Validate prices | src/lib/valuationQuality.ts — finite numbers, non-negative totals, valid indicators; failures → HTTP 422 with { errors: [...] } and no valuations payload (fail closed) |
Add caps / recompute loop inside executeValuationWorkflow when you have tunables. |
Orchestration: executeValuationWorkflow is the single entry used by POST /valuation/calculate so the pipeline stays explicit and testable.
All Brain endpoints require an x-api-key header. See Authentication.
Returns every undrafted player with auction_value (canonical official dollar valuation, equal to adjusted_value) and auxiliary draft UX fields (recommended_bid, team_adjusted_value, edge), plus a Steal / Reach / Fair Value indicator.
Baseline values are scoring-aware (5x5 / 6x6 / points) and include bounded scarcity/replacement adjustments before inflation.
The AmethystDraft API forwards a single JSON object (no extra wrapper): either the merged body from buildEngineValuationCalculateBodyFromFixture or live league/roster state. Canonical JSON Schema for the flat fixture shape: schemas/valuation-request.v1.schema.json (align with Draft apps/api/schemas/valuation-request.v1.schema.json when present). Nested { league, draft_state } is still accepted for older tests; see schemas/valuation-request-v1.json.
Flat body (Draft upstream):
{
"schema_version": "1.0.0",
"checkpoint": "pre_draft",
"roster_slots": [{ "position": "OF", "count": 3 }],
"scoring_categories": [{ "name": "HR", "type": "batting" }],
"total_budget": 260,
"num_teams": 12,
"league_scope": "Mixed",
"scoring_format": "5x5",
"drafted_players": [],
"deterministic": true,
"seed": 42,
"minors": [],
"taxi": []
}(schemaVersion camelCase is accepted as an alias for schema_version.)
drafted_players / draft_state rows: player_id is the MLB Stats API person id as a string (same as Draft externalPlayerId and Mongo mlbId). Use position as the primary slot; optional positions[] carries full eligibility. At least one of position, positions[], or roster_slot must yield a primary position.
Optional fields:
| Field | Purpose |
|---|---|
schema_version / schemaVersion |
Contract version; majors 0 and 1 supported |
checkpoint |
e.g. pre_draft, after_pick_10, … (logged, no PII) |
league_id |
Optional; echoed in context_v2.scope.league_id. Nested bodies may use league.id instead; top-level league_id wins |
budget_by_team_id |
Per-team remaining $; when non-empty, league remaining = sum(map) and paid ignored — see API contract |
scoring_format |
5x5 | 6x6 | points (validated; v1 inflation may ignore) |
hitter_budget_pct |
Forward-compatible; v1 baseline may ignore |
pos_eligibility_threshold |
Reserved / informational — does not drive eligibility in v1 (no games-by-position in Mongo) |
position_overrides |
Optional { player_id, positions[] }[] from Draftroom min-games eligibility — replaces Mongo position / positions for baseline scarcity, replacement_slots_v2, team-adjusted value, and scarcity |
minors, taxi |
{ team_id, players[] }[] on flat bodies; nested fixtures may still use a legacy record map |
deterministic |
Fixed calculated_at and stable sorts |
seed |
With deterministic, seeded tie-breaks so CI can pin ordering |
player_ids |
Limit valuations[] to these undrafted ids; inflation pool stays full undrafted set |
Validation errors return 400 with { "errors": [{ "field": "drafted_players.0.position", "message": "..." }] } (JSON path style, no stack traces). Version failures use field: "schema_version".
When quality checks fail, workflow performs bounded recompute passes (inflation cap/floor clamps) before fail-closed 422.
Auth: Draft calls this route with x-api-key (AMETHYST_API_KEY). The Draft-only PLAYER_API_TEST_KEY is not used here.
Single-player valuation convenience route. Accepts the same league and draft context as /valuation/calculate, plus required player_id, and returns:
- full valuation metadata (
inflation_factor,players_remaining, etc.) valuationsas a single-item arrayplayeras the single valued row
If player_id is missing, returns 400 with { errors: [{ field: "player_id", message: "player_id is required" }] }.
If player_id is not in the current valuation pool, returns 404 with { errors: [{ field: "player_id", message: "Player not found in valuation pool" }] }.
valuations[] rows include optional explainability metadata:
baseline_components(scoring_format,projection_component,scarcity_component)scarcity_adjustment— always0(roster scarcity is already inbaseline_value)inflation_adjustment—adjusted_value - baseline_value(full list→auction delta). For defaultreplacement_slots_v2, this is the surplus allocation move vs list strength (min_bid + surplus allocation factor × surplus_basis), not a separate “penalty” line item.
Deploy label valuation_model_version: env VALUATION_MODEL_VERSION, or Docker BUILD_GIT_SHA (CI sets this), else package.json name@version. Set VALUATION_AGGREGATE_LOG=1 for structured per-request pool/inflation logs.
First-class baseline read: same player_id rules as valuation (string MLB id / mlbId). Returns engine_contract_version plus players[]. Responses are cached 120s per request body (Redis when configured). Merge with MLB bios in Draft. league_scope filters the list. pos_eligibility_threshold is informational only in v1 (does not change catalog math); use position_overrides on valuation requests for eligibility-aware pricing.
{
"player_ids": ["660271", "592450"],
"league_scope": "Mixed"
}Returns positional scarcity scores and Monopoly Detection warnings when one team controls a disproportionate share of a scoring category.
{
"drafted_players": [...],
"scoring_categories": [{ "name": "SV", "type": "pitching" }],
"position": "SS",
"num_teams": 12,
"league_scope": "Mixed"
}Predicts the most likely next pick for each team in the draft order using ADP + roster-need heuristics. Powers the AI Practice Draft environment.
{
"pick_order": ["team_1", "team_2"],
"teams": [{ "team_id": "team_1", "roster": [] }],
"roster_slots": [{ "position": "SP", "count": 5 }],
"league_scope": "Mixed"
}Returns injury updates, role changes, trades, promotions, and demotions sourced from the MLB Transactions API.
GET /signals/news?days=7&signal_type=injury
signal_type options: injury | role_change | trade | demotion | promotion
Every request to the analytical endpoints requires:
x-api-key: <your-key>
Keys are stored in the apikeys MongoDB collection.
The static portal under public/ uses session-based portal auth and account-scoped key issuance:
POST /api/auth/register—{ "displayName", "email", "password", "organization"? }— createsPortalUser, links/createsDeveloperAccount, sets HTTP-only session cookie.POST /api/auth/login—{ "email", "password" }— session cookie for subsequent portal calls.POST /api/auth/logout— clears session cookie.GET /api/auth/me(session) — current user + developer account summary.GET /api/account/me,GET /api/account/keys,POST /api/account/keys/issue(session) — list keys and mint a new key for the signed-in developer account (tier:free|standard|premium). Plaintext secret returned once in JSON (secret).
Programmatic / operator paths (no portal session):
GET /api/keys/status—{ "issuanceEnabled": boolean, "requiresToken": boolean }for UIs. Issuance defaults on; setKEY_ISSUANCE_ENABLED=0,false, oroffto disable.POST /api/developers—{ "displayName", "contactEmail"?, "organization"? }— create or return an existing active developer account (deduped by email or normalized name).POST /api/keys/issue— body{ "owner" (required), "email"?, "tier"?, "developerAccountId"? }; ifKEY_ISSUANCE_SECRETis set, sendX-Key-Issuance-Token. ReturnsapiKeyonce (use asx-api-key).
Example (local, issuance enabled, no secret — operator shortcut):
curl -sS -X POST "http://localhost:3002/api/keys/issue" \
-H "Content-Type: application/json" \
-d '{"owner":"local-dev","tier":"free"}' | jq .POST /api/keys remains the full programmatic create (label, owner, tier, scopes, optional expiry) for admin tooling; portal minting prefers POST /api/account/keys/issue when signed in, or POST /api/keys/issue for scripted flows.
For CI / pre-deploy smoke tests that call both Engine (x-api-key) and Draft (x-player-api-key / Bearer), use the same step-by-step tone as your runbooks: docs/pre-deploy-testing-keys.md.
New keys are stored hashed (keyHash, keyPrefix, label, owner, tier, scopes, …). Prefer the POST /api/keys or POST /api/keys/issue APIs so hashing matches the server’s API_KEY_PEPPER / APP_SECRET. The auth middleware still accepts legacy documents that store a plaintext key for migration. See src/models/ApiKey.ts and src/routes/apiKeys.ts for the canonical shape.
On startup the server runs best-effort compatibility hooks on apikeys (see src/lib/apiKeyCollection.ts): collMod to turn document validation off, and drop of a legacy unique index on key alone (hashed keys omit key; a unique key_1 index makes every insert collide on null). These require Mongo privileges; failures are logged and skipped. New keys also receive a unique synthetic email when none is supplied, for clusters that keep a unique index on email.
Usage is tracked per key (usageCount) for royalty reporting.
The embedded API Reference tab in the developer portal (pnpm dev:portal, then open the Reference tab) summarizes authentication, Draft-vs-Engine wiring, local ports, and links here for the full checklist.
Prerequisites: Node 20, pnpm 9, MongoDB Atlas access, Redis (optional)
# Install Redis (optional — API works without it)
brew install redis && brew services start redis
# Install dependencies
pnpm install
# Configure environment
cp .env.example .env
# Fill in MONGO_URI
# Refresh catalog + verify eligibility quality
pnpm sync-players:verify
# Start dev server (port 3002 — avoids conflict with draftroom on 3001)
pnpm devTest an endpoint:
curl -X POST http://localhost:3002/valuation/calculate \
-H "Content-Type: application/json" \
-H "x-api-key: <your-key>" \
-d '{"roster_slots":[{"position":"OF","count":3}],"scoring_categories":[{"name":"HR","type":"batting"}],"total_budget":260,"drafted_players":[]}'| Variable | Local | Production (App Runner) |
|---|---|---|
MONGO_URI |
Atlas connection string | Same |
PORT |
3002 |
8080 |
CORS_ORIGIN |
Omit (any Origin / reflect) or comma-separated allowlist | Omit works when the portal is served from the same App Runner URL. Set explicit origins for a separate browser UI or to lock down cross-origin access. |
PORTAL_API_BASE_URL |
Omit | Set to your API public origin (e.g. https://api.example.com, no trailing slash) only when the portal HTML is hosted on a different host than the API. |
REDIS_URL |
redis://localhost:6379 |
Upstash URL (when ready) |
KEY_ISSUANCE_ENABLED |
Omit or 1 / on (default on) |
Set to 0, false, or off to disable portal minting |
KEY_ISSUANCE_SECRET |
Optional shared secret for X-Key-Issuance-Token |
Same — use for operator-gated issuance |
Push to main — GitHub Actions builds, pushes to ECR, and triggers an App Runner deployment automatically.
Required GitHub secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
App Runner health check: GET /api/health on port 8080
MongoDB Atlas: Network Access must allow 0.0.0.0/0 (App Runner has no static IP).
Portal sign-in “network error”: Usually CORS: omit CORS_ORIGIN so the API reflects the browser Origin (no localhost default). If the portal is on another domain than the API, set CORS_ORIGIN to that portal origin (comma-separated if several) and set PORTAL_API_BASE_URL to the API’s public https://… URL.
Key issuance: Minting defaults on in the app (unset KEY_ISSUANCE_ENABLED means enabled). The Docker image also sets ENV KEY_ISSUANCE_ENABLED=1 for clarity. To disable on App Runner, set KEY_ISSUANCE_ENABLED=0 in the service environment (overrides the image). Optionally set KEY_ISSUANCE_SECRET and send X-Key-Issuance-Token on POST /api/keys/issue. Record intent in GitHub with gh variable set KEY_ISSUANCE_ENABLED --body 1 when the API allows (does not change AWS by itself).
Pass league_scope on any endpoint to filter the player pool:
| Value | Pool |
|---|---|
Mixed (default) |
All MLB players |
AL |
American League only |
NL |
National League only |
openapi/
openapi.yaml # OpenAPI 3.1 — API source of truth (with JSON Schema refs in prose)
schemas/
valuation-request.v1.schema.json # Flat Draft upstream (canonical)
valuation-request-v1.json # Nested league + draft_state (legacy tests)
test-fixtures/player-api/ # Engine samples; checkpoints/ mirrors Draft Activity #9 names
src/
index.ts # App entry point, route mounting
types/brain.ts # Shared TypeScript interfaces
lib/
redis.ts # Redis client (non-fatal on failure)
leagueScope.ts # AL/NL/Mixed player filtering
valuationQuality.ts # Reasonableness checks on valuation responses
draftedPlayerZod.ts # Zod schema for drafted-player rows
valuationRequest.ts # Zod parse + normalize (Draft flat vs nested v1)
zodErrors.ts # { field, message } for 400 bodies
middleware/
apiKey.ts # x-api-key validation + usage tracking
cache.ts # Redis response cache
models/
ApiKey.ts # Licensee key model
Player.ts # Master player data
services/
valuationWorkflow.ts # UML-aligned orchestration + quality gate
inflationEngine.ts # Auction inflation + Steal/Reach/Fair Value
scarcityEngine.ts # Positional scarcity + Monopoly Detection
mockPickEngine.ts # ADP + team-need draft simulator
newsService.ts # MLB Transactions API signals
routes/
valuation.ts # POST /valuation/calculate
catalog.ts # POST /catalog/batch-values
scarcity.ts # POST /analysis/scarcity
simulation.ts # POST /simulation/mock-pick
signals.ts # GET /signals/news
Run tests: pnpm test (Vitest; CI runs this on every deploy). Watch mode: pnpm test:watch. Lint includes test/ and vitest.config.ts.