{
  "openapi": "3.1.0",
  "info": {
    "title": "Nargusta Public API",
    "version": "1.0.0",
    "summary": "Read-only discovery endpoints for the Nargusta sailors' hospitality network.",
    "description": "Nargusta is a phone-verified hospitality network where sailors host one another. These endpoints are the subset that requires no account: host search and browsing, place resolution, and yacht club name lookup. Everything else in the network (requests, letters of introduction, passages, vouching, profiles) requires an authenticated member session and is deliberately not described here. Unauthenticated callers are limited to 120 reads per IP address per hour; authenticated members are not rate limited. Responses to unauthenticated callers omit member reputation counts and distances.",
    "contact": { "name": "Nargusta", "url": "https://nargusta.com/contact" },
    "license": { "name": "Free and non-commercial use", "url": "https://nargusta.com/terms" }
  },
  "servers": [{ "url": "https://nargusta.com", "description": "Production" }],
  "externalDocs": { "description": "Human-readable API documentation", "url": "https://nargusta.com/api-docs" },
  "paths": {
    "/api/search": {
      "get": {
        "operationId": "searchHosts",
        "summary": "Search hosts near a place or coordinate",
        "description": "Ranks members offering hospitality near an origin. Supply either a free-text place in `q` or a `lat`/`lng` pair; coordinates take precedence when both are present and parse as finite numbers.",
        "parameters": [
          { "name": "q", "in": "query", "required": false, "description": "Free-text place name to geocode as the search origin. Required unless lat and lng are supplied.", "schema": { "type": "string" }, "example": "Newport, RI" },
          { "name": "lat", "in": "query", "required": false, "description": "Origin latitude in decimal degrees. Must be paired with lng.", "schema": { "type": "number" } },
          { "name": "lng", "in": "query", "required": false, "description": "Origin longitude in decimal degrees. Must be paired with lat.", "schema": { "type": "number" } },
          { "name": "radius", "in": "query", "required": false, "description": "Search radius in statute miles, capped at 1000. The literal string \"any\" removes the limit. Unparseable values fall back to the 50 mile default.", "schema": { "oneOf": [{ "type": "number", "exclusiveMinimum": 0, "maximum": 1000 }, { "const": "any" }], "default": 50 } },
          { "name": "tag", "in": "query", "required": false, "description": "Restrict results to members offering these forms of hospitality. Repeat the parameter for multiple tags.", "schema": { "type": "array", "items": { "$ref": "#/components/schemas/OfferTag" } }, "style": "form", "explode": true },
          { "name": "verifiedOnly", "in": "query", "required": false, "description": "Set to 1 to return only members with at least one community-verified club affiliation.", "schema": { "type": "string", "enum": ["1"] } }
        ],
        "responses": {
          "200": {
            "description": "Ranked hosts. `origin` is null when a place name could not be resolved, in which case `results` is empty.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchResult" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/GuestLimit" }
        }
      }
    },
    "/api/browse": {
      "get": {
        "operationId": "browseHosts",
        "summary": "Browse hosts without a location",
        "description": "Returns member cards with no geographic origin, for browsing the whole network.",
        "parameters": [
          { "name": "tag", "in": "query", "required": false, "description": "Restrict results to members offering these forms of hospitality. Repeat the parameter for multiple tags.", "schema": { "type": "array", "items": { "$ref": "#/components/schemas/OfferTag" } }, "style": "form", "explode": true },
          { "name": "verifiedOnly", "in": "query", "required": false, "description": "Set to 1 to return only members with at least one community-verified club affiliation.", "schema": { "type": "string", "enum": ["1"] } }
        ],
        "responses": {
          "200": {
            "description": "Member cards.",
            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/MemberCard" } } } }
          },
          "429": { "$ref": "#/components/responses/GuestLimit" }
        }
      }
    },
    "/api/geocode": {
      "get": {
        "operationId": "resolvePlace",
        "summary": "Resolve a place name to a coordinate",
        "description": "Resolves a typed place to a coordinate and a canonical label, so a caller can confirm where a place actually plots before searching on it.",
        "parameters": [
          { "name": "q", "in": "query", "required": true, "description": "Place name to resolve.", "schema": { "type": "string", "minLength": 1 }, "example": "Antigua" }
        ],
        "responses": {
          "200": {
            "description": "Resolution outcome. Check `found` before reading the coordinate.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GeocodeResult" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/GuestLimit" }
        }
      }
    },
    "/api/clubs": {
      "get": {
        "operationId": "searchClubs",
        "summary": "Look up yacht clubs by name",
        "description": "Fuzzy lookup over the static yacht and sailing club directory, matching on name prefix, substring, acronym, and subsequence. Returns at most 8 clubs, best match first. An empty query returns an empty array. This endpoint reads no member data and is not rate limited.",
        "parameters": [
          { "name": "q", "in": "query", "required": false, "description": "Partial club name or acronym.", "schema": { "type": "string" }, "example": "nyyc" }
        ],
        "responses": {
          "200": {
            "description": "Matching clubs.",
            "content": { "application/json": { "schema": { "type": "array", "maxItems": 8, "items": { "$ref": "#/components/schemas/Club" } } } }
          }
        }
      }
    }
  },
  "components": {
    "responses": {
      "BadRequest": {
        "description": "A required parameter was missing.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "GuestLimit": {
        "description": "The unauthenticated read limit of 120 requests per IP address per hour has been reached.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "OfferTag": {
        "type": "string",
        "description": "A form of hospitality a member offers.",
        "enum": ["club-access", "mooring-dock", "crew-day-sail", "local-hosting"]
      },
      "MemberCard": {
        "type": "object",
        "description": "The public view of a member. Members are never identified by full name or phone number. For unauthenticated callers, `hosts_count`, `guest_count` and `vouch_count` are zero, `established_host` is false, and `distance_km` is absent.",
        "properties": {
          "id": { "type": "string", "description": "Opaque member identifier." },
          "display_name": { "type": "string", "description": "First name plus last initial.", "examples": ["James T."] },
          "clubs": { "type": "array", "items": { "type": "string" }, "description": "Self-declared club affiliations." },
          "home_port": { "type": "string" },
          "offer_tags": { "type": "array", "items": { "$ref": "#/components/schemas/OfferTag" } },
          "offer_text": { "type": "string", "description": "The member's own description of what they offer." },
          "hosts_count": { "type": "integer", "minimum": 0, "description": "Times this member has hosted." },
          "guest_count": { "type": "integer", "minimum": 0, "description": "Times this member has been hosted." },
          "vouch_count": { "type": "integer", "minimum": 0 },
          "verified_clubs": { "type": "array", "items": { "type": "string" }, "description": "Affiliations other members have verified." },
          "established_host": { "type": "boolean" },
          "distance_km": { "type": "number", "minimum": 0, "description": "Great-circle distance from the search origin. Present only for authenticated callers on a located search." }
        },
        "required": ["id", "display_name", "clubs", "home_port", "offer_tags", "offer_text", "hosts_count", "guest_count", "vouch_count", "verified_clubs", "established_host"]
      },
      "SearchResult": {
        "type": "object",
        "properties": {
          "origin": {
            "description": "The resolved search origin, or null when the place name could not be resolved.",
            "oneOf": [{ "$ref": "#/components/schemas/LatLng" }, { "type": "null" }]
          },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/MemberCard" } },
          "clubsHere": { "type": "array", "items": { "type": "string" }, "description": "Club affiliations claimed by the members in results. Not necessarily clubs located near the origin; a member found near the origin may claim a club based far away." }
        },
        "required": ["origin", "results", "clubsHere"]
      },
      "LatLng": {
        "type": "object",
        "properties": {
          "lat": { "type": "number" },
          "lng": { "type": "number" }
        },
        "required": ["lat", "lng"]
      },
      "GeocodeResult": {
        "type": "object",
        "properties": {
          "found": { "type": "boolean" },
          "lat": { "type": "number", "description": "Present only when found is true." },
          "lng": { "type": "number", "description": "Present only when found is true." },
          "label": { "type": ["string", "null"], "description": "Canonical place label. Present only when found is true." }
        },
        "required": ["found"]
      },
      "Club": {
        "type": "object",
        "properties": {
          "name": { "type": "string" },
          "country": { "type": "string" },
          "region": { "type": "string" }
        },
        "required": ["name", "country"]
      },
      "Error": {
        "type": "object",
        "properties": { "error": { "type": "string", "description": "A sentence describing the problem, suitable for display." } },
        "required": ["error"]
      }
    }
  }
}
