openapi: 3.1.0
info:
  title: Lyra Public API
  version: 3.0.0
  description: |
    REST API for the Lyra block list, Clean brand list, and user-managed personal lists.

    **All endpoints under `/api/v1/*` and the MCP server at `/mcp` require authentication**
    via Bearer license key (`Authorization: Bearer lyra_<48-hex>`) or a session cookie.
    The license key is obtained either through the OAuth Device Authorization Flow at
    `/mcp/auth` (recommended for AI assistants) or generated manually at `/billing`.

    **Daily quota:** 100 requests per day per user across all authenticated endpoints
    and MCP tools. Resets at 00:00 UTC. Quota state is reported in `X-Lyra-Quota-*`
    response headers and via `GET /api/v1/quota`. Exceeded requests return `429`.

    **Session limit:** 3 active sessions per account, counting browser logins and
    license keys together. A new session beyond the cap revokes the oldest one.

    **Free trial:** 15 days from sign-up, full access. Then $5 USD/year or $25 lifetime
    (R$25/year or R$125 in Brazil via Mercado Pago).

    Static JSON files (`/v1/domains.json`, `/v1/clean.json`, `/v1/version.json`) and the
    HTML pages (`/blocked`, `/clean`, `/mcp/about`) remain open for transparency and to
    keep the browser extension working without authentication.

    All endpoints accept either a `?locale=` query parameter or an `Accept-Language`
    header to localize category labels. Defaults to `en`.

  contact:
    name: Lyra
    email: hello@lyrasearch.com
    url: https://lyrasearch.com
  license:
    name: Proprietary
    url: https://lyrasearch.com/privacy.html

servers:
  - url: https://lyrasearch.com
    description: Production

