Skip to content

Latest commit

 

History

History
534 lines (411 loc) · 11.4 KB

File metadata and controls

534 lines (411 loc) · 11.4 KB

idempo — API Contracts

Related: SPEC.md · GAME.md

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.


Table of Contents

  1. Gateway
  2. Authentication
  3. Matches
  4. Player Actions
  5. Wallet
  6. Inventory
  7. Marketplace — Listings
  8. Marketplace — Trades
  9. Leaderboard
  10. WebSocket Events

0. Gateway

GET /health — Liveness / readiness probe

// Response 200
{ "status": "ok", "uptime": 42.3 }

Used by Kubernetes liveness and readiness probes. No authentication required.


1. Authentication

All auth routes are proxied by the gateway to identity-service (:3010). Cookies are httpOnly, SameSite=Lax, path=/.

GET /auth/github — Initiate GitHub OAuth

Redirects the browser to GitHub's OAuth authorization page. No request body.


GET /auth/github/callback — GitHub OAuth callback

GitHub redirects here after the user authorises. The identity-service:

  1. Upserts the user in identity_db.users (stable UUID playerId).
  2. Mints access token (15 min) + refresh token (7 days).
  3. Sets accessToken and refreshToken as httpOnly cookies.
  4. Redirects the browser to WEB_REDIRECT_URL (default http://localhost:3000).

POST /auth/refresh — Rotate refresh token

// 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.


GET /auth/me — Current user identity

// Cookie required: accessToken
// Response 200
{ "playerId": "uuid", "username": "github-login", "avatarUrl": "https://..." }

// Response 401 — cookie absent or expired
{ "error": "UNAUTHORIZED", "detail": "...", "correlationId": "uuid" }

POST /auth/logout — Revoke session

// Cookie required: accessToken
// Response 200
{ "ok": true }
// accessToken + refreshToken cookies cleared on response
// All refresh tokens for the user revoked in DB

POST /auth/test-token — Dev/test bypass (non-production only)

Disabled 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" }

2. Matches

POST /matches — Create or join a match

// 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
}

GET /matches/:matchId — Get match state

// 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
}

3. Player Actions

POST /matches/:matchId/actions — Submit an arena action

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." }

4. Wallet

GET /wallet — Get balance and Stamp count

// Response 200
{
  "playerId": "uuid",
  "balance": 1250,         // in minor units (cents)
  "heldAmount": 500,       // reserved for pending trades
  "stampBalance": 3,
  "updatedAt": "ISO8601"
}

GET /wallet/transactions — Ledger history

// 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
}

5. Inventory

GET /inventory — List owned items

// 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
    }
  ]
}

6. Marketplace — Listings

POST /marketplace/listings — Create a listing

// 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).

GET /marketplace/listings — Browse active listings

// 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
}

DELETE /marketplace/listings/:listingId — Cancel a listing

// Response 200
{ "listingId": "uuid", "status": "CANCELLED" }

Response 409 if a trade is in progress for this listing.


7. Marketplace — Trades

POST /marketplace/trades — Initiate a trade (starts Saga)

// 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.

GET /marketplace/trades/:tradeId — Get trade status

// 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"
}

GET /marketplace/trades — Trade history for authenticated player

// Query params: ?page=1&limit=20&role=buyer
// Response 200
{
  "items": [ /* array of trade objects */ ],
  "total": 7,
  "page": 1
}

8. Leaderboard

GET /leaderboard/top100 — Global top-100 (Redis-cached)

// 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
}

9. WebSocket Events

Connect to: ws://localhost:4000/game?token=<wsToken>

The wsToken is obtained from POST /matches and is match-scoped.

Server → Client

match.state — Full state sync on connect

{
  "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 }
    ]
  }
}

match.action — Player action resolved

{
  "event": "match.action",
  "data": {
    "actionId": "uuid",
    "playerId": "uuid",
    "type": "attack",
    "sealed": true,
    "result": { "targetId": "uuid", "damage": 30, "targetHp": 70 }
  }
}

match.player_eliminated

{
  "event": "match.player_eliminated",
  "data": { "playerId": "uuid", "eliminatedBy": "uuid", "resourcesDropped": 150 }
}

match.finished

{
  "event": "match.finished",
  "data": {
    "matchId": "uuid",
    "winnerId": "uuid",
    "finalScores": [{ "playerId": "uuid", "score": 480 }],
    "rewards": [
      { "type": "currency", "amount": 500 },
      { "type": "stamps", "amount": 3 }
    ]
  }
}

trade.completed

{
  "event": "trade.completed",
  "data": { "tradeId": "uuid", "role": "buyer" | "seller" }
}

trade.failed

{
  "event": "trade.failed",
  "data": { "tradeId": "uuid", "reason": "TRANSFER_FAILED" }
}

Client → Server

action — Submit a player action (alternative to REST for lower latency)

{
  "event": "action",
  "data": {
    "idempotencyKey": "uuid",
    "type": "attack",
    "useStamp": true,
    "payload": { "targetId": "uuid" }
  }
}

Server replies with match.action or an error event.


Error Format

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.