{
  "openapi": "3.0.0",
  "info": {
    "title": "Tavern Finder API",
    "description": "Public API for discovering tabletop gaming events and venues, plus authenticated routes for RSVP'ing, creating events, and managing personal access tokens. AI agents are encouraged to use this directly or via the MCP server at https://mcp.tavernfinder.com/mcp.",
    "version": "1.1.0"
  },
  "servers": [
    {
      "url": "https://tavernfinder.com/api/v1",
      "description": "Production"
    }
  ],
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Either a short-lived JWT access token (issued by /auth/login) for interactive users, or an opaque personal access token of the form `tf_pat_<32 hex>` minted at https://tavernfinder.com/profile/tokens. Write endpoints check the PAT's scopes (`write:rsvp`, `write:create_event`, `write:profile`); JWT requests have all scopes implicitly."
      }
    },
    "schemas": {
      "Activity": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "venueId": { "type": "string", "format": "uuid" },
          "venueName": { "type": "string" },
          "venueCity": { "type": "string", "nullable": true },
          "title": { "type": "string", "nullable": true },
          "gameBggId": { "type": "integer", "nullable": true },
          "gameName": { "type": "string", "nullable": true },
          "date": { "type": "string", "format": "date" },
          "startTime": { "type": "string" },
          "endTime": { "type": "string" },
          "maxCapacity": { "type": "integer", "nullable": true },
          "participantCount": { "type": "integer" },
          "distance": { "type": "number", "nullable": true, "description": "Distance in km if lat/lng provided" }
        }
      },
      "Venue": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "city": { "type": "string", "nullable": true },
          "address": { "type": "string", "nullable": true },
          "latitude": { "type": "string", "nullable": true },
          "longitude": { "type": "string", "nullable": true },
          "venueType": { "type": "string", "enum": ["game_store", "cafe_bar", "community_space"] },
          "amenities": { "type": "array", "items": { "type": "string" } },
          "products": { "type": "array", "items": { "type": "string" } },
          "isActive": { "type": "boolean" },
          "hasOwner": { "type": "boolean" },
          "distance": { "type": "number", "nullable": true }
        }
      },
      "Game": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "description": "BoardGameGeek ID" },
          "name": { "type": "string" },
          "yearPublished": { "type": "integer", "nullable": true },
          "thumbnail": { "type": "string", "nullable": true },
          "bggRank": { "type": "integer", "nullable": true }
        }
      },
      "AccessToken": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": "string", "maxLength": 80 },
          "scopes": { "type": "array", "items": { "type": "string", "enum": ["read:events", "read:venues", "read:games", "write:rsvp", "write:create_event", "write:profile"] } },
          "lastUsedAt": { "type": "string", "format": "date-time", "nullable": true },
          "createdAt": { "type": "string", "format": "date-time" },
          "revokedAt": { "type": "string", "format": "date-time", "nullable": true }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "type": "string" },
              "message": { "type": "string" }
            }
          }
        }
      }
    }
  },
  "paths": {
    "/activities/discover": {
      "get": {
        "summary": "Discover nearby events and table bookings",
        "description": "Public. Returns upcoming activities filtered by game, type, and proximity. Excludes activities at private venues.",
        "parameters": [
          { "name": "lat", "in": "query", "schema": { "type": "number" }, "description": "Latitude for distance calculation" },
          { "name": "lng", "in": "query", "schema": { "type": "number" }, "description": "Longitude for distance calculation" },
          { "name": "type", "in": "query", "schema": { "type": "string", "enum": ["all", "event", "table"] } },
          { "name": "gameBggId", "in": "query", "schema": { "type": "integer" }, "description": "BoardGameGeek game ID" },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 1000 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of activities",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Activity" } },
                    "total": { "type": "integer" },
                    "nearbyCount": { "type": "integer" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/venues": {
      "get": {
        "summary": "Search venues",
        "description": "Public. Excludes private_space venues. Supports proximity sorting via lat/lng.",
        "parameters": [
          { "name": "q", "in": "query", "schema": { "type": "string" }, "description": "Free-text search across name and address" },
          { "name": "city", "in": "query", "schema": { "type": "string" } },
          { "name": "venueType", "in": "query", "schema": { "type": "string", "enum": ["game_store", "cafe_bar", "community_space"] } },
          { "name": "amenities", "in": "query", "schema": { "type": "string" }, "description": "CSV list — play_space, food, game_library, etc." },
          { "name": "products", "in": "query", "schema": { "type": "string" }, "description": "CSV list — board_games, tcg_products, miniatures, etc." },
          { "name": "unclaimed", "in": "query", "schema": { "type": "boolean" } },
          { "name": "lat", "in": "query", "schema": { "type": "number" } },
          { "name": "lng", "in": "query", "schema": { "type": "number" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 1000 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of venues",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Venue" } },
                    "total": { "type": "integer" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/venues/{venueId}": {
      "get": {
        "summary": "Get a single venue",
        "description": "Public.",
        "parameters": [
          { "name": "venueId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
        ],
        "responses": {
          "200": { "description": "Venue", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Venue" } } } },
          "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/geo/me": {
      "get": {
        "summary": "Best-effort IP geolocation",
        "description": "Public. Reads Cloudflare-injected request headers (cf-iplatitude, cf-iplongitude, cf-ipcity, cf-ipcountry) and returns them. Returns null fields when the request didn't traverse Cloudflare (e.g. direct LAN access in dev). No third-party calls.",
        "responses": {
          "200": {
            "description": "Coords + location names, or nulls when unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "lat": { "type": "number", "nullable": true, "example": 38.7077 },
                        "lng": { "type": "number", "nullable": true, "example": -9.1366 },
                        "city": { "type": "string", "nullable": true, "example": "Lisbon" },
                        "country": { "type": "string", "nullable": true, "example": "PT" }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/games-bgg": {
      "get": {
        "summary": "Search the BoardGameGeek catalog",
        "description": "Public. Fuzzy match on game names and aliases. Use the returned `id` as `gameBggId` on /activities/discover.",
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 1000 } }
        ],
        "responses": {
          "200": { "description": "Paginated list of games", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Game" } } } } } } }
        }
      }
    },
    "/auth/register": {
      "post": {
        "summary": "Register a new user",
        "description": "Public. Returns access + refresh tokens. The new account is unverified until the user clicks the email verification link.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "password", "name", "lastName", "tosAccepted"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "password": { "type": "string", "minLength": 8 },
                  "name": { "type": "string" },
                  "lastName": { "type": "string" },
                  "tosAccepted": { "type": "boolean", "description": "Must be true; user agrees to https://tavernfinder.com/terms" },
                  "locale": { "type": "string", "enum": ["pt", "en", "es"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Created with access + refresh tokens" },
          "409": { "description": "Email already registered" }
        }
      }
    },
    "/auth/login": {
      "post": {
        "summary": "Log in",
        "description": "Public. Returns access + refresh tokens.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "password"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "password": { "type": "string" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Credentials accepted" },
          "401": { "description": "Invalid credentials" }
        }
      }
    },
    "/users/me/tokens": {
      "get": {
        "summary": "List the user's personal access tokens",
        "description": "JWT only — PATs cannot list/manage other PATs (anti-pivot).",
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": { "description": "List of tokens", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/AccessToken" } } } } } } },
          "403": { "description": "Called with a PAT instead of a JWT" }
        }
      },
      "post": {
        "summary": "Mint a new personal access token",
        "description": "JWT only. Returns the plaintext token **once** — it is not recoverable.",
        "security": [{ "BearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name"],
                "properties": {
                  "name": { "type": "string", "maxLength": 80, "description": "Human label, e.g. 'Claude Desktop on my laptop'" },
                  "scopes": { "type": "array", "items": { "type": "string", "enum": ["read:events", "read:venues", "read:games", "write:rsvp", "write:create_event", "write:profile"] } }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": { "type": "string", "format": "uuid" },
                    "name": { "type": "string" },
                    "scopes": { "type": "array", "items": { "type": "string" } },
                    "createdAt": { "type": "string", "format": "date-time" },
                    "plaintext": { "type": "string", "description": "The opaque token string (`tf_pat_...`). Shown once." }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/users/me/tokens/{id}": {
      "delete": {
        "summary": "Revoke a personal access token",
        "description": "JWT only. Soft-revoke (sets revoked_at). Subsequent calls with the token fail with 401.",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
        ],
        "responses": {
          "200": { "description": "Revoked" },
          "404": { "description": "Token not found or owned by another user" }
        }
      }
    },
    "/venues/{venueId}/activities/{activityId}/join": {
      "post": {
        "summary": "RSVP / join an event or table booking",
        "description": "Requires `write:rsvp` scope (or a JWT). The user must be a member of the venue.",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          { "name": "venueId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
          { "name": "activityId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "accessCode": { "type": "string", "description": "Required only if the event is access-code-protected" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Joined" },
          "403": { "description": "Missing scope, blacklisted, or no venue membership" },
          "409": { "description": "Already joined or capacity full" }
        }
      }
    },
    "/venues/{venueId}/activities": {
      "post": {
        "summary": "Create an event or table booking",
        "description": "Requires `write:create_event` scope (or a JWT). Player role can create table bookings; staff/CL can create open events.",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          { "name": "venueId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["date", "startTime", "endTime"],
                "properties": {
                  "tableId": { "type": "string", "format": "uuid" },
                  "date": { "type": "string", "format": "date" },
                  "startTime": { "type": "string" },
                  "endTime": { "type": "string" },
                  "title": { "type": "string", "maxLength": 200 },
                  "description": { "type": "string", "maxLength": 2000 },
                  "gameBggId": { "type": "integer" },
                  "maxCapacity": { "type": "integer" },
                  "accessCode": { "type": "string" },
                  "autoJoin": { "type": "boolean" }
                }
              }
            }
          }
        },
        "responses": {
          "201": { "description": "Created" },
          "403": { "description": "Missing scope or insufficient venue role" }
        }
      }
    }
  }
}