paths:
  /api/v1/blocked:
    get:
      summary: List blocked domains
      description: Returns the official Lyra block list of fast-fashion, dropshipping and predatory marketplaces.
      tags: [Lists]
      parameters:
        - $ref: '#/components/parameters/Locale'
      responses:
        '200':
          description: Block list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BlockedListResponse'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /api/v1/clean:
    get:
      summary: List Clean brands
      description: Returns the curated Lyra Clean brands (slow-buy, sustainable, quality).
      tags: [Lists]
      parameters:
        - $ref: '#/components/parameters/Locale'
        - in: query
          name: category
          schema:
            type: string
            enum: [sustainable-fashion, slow-design, local-brand, second-hand, craft, fair-trade, other]
        - in: query
          name: country
          schema:
            type: string
            description: ISO country code (US, BR, FR, etc.)
      responses:
        '200':
          description: Clean brands
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CleanListResponse'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /api/v1/check:
    get:
      summary: Check a domain
      description: Checks whether a given domain is on the block list, the Clean list, or neither.
      tags: [Lookup]
      parameters:
        - $ref: '#/components/parameters/Locale'
        - in: query
          name: domain
          required: true
          schema:
            type: string
          description: Domain or URL (shein.com or https://www.shein.com/...)
      responses:
        '200':
          description: Match result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckResponse'
        '400':
          $ref: '#/components/responses/BadRequest'

  /api/v1/suggest:
    get:
      summary: Suggest Clean alternatives
      description: |
        Given a domain (typically blocked) or no domain, returns up to N curated Clean alternatives
        ordered by category relevance.
      tags: [Lookup]
      parameters:
        - $ref: '#/components/parameters/Locale'
        - in: query
          name: domain
          required: false
          schema:
            type: string
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 20
            default: 5
      responses:
        '200':
          description: Suggestions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuggestResponse'

  /api/v1/stats:
    get:
      summary: Aggregate stats
      description: Returns counts by category, country, and list versions.
      tags: [Lists]
      parameters:
        - $ref: '#/components/parameters/Locale'
      responses:
        '200':
          description: Stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatsResponse'

  /api/v1/search:
    get:
      summary: Build a filtered search URL
      description: |
        Returns a DuckDuckGo or Google search URL pre-filtered with `-site:` operators
        from the official block list. If a Bearer license key is supplied, the user's
        personal block list is merged in (deduped). Capped at 80 sites due to URL length.
      tags: [Search]
      security:
        - {}
        - bearerAuth: []
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string }
          description: Free-text search query
        - in: query
          name: engine
          schema:
            type: string
            enum: [ddg, google]
            default: ddg
      responses:
        '200':
          description: Filtered search URL
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '400':
          $ref: '#/components/responses/BadRequest'

  /api/v1/install:
    get:
      summary: Get install links
      description: Returns localized install URLs for Chrome / Edge / Safari / Android / iOS / web channels.
      tags: [Distribution]
      parameters:
        - $ref: '#/components/parameters/Locale'
      responses:
        '200':
          description: Install links
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstallResponse'

  /api/v1/share:
    get:
      summary: Get share messages
      description: |
        Pre-built shareable messages (short / long / hashtag) and pre-filled intent URLs
        for Twitter, WhatsApp, Telegram, Email, LinkedIn and Facebook.
      tags: [Distribution]
      parameters:
        - $ref: '#/components/parameters/Locale'
      responses:
        '200':
          description: Share messages and intent URLs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShareResponse'

  /api/v1/submit:
    post:
      summary: Submit a domain or brand for human review
      description: |
        Authenticated users (via Bearer license key or session cookie) can submit a
        domain to add to the block list, or a brand to add to the Clean list.
        Submissions are queued for human review.
      tags: [Submit]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitRequest'
      responses:
        '200':
          description: Submission accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubmitResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/personal:
    get:
      summary: Get personal block + Clean lists
      description: Returns the authenticated user's personal block and Clean lists.
      tags: [Personal]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Personal lists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonalListsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      summary: Add to personal block or Clean list
      description: |
        Adds a domain to the user's personal block list or Clean list.
        Adding to one automatically removes from the other (mutex).
      tags: [Personal]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonalAddRequest'
      responses:
        '200':
          description: Added
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'
    delete:
      summary: Remove from personal block or Clean list
      tags: [Personal]
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: list
          required: true
          schema: { type: string, enum: [block, clean] }
        - in: query
          name: domain
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Removed
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/keys:
    get:
      summary: List user's license keys
      description: Returns all license keys (active and revoked) for the authenticated user.
      tags: [Keys]
      security:
        - sessionCookie: []
      responses:
        '200':
          description: List of license keys
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      summary: Create a new license key (manual)
      description: |
        Generates a manual license key. Most users should prefer the OAuth Device Flow
        at `/mcp/oauth/device` — see `/mcp/about` and the MCP discovery manifest at
        `/.well-known/mcp.json`.
      tags: [Keys]
      security:
        - sessionCookie: []
      responses:
        '200':
          description: New license key (only shown once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { type: string, example: 'lyra_a1b2c3...' }
                  id: { type: string }

  /api/v1/quota:
    get:
      summary: Daily quota status for the authenticated user
      description: Returns current daily quota usage. Does not consume quota itself.
      tags: [Auth]
      security:
        - bearerAuth: []
        - sessionCookie: []
      responses:
        '200':
          description: Quota status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QuotaStatus'

  /mcp/oauth/device:
    post:
      summary: Start a Device Authorization Flow (RFC 8628)
      description: |
        Returns a `device_code` and a `verification_uri_complete` URL that uniquely
        identifies this authorization request. The AI client should show the URL to
        the user as a clickable link — there is no short code to copy or type. The
        client then polls `/mcp/oauth/token` every `interval` seconds until the user
        authorizes or rejects.

        This is the recommended way for AI assistants (Claude Desktop, Cursor, Zed,
        Continue, etc.) to obtain a Bearer license key.
      tags: [OAuth]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                client_name:
                  type: string
                  description: Human-readable name of the calling client (e.g. "Claude Desktop").
                scope:
                  oneOf:
                    - type: string
                    - type: array
                      items: { type: string }
                  description: Space-separated or array of scopes. See `/mcp/about` for the full list.
      responses:
        '200':
          description: Device flow initiated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceCodeResponse'

  /mcp/oauth/token:
    post:
      summary: Poll for the access token (RFC 8628 grant)
      description: |
        Standard OAuth 2.0 device-code grant. Use `grant_type =
        urn:ietf:params:oauth:grant-type:device_code` with the `device_code` returned
        by `/mcp/oauth/device`. Polling responses use the standard error codes
        `authorization_pending`, `slow_down` (not implemented), `access_denied`,
        `expired_token`.
      tags: [OAuth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_code]
              properties:
                device_code: { type: string }
                grant_type: { type: string, example: 'urn:ietf:params:oauth:grant-type:device_code' }
      responses:
        '200':
          description: Access token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string, example: 'lyra_a1b2c3...' }
                  token_type: { type: string, example: 'Bearer' }
                  scope: { type: string }
                  expires_in: { type: integer, nullable: true, description: 'Seconds until token expiry (typically 31536000 = 1 year).' }
        '400':
          description: Authorization pending, expired, or denied
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, enum: [authorization_pending, expired_token, access_denied, invalid_grant, invalid_request, unsupported_grant_type, server_error] }
                  error_description: { type: string }

  /mcp/oauth/revoke:
    post:
      summary: Revoke an access token or license key
      tags: [OAuth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [token]
                  properties:
                    token: { type: string, description: 'The Bearer token to revoke (self-service, no auth required).' }
                - type: object
                  required: [license_key_id]
                  properties:
                    license_key_id: { type: string, description: 'License key id (requires the user session/token to match).' }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  revoked: { type: integer }

  /mcp:
    post:
      summary: Model Context Protocol endpoint
      description: |
        JSON-RPC 2.0 over HTTP. Implements MCP protocol version 2025-06-18.
        Server version 0.2.0.

        Supports `initialize`, `tools/list`, `tools/call`, `ping`.

        **Public tools (no auth):**
        - `list_blocked_domains` — official block list
        - `list_clean_brands` — Clean curated brands
        - `check_domain` — is this domain on either list?
        - `suggest_alternatives` — N Clean alternatives by category
        - `get_stats` — aggregate counts
        - `search_with_lyra` — DDG/Google URL with -site: filters
        - `get_install_links` — Chrome / Edge / Safari / Android / iOS links
        - `get_share_message` — ready-to-paste social copy

        **Authenticated tools (Bearer license key required):**
        - `submit_block_domain` — propose a domain to block (queued)
        - `submit_clean_brand` — propose a Clean brand
        - `list_personal` — your personal block + Clean lists
        - `add_personal_block` / `remove_personal_block`
        - `add_personal_clean` / `remove_personal_clean`

        Conceptual documentation: /mcp/about
      tags: [MCP]
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/JsonRpcRequest'
      responses:
        '200':
          description: JSON-RPC response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JsonRpcResponse'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: 'lyra_<48-hex>'
      description: License key issued at /billing
    sessionCookie:
      type: apiKey
      in: cookie
      name: better-auth.session_token
      description: Session cookie from the Lyra web app

  parameters:
    Locale:
      in: query
      name: locale
      schema:
        type: string
        enum: [en, pt-BR, es, zh-CN, zh-TW, hi, ar, fr, bn, ru, id, ur, de, ja, vi, tr, ko, it, pl, nl, he]
        default: en
      description: BCP-47 locale code. Falls back to Accept-Language header, then to `en`.

  responses:
    BadRequest:
      description: Invalid input
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid Bearer license key / session
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UpstreamError:
      description: Upstream list fetch failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
      required: [error]

    BlockedDomain:
      type: object
      properties:
        domain: { type: string }
        category: { type: string, enum: [fast-fashion, marketplace, dropshipping, other] }
        categoryLabel: { type: string }
        added_at: { type: string, format: date }

    CleanBrand:
      type: object
      properties:
        domain: { type: string }
        name: { type: string }
        category: { type: string }
        categoryLabel: { type: string }
        country: { type: string, nullable: true }
        description: { type: string, nullable: true }

    BlockedListResponse:
      type: object
      properties:
        version: { type: integer }
        updated_at: { type: string, format: date-time }
        locale: { type: string }
        count: { type: integer }
        domains:
          type: array
          items: { $ref: '#/components/schemas/BlockedDomain' }

    CleanListResponse:
      type: object
      properties:
        version: { type: integer }
        updated_at: { type: string, format: date-time }
        locale: { type: string }
        count: { type: integer }
        brands:
          type: array
          items: { $ref: '#/components/schemas/CleanBrand' }

    CheckResponse:
      type: object
      properties:
        domain: { type: string }
        locale: { type: string }
        blocked:
          type: object
          properties:
            isBlocked: { type: boolean }
            category: { type: string, nullable: true }
            categoryLabel: { type: string, nullable: true }
        clean:
          type: object
          properties:
            isClean: { type: boolean }
            name: { type: string, nullable: true }
            category: { type: string, nullable: true }
            categoryLabel: { type: string, nullable: true }
            country: { type: string, nullable: true }
            description: { type: string, nullable: true }

    SuggestResponse:
      type: object
      properties:
        locale: { type: string }
        queryDomain: { type: string, nullable: true }
        queryCategory: { type: string }
        queryCategoryLabel: { type: string }
        count: { type: integer }
        suggestions:
          type: array
          items: { $ref: '#/components/schemas/CleanBrand' }

    StatsResponse:
      type: object
      properties:
        locale: { type: string }
        blockList:
          type: object
          properties:
            version: { type: integer }
            updated_at: { type: string }
            total: { type: integer }
            byCategory: { type: object, additionalProperties: { type: integer } }
        cleanList:
          type: object
          properties:
            version: { type: integer }
            updated_at: { type: string }
            total: { type: integer }
            byCategory: { type: object, additionalProperties: { type: integer } }
            byCountry: { type: object, additionalProperties: { type: integer } }

    SearchResponse:
      type: object
      properties:
        locale: { type: string }
        engine: { type: string, enum: [ddg, google] }
        query: { type: string }
        blockedCount: { type: integer, description: 'Number of -site: filters applied' }
        fullQuery: { type: string }
        searchUrl: { type: string, format: uri }

    InstallResponse:
      type: object
      properties:
        locale: { type: string }
        channels:
          type: array
          items:
            type: object
            properties:
              id: { type: string, example: chrome }
              name: { type: string }
              url: { type: string, format: uri }
              status: { type: string, enum: [available, soon, planned] }

    ShareResponse:
      type: object
      properties:
        locale: { type: string }
        messages:
          type: object
          properties:
            short: { type: string }
            long: { type: string }
            hashtags: { type: string }
        intents:
          type: object
          additionalProperties: { type: string, format: uri }

    SubmitRequest:
      type: object
      required: [list, domain]
      properties:
        list: { type: string, enum: [block, clean] }
        domain: { type: string }
        reason: { type: string }
        name: { type: string, description: 'Required when list=clean' }
        category: { type: string }
        country: { type: string }
        description: { type: string }

    SubmitResponse:
      type: object
      properties:
        ok: { type: boolean }
        submissionId: { type: string }
        status: { type: string, enum: [queued] }

    PersonalListsResponse:
      type: object
      properties:
        block:
          type: array
          items:
            type: object
            properties:
              domain: { type: string }
              addedAt: { type: string, format: date-time }
        clean:
          type: array
          items:
            type: object
            properties:
              domain: { type: string }
              addedAt: { type: string, format: date-time }

    PersonalAddRequest:
      type: object
      required: [list, domain]
      properties:
        list: { type: string, enum: [block, clean] }
        domain: { type: string }

    JsonRpcRequest:
      type: object
      properties:
        jsonrpc: { type: string, enum: ['2.0'] }
        id: { oneOf: [{ type: string }, { type: integer }] }
        method: { type: string }
        params: { type: object }
      required: [jsonrpc, method]

    JsonRpcResponse:
      type: object
      properties:
        jsonrpc: { type: string, enum: ['2.0'] }
        id: { oneOf: [{ type: string }, { type: integer }, { type: 'null' }] }
        result: {}
        error:
          type: object
          properties:
            code: { type: integer }
            message: { type: string }
            data: {}

    QuotaStatus:
      type: object
      properties:
        used: { type: integer, example: 17 }
        limit: { type: integer, example: 100 }
        remaining: { type: integer, example: 83 }
        resetsAt: { type: integer, format: int64, example: 1746518400, description: 'Unix epoch seconds (UTC) when the daily quota resets.' }

    DeviceCodeResponse:
      type: object
      required: [device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval]
      properties:
        device_code: { type: string }
        user_code: { type: string, example: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', description: 'Opaque hex handle that lives in the verification URL. Never shown to the user separately.' }
        verification_uri: { type: string, format: uri, example: 'https://lyrasearch.com/mcp/auth/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', description: 'Same as verification_uri_complete in this implementation; we do not split short and complete URIs because the user never types a code.' }
        verification_uri_complete: { type: string, format: uri, example: 'https://lyrasearch.com/mcp/auth/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' }
        expires_in: { type: integer, example: 600 }
        interval: { type: integer, example: 5 }

tags:
  - name: Lists
    description: Curated block and Clean lists (auth required)
  - name: Lookup
    description: Per-domain checks and suggestions (auth required)
  - name: Search
    description: Filtered search URL builder (auth required)
  - name: Distribution
    description: Install and share helpers (auth required)
  - name: Submit
    description: Community contributions (auth required)
  - name: Personal
    description: User's personal block + Clean lists (auth required)
  - name: Keys
    description: License key management
  - name: Auth
    description: Quota and authentication helpers
  - name: OAuth
    description: Device Authorization Flow (RFC 8628) for AI assistants
  - name: MCP
    description: Model Context Protocol endpoint for AI agents
