All REST endpoints are routed through the API Gateway at http://localhost:3001/api. Auth routes (/api/auth/**) are proxied to the identity-service. Mutating requests to game and economy endpoints require a valid accessToken httpOnly cookie (set by the identity-service OAuth flow). The gateway injects a X-Correlation-Id header on all forwarded requests.
- Gateway
- Authentication
- Matches
- Player Actions
- Wallet
- Inventory
- Marketplace — Listings
- Marketplace — Trades
- Leaderboard
- WebSocket Events
// Response 200
{ "status": "ok", "uptime": 42.3 }Used by Kubernetes liveness and readiness probes. No authentication required.
All auth routes are proxied by the gateway to
identity-service(:3010). Cookies are httpOnly, SameSite=Lax, path=/.
Redirects the browser to GitHub's OAuth authorization page. No request body.
GitHub redirects here after the user authorises. The identity-service:
- Upserts the user in
identity_db.users(stable UUIDplayerId). - Mints access token (15 min) + refresh token (7 days).
- Sets
accessTokenandrefreshTokenas httpOnly cookies. - Redirects the browser to
WEB_REDIRECT_URL(defaulthttp://localhost:3000).
// Cookie required: refreshToken
// Response 200
{ "ok": true }
// New accessToken + refreshToken cookies set on response
// Response 401 — token absent, expired, or revoked
{ "error": "UNAUTHORIZED", "detail": "Refresh token is invalid or expired.", "correlationId": "uuid" }Uses JTI-based single-use rotation: old JTI is atomically revoked in refresh_tokens and a new JTI is issued.
// Cookie required: accessToken
// Response 200
{ "playerId": "uuid", "username": "github-login", "avatarUrl": "https://..." }
// Response 401 — cookie absent or expired
{ "error": "UNAUTHORIZED", "detail": "...", "correlationId": "uuid" }// Cookie required: accessToken
// Response 200
{ "ok": true }
// accessToken + refreshToken cookies cleared on response
// All refresh tokens for the user revoked in DBDisabled in production (
NODE_ENV=production→ 404). Used by E2E tests and local scripts to obtain a valid session cookie without the GitHub OAuth browser flow.
// Request
{ "playerId": "uuid", "username": "string" }
// Response 200
{ "ok": true }
// accessToken cookie set on response
// Response 400 — missing fields
{ "error": "VALIDATION_ERROR", "detail": "playerId and username are required.", "correlationId": "uuid" }// Request
{ "refreshToken": "string" }
// Response 200
{ "accessToken": "jwt", "expiresIn": 900 }
// Response 401
{ "error": "UNAUTHORIZED", "detail": "Refresh token invalid or expired.", "correlationId": "uuid" }// Request
{} // Player is assigned to a pending match or a new one is created
// Response 201
{
"matchId": "uuid",
"status": "PENDING",
"players": [{ "playerId": "uuid", "username": "string" }],
"gridSize": 10,
"wsToken": "jwt" // short-lived token for WebSocket auth
}// Response 200
{
"matchId": "uuid",
"status": "ACTIVE", // PENDING | ACTIVE | FINISHED
"players": [
{ "playerId": "uuid", "username": "string", "hp": 80, "score": 120, "position": { "x": 3, "y": 5 } }
],
"startedAt": "ISO8601",
"finishedAt": null
}This is the primary idempotency endpoint. The client must supply X-Idempotency-Key for any action. If useStamp: true, the key is also the Stamp UUID and triggers the Stamp-sealed flow (see GAME.md §4).
Headers:
| Header | Required | Description |
|---|---|---|
Authorization |
✅ | Bearer <jwt> |
X-Idempotency-Key |
✅ | Client-generated UUID v4. Must be globally unique per action. |
Request:
{
"type": "attack" | "defend" | "move" | "collect",
"useStamp": false, // optional — consumes one Stamp from balance
"payload": {
// attack
"targetId": "uuid",
// move
"direction": "north" | "south" | "east" | "west",
// collect — no extra payload needed
}
}Response 200 — Action accepted (first submission or idempotent replay):
{
"actionId": "uuid", // equals X-Idempotency-Key
"type": "attack",
"result": {
"damage": 30,
"targetHp": 50
},
"sealed": true, // true if Stamp was spent
"stampBalance": 4 // updated balance (only if sealed)
}Response 409 — Action invalid (target dead, out of range, etc.):
{ "error": "TARGET_OUT_OF_RANGE", "detail": "Target is not in attack range." }Response 402 — Stamp requested but balance is 0:
{ "error": "INSUFFICIENT_STAMPS", "detail": "No Stamps available to seal this action." }// Response 200
{
"playerId": "uuid",
"balance": 1250, // in minor units (cents)
"heldAmount": 500, // reserved for pending trades
"stampBalance": 3,
"updatedAt": "ISO8601"
}// Query params: ?page=1&limit=20&type=REWARD
// Response 200
{
"items": [
{
"id": "uuid",
"amount": 500,
"type": "REWARD",
"referenceId": "uuid",
"createdAt": "ISO8601"
}
],
"total": 42,
"page": 1
}// Response 200
{
"items": [
{
"id": "uuid",
"itemId": "rare_sword_01",
"name": "Rare Sword",
"type": "weapon",
"locked": false // true when item is committed to a pending trade
}
]
}// Request
{ "itemId": "uuid", "price": 800 }
// Response 201
{
"listingId": "uuid",
"itemId": "uuid",
"price": 800,
"status": "ACTIVE",
"createdAt": "ISO8601"
}Response 409 if item is locked (already in a pending trade).
// Query params: ?page=1&limit=20&minPrice=100&maxPrice=1000
// Response 200
{
"items": [
{
"listingId": "uuid",
"sellerId": "uuid",
"sellerName": "string",
"itemId": "uuid",
"itemName": "string",
"price": 800,
"createdAt": "ISO8601"
}
],
"total": 15,
"page": 1
}// Response 200
{ "listingId": "uuid", "status": "CANCELLED" }Response 409 if a trade is in progress for this listing.
// Request
{ "listingId": "uuid" }
// Response 202 — Saga initiated
{
"tradeId": "uuid",
"listingId": "uuid",
"status": "PENDING",
"price": 800
}Response 402 — Insufficient balance.
Response 503 — Wallet or Inventory circuit breaker open.
// Response 200
{
"tradeId": "uuid",
"listingId": "uuid",
"buyerId": "uuid",
"sellerId": "uuid",
"itemId": "uuid",
"price": 800,
"status": "COMPLETED", // PENDING | COMPLETED | FAILED
"sagaState": "COMPLETED",
"createdAt": "ISO8601",
"completedAt": "ISO8601"
}// Query params: ?page=1&limit=20&role=buyer
// Response 200
{
"items": [ /* array of trade objects */ ],
"total": 7,
"page": 1
}// Response 200
{
"entries": [
{ "rank": 1, "playerId": "uuid", "username": "string", "score": 4200 }
],
"cachedAt": "ISO8601", // timestamp of last Redis write
"stale": false // true if served from stale cache due to DB slowness
}Connect to: ws://localhost:4000/game?token=<wsToken>
The wsToken is obtained from POST /matches and is match-scoped.
{
"event": "match.state",
"data": {
"matchId": "uuid",
"status": "ACTIVE",
"grid": {
"size": 10,
"cells": [
{ "x": 3, "y": 5, "type": "resource_node", "depleted": false }
]
},
"players": [
{ "playerId": "uuid", "username": "string", "hp": 100, "position": { "x": 0, "y": 0 }, "score": 0 }
]
}
}{
"event": "match.action",
"data": {
"actionId": "uuid",
"playerId": "uuid",
"type": "attack",
"sealed": true,
"result": { "targetId": "uuid", "damage": 30, "targetHp": 70 }
}
}{
"event": "match.player_eliminated",
"data": { "playerId": "uuid", "eliminatedBy": "uuid", "resourcesDropped": 150 }
}{
"event": "match.finished",
"data": {
"matchId": "uuid",
"winnerId": "uuid",
"finalScores": [{ "playerId": "uuid", "score": 480 }],
"rewards": [
{ "type": "currency", "amount": 500 },
{ "type": "stamps", "amount": 3 }
]
}
}{
"event": "trade.completed",
"data": { "tradeId": "uuid", "role": "buyer" | "seller" }
}{
"event": "trade.failed",
"data": { "tradeId": "uuid", "reason": "TRANSFER_FAILED" }
}{
"event": "action",
"data": {
"idempotencyKey": "uuid",
"type": "attack",
"useStamp": true,
"payload": { "targetId": "uuid" }
}
}Server replies with match.action or an error event.
All error responses follow:
{
"error": "MACHINE_READABLE_CODE",
"detail": "Human-readable explanation.",
"correlationId": "uuid"
}| Code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED |
401 | Missing or invalid JWT |
MATCH_NOT_FOUND |
404 | Match does not exist |
ACTION_ALREADY_PROCESSED |
200 | Idempotent replay — original response returned |
TARGET_OUT_OF_RANGE |
409 | Action invalid given current game state |
INSUFFICIENT_STAMPS |
402 | Stamp requested but stamp_balance = 0 |
INSUFFICIENT_BALANCE |
402 | Not enough currency for trade |
ITEM_LOCKED |
409 | Item already in a pending trade |
CIRCUIT_OPEN |
503 | Downstream service unavailable |
For game mechanics (grid rules, combat resolution, action range) see GAME.md. For Kafka event contracts see SPEC.md §3.