openapi: 3.1.0
info:
  title: Biblon Catalog API
  version: 1.0.0
  description: |
    Book catalog API for ValueBooks' Honya SaaS platform.
    Biblon is the authoritative source for book and product master data —
    bibliographic metadata, pricing, classification, and cover images.

    ## Authentication
    All `/api/*` endpoints require a bearer token:

        Authorization: Bearer biblon_<env>_<random>

    Keys are issued per consumer by the ValueBooks platform team. Each
    service stores its own key in its own secret store; there is no
    shared password and no rendered local spec file.

    ### Dev environment

    Shared key for the `biblon-dev.valuebooks.jp` server (dev only — do
    not reuse in prod):

        biblon_dev_4CHx0rK5IpsyVF8oM1QTShTwgELcY--d

    ## Catalog ID
    Each edition has a `catalogId` — a `VC` prefix followed by 8 lowercase
    alphanumeric characters (e.g., `VC4f7kx2q9`). This is the stable domain
    identifier for cross-service references.
  contact:
    name: ValueBooks Engineering
  license:
    name: Proprietary

servers:
  - url: https://biblon.valuebooks.jp
    description: Production
  - url: https://biblon-dev.valuebooks.jp
    description: Dev environment
  - url: http://localhost:8001
    description: Local development

security:
  - bearerAuth: []

paths:
  /health:
    get:
      operationId: getHealth
      summary: Liveness check
      security: []
      tags: [Health]
      responses:
        "200":
          description: API is alive
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    const: ok
              example:
                status: ok

  /api/books/search:
    get:
      operationId: searchBooks
      summary: Full-text search with filters
      description: |
        Japanese full-text search via OpenSearch with kuromoji tokenizer.
        Searches across title (3x boost), author (2x boost), publisher,
        description, and ISBN. Supports filtering by author, publisher, and date range, with pagination.
        At least one of `q` or a filter parameter is required.

        When OpenSearch returns no results for a query-only search (no filters),
        the API falls back to external providers: OpenBD (ISBN only) → Google Books
        (ISBN and free-text). Results from external providers have `source` set to
        `openbd` or `google_books` and an empty `catalogId`.

        Example — upcoming releases (発売前新刊), nearest first:
          `GET /api/books/search?published_from=2026-05-13&sort=published_date:asc&limit=20`
      tags: [Books]
      parameters:
        - name: q
          in: query
          required: false
          description: Free-text search query (Japanese or English)
          schema:
            type: string
          example: 夏目漱石
        - name: publisher
          in: query
          required: false
          description: Filter by publisher (full-text match via kuromoji)
          schema:
            type: string
          example: 筑摩書房
        - name: author
          in: query
          required: false
          description: Filter by author (full-text match via kuromoji)
          schema:
            type: string
          example: 上野千鶴子
        - name: published_from
          in: query
          required: false
          description: |
            Filter by publication date (inclusive start, ISO 8601 YYYY-MM-DD).

            Usage patterns (from issue #160):
              - Single date:    published_from=X&published_to=X
              - Range:          published_from=X&published_to=Y
              - Before X:       published_to=X
              - After X (incl. pre-release 発売前新刊): published_from=X
                (combine with sort=published_date:asc for "nearest first")
          schema:
            type: string
            format: date
          example: "2025-01-01"
        - name: published_to
          in: query
          required: false
          description: Filter by publication date (inclusive end, ISO 8601)
          schema:
            type: string
            format: date
          example: "2025-12-31"
        - name: sort
          in: query
          required: false
          description: |
            Sort key. Records with null `published_date` are placed last
            regardless of direction. When omitted, results are returned in
            relevance order (existing behavior).
          schema:
            type: string
            enum: [published_date:asc, published_date:desc]
          example: published_date:asc
        - name: offset
          in: query
          required: false
          description: |
            Pagination offset.
            The OpenSearch result window caps `offset + limit` at 10000;
            requests exceeding that bound return 400.
          schema:
            type: integer
            minimum: 0
            default: 0
          example: 0
        - name: limit
          in: query
          required: false
          description: Results per page (max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          example: 20
        - in: query
          name: vb
          required: false
          description: |
            When `1`, enrich the response with Value Books market data
            (sales price, buyback assessment, stock). Returns `vb: null`
            on ACL miss or failure (per issue #135).
          schema:
            type: string
            enum: ["1", "true"]
      responses:
        "200":
          description: Paginated search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
              example:
                results:
                  - catalogId: VC4f7kx2q9
                    isbn: "9784003101018"
                    title: 吾輩は猫である
                    author: 夏目 漱石
                    publisher: 岩波書店
                    year: 2004
                    pages: 592
                    price: 660
                    coverUrl: https://cdn.valuebooks.jp/covers/9784003101018.jpg
                    description: 中学教師珍野苦沙弥先生の書斎に集まる...
                    cCode: C0193
                    source: jpro
                    score: 12.45
                total: 342
                offset: 0
                limit: 20
        "400":
          $ref: "#/components/responses/BadRequest"
        "503":
          description: Search service unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/books/search/theme:
    get:
      operationId: searchByTheme
      summary: Natural-language theme search (hybrid BM25 + kNN, MMR-diversified)
      description: |
        Semantic search optimized for selecting books by feeling, theme, and
        intended audience. Uses Cohere multilingual embeddings fused with the
        existing kuromoji BM25 query via OpenSearch RRF. When the embedding
        model is unavailable, the response carries `degraded: true` and falls
        back to BM25-only.

        Diversity reranking (MMR + per-author/c_code caps) applies to the first
        page only (`offset=0`). Use `diversity=0` to disable.

        Examples of queries this handles well:
        - "夏に読みたい少し切ない短編"
        - "中学生向けで読みやすいSF"
        - "最近の落ち着いたエッセイ"
      tags: [Books]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
        - { name: published_from, in: query, schema: { type: string, format: date } }
        - { name: published_to,   in: query, schema: { type: string, format: date } }
        - name: sort
          in: query
          description: |
            Sort key. When omitted, hybrid (BM25 + kNN with RRF) ranking
            and MMR diversity are preserved. When set, runs a BM25-only
            query ordered by the sort field — hybrid ranking and MMR
            diversity are skipped because the user has explicitly chosen
            an order. Records with null `published_date` are placed last
            regardless of direction.
          schema:
            type: string
            enum: [published_date:asc, published_date:desc]
        - { name: category,         in: query, schema: { type: string, pattern: '^\d{1,4}$' } }
        - { name: exclude_category, in: query, schema: { type: string, pattern: '^\d{1,4}$' } }
        - { name: publisher, in: query, schema: { type: string } }
        - { name: author,    in: query, schema: { type: string } }
        - { name: limit,  in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
        - name: offset
          in: query
          description: |
            Pagination offset.
            The OpenSearch result window caps `offset + limit` at 10000;
            requests exceeding that bound return 400.
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: diversity
          in: query
          description: |
            0.0 = pure relevance (no MMR), 1.0 = pure diversity. Default 0.5.
            `lambda` is accepted as an alias; if both supplied, `diversity` wins.
          schema: { type: number, minimum: 0, maximum: 1, default: 0.5 }
        - { name: max_per_author, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 2 } }
        - { name: max_per_c_code, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 2 } }
      responses:
        "200":
          description: Hybrid search results with diversity reranking and refinement hints.
          content:
            application/json:
              schema:
                type: object
                required: [results, total, offset, limit, degraded]
                properties:
                  results:  { type: array, items: { $ref: '#/components/schemas/ThemeResult' } }
                  total:    { type: integer }
                  offset:   { type: integer }
                  limit:    { type: integer }
                  degraded: { type: boolean }
                  reason:   { type: string }
                  suggested_refinements:
                    type: array
                    items: { $ref: '#/components/schemas/Refinement' }

  /api/books/search/facets:
    get:
      operationId: searchFacets
      summary: Facet aggregations for a theme query (with refinement hints)
      tags: [Books]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
        - { name: category,         in: query, schema: { type: string, pattern: '^\d{1,4}$' } }
        - { name: exclude_category, in: query, schema: { type: string, pattern: '^\d{1,4}$' } }
        - { name: publisher, in: query, schema: { type: string } }
        - { name: author, in: query, schema: { type: string } }
        - { name: published_from, in: query, schema: { type: string, format: date } }
        - { name: published_to,   in: query, schema: { type: string, format: date } }
      responses:
        "200":
          description: Aggregation buckets and suggested refinements.
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  facets:
                    type: object
                    properties:
                      publisher:  { type: array, items: { $ref: '#/components/schemas/FacetBucket' } }
                      c_code_top: { type: array, items: { $ref: '#/components/schemas/FacetBucket' } }
                      decade:     { type: array, items: { $ref: '#/components/schemas/FacetBucket' } }
                      price_band: { type: array, items: { $ref: '#/components/schemas/FacetBucket' } }
                  suggested_refinements:
                    type: array
                    items: { $ref: '#/components/schemas/Refinement' }

  /api/books/trending:
    get:
      operationId: getTrendingBooks
      summary: Trending books by signal × period × category
      tags: [Books]
      parameters:
        - { name: period,   in: query, schema: { type: string, enum: [day, week, month, year], default: week } }
        - { name: signal,   in: query, schema: { type: string, enum: [amazon_rank, new_release, newly_added], default: amazon_rank } }
        - { name: category, in: query, schema: { type: string, pattern: '^\d{1,4}$' } }
        - { name: limit,    in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
      responses:
        "200":
          description: Top-N for the given period × category × signal.
          content:
            application/json:
              schema:
                type: object
                properties:
                  period:  { type: string }
                  signal:  { type: string }
                  results: { type: array, items: { $ref: '#/components/schemas/ThemeResult' } }

  /api/books/related:
    get:
      operationId: findRelatedBooks
      summary: Find related books by relation type
      description: |
        Catalog-ID lookup uses the `catalog` query parameter, mirroring the
        existing `GET /api/books?catalog=` convention. Diversity reranking only
        applies when `relation=similar_theme`; for `same_author` and
        `same_category` the diversity parameters are accepted but ignored.
      tags: [Books]
      parameters:
        - { name: catalog,  in: query, required: true, schema: { type: string, pattern: '^VC[0-9a-z]{8}$' } }
        - { name: relation, in: query, schema: { type: string, enum: [similar_theme, same_author, same_category], default: similar_theme } }
        - { name: limit,    in: query, schema: { type: integer, minimum: 1, maximum: 20, default: 10 } }
        - name: diversity
          in: query
          description: 0.0 pure relevance, 1.0 pure diversity. Applies only when relation=similar_theme. Default 0.5. (alias `lambda`).
          schema: { type: number, minimum: 0, maximum: 1, default: 0.5 }
        - { name: max_per_author, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 2 } }
        - { name: max_per_c_code, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 2 } }
      responses:
        "200":
          description: Related books for the given relation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  relation: { type: string }
                  seed:
                    type: object
                    properties:
                      catalogId: { type: string }
                      title:     { type: string }
                  results: { type: array, items: { $ref: '#/components/schemas/ThemeResult' } }
        "400":
          description: Missing `catalog` query parameter or unknown `relation`.
        "404":
          description: Seed catalog ID not found.

  /api/books:
    get:
      operationId: getBookByIdentifier
      summary: Lookup by ISBN or catalog ID
      description: |
        Find a book by ISBN-13 or catalog ID. Provide exactly one of `isbn` or `catalog`.
        ISBN lookup checks PostgreSQL first, then falls back to OpenBD and Google Books.
        Catalog ID lookup checks PostgreSQL only.
      tags: [Books]
      parameters:
        - name: isbn
          in: query
          required: false
          description: ISBN-13 (e.g., 9784003101018)
          schema:
            type: string
            pattern: "^[0-9]{13}$"
          example: "9784003101018"
        - name: catalog
          in: query
          required: false
          description: Catalog ID (VC + 8 alphanumeric chars, e.g., VC4f7kx2q9)
          schema:
            type: string
            pattern: "^VC[a-z0-9]{8}$"
          example: "VC4f7kx2q9"
        - in: query
          name: vb
          required: false
          description: |
            When `1`, enrich the response with Value Books market data
            (sales price, buyback assessment, stock). Returns `vb: null`
            on ACL miss or failure (per issue #135).
          schema:
            type: string
            enum: ["1", "true"]
      responses:
        "200":
          description: Book found (Book from catalog, or SearchResult from external fallback)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Book"
                  - $ref: "#/components/schemas/SearchResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

    post:
      operationId: upsertBook
      summary: Upsert a book
      description: |
        Create or update a book. Deduplicates by ISBN (if provided and valid),
        then by title + author. Returns 201 if created, 200 if existing found.
      tags: [Books]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBookInput"
            example:
              isbn: "9784003101018"
              title: 吾輩は猫である
              author: 夏目 漱石
              publisher: 岩波書店
              year: 2004
              pages: 592
              price: 660
      responses:
        "200":
          description: Existing book found (deduplicated)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
        "201":
          description: Book created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/books/batch:
    post:
      operationId: getBooksBatch
      summary: Batch get by ISBNs
      description: |
        Retrieve multiple books by their ISBNs in a single request.
      tags: [Books]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [isbns]
              properties:
                isbns:
                  type: array
                  items:
                    type: string
                    pattern: "^97[89]\\d{10}$"
                  description: List of ISBN-13 values
            example:
              isbns:
                - "9784101010014"
                - "9784101010021"
      responses:
        "200":
          description: Books found (missing ISBNs are silently omitted)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Book"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/books/{id}:
    get:
      operationId: getBookById
      summary: Lookup by UUID
      description: |
        Find a book by its internal PostgreSQL UUID.
      tags: [Books]
      parameters:
        - name: id
          in: path
          required: true
          description: Internal UUID
          schema:
            type: string
            format: uuid
          example: 550e8400-e29b-41d4-a716-446655440000
      responses:
        "200":
          description: Book found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/isbn/validate:
    get:
      operationId: validateIsbn
      summary: Validate an ISBN
      description: |
        Validate an ISBN string and detect its format (ISBN-10 or ISBN-13).
        Normalizes the input by removing hyphens and spaces.
      tags: [ISBN]
      parameters:
        - name: isbn
          in: query
          required: true
          description: ISBN to validate (hyphens allowed)
          schema:
            type: string
          example: "978-4-00-310101-8"
      responses:
        "200":
          description: Validation result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IsbnValidationResult"
              examples:
                valid:
                  summary: Valid ISBN-13
                  value:
                    valid: true
                    format: isbn13
                    normalized: "9784003101018"
                invalid:
                  summary: Invalid ISBN
                  value:
                    valid: false
                    format: null
                    normalized: null
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/isbn/convert:
    get:
      operationId: convertIsbn10to13
      summary: Convert ISBN-10 to ISBN-13
      description: |
        Convert a valid ISBN-10 to its ISBN-13 equivalent.
      tags: [ISBN]
      parameters:
        - name: isbn10
          in: query
          required: true
          description: ISBN-10 to convert
          schema:
            type: string
            pattern: "^[0-9]{9}[0-9X]$"
          example: "4003101014"
      responses:
        "200":
          description: Converted ISBN
          content:
            application/json:
              schema:
                type: object
                required: [isbn13]
                properties:
                  isbn13:
                    type: string
                    description: The ISBN-13 equivalent
              example:
                isbn13: "9784003101018"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/admin/stats:
    get:
      operationId: getAdminStats
      summary: Catalog statistics
      description: |
        Returns total book count, per-source breakdown, and OpenSearch index stats.
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: Statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalBooks:
                    type: integer
                  booksBySource:
                    type: array
                    items:
                      type: object
                      properties:
                        source:
                          type: string
                        count:
                          type: integer
                        latest:
                          type: [string, "null"]
                          format: date-time
                  opensearch:
                    type: object
                    properties:
                      index:
                        type: string
                      docCount:
                        type: integer
                      storeSize:
                        type: string

  /api/admin/etl-runs:
    get:
      operationId: getEtlRuns
      summary: Recent ETL runs
      description: |
        Returns the 20 most recent ETL pipeline runs.
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: ETL run history
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      format: uuid
                    source:
                      type: string
                    status:
                      type: string
                    startedAt:
                      type: string
                      format: date-time
                    finishedAt:
                      type: [string, "null"]
                      format: date-time
                    recordsProcessed:
                      type: integer
                    errorMessage:
                      type: [string, "null"]

  /api/admin/health:
    get:
      operationId: getAdminHealth
      summary: Deep health check
      description: |
        Checks connectivity to PostgreSQL, OpenSearch, and API.
        Returns 503 if any dependency is disconnected.
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: All dependencies connected
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    status:
                      type: string
                      enum: [connected, disconnected]
                    responseMs:
                      type: integer
              example:
                postgresql:
                  status: connected
                  responseMs: 3
                opensearch:
                  status: connected
                  responseMs: 12
                api:
                  status: connected
                  responseMs: 0
        "503":
          description: One or more dependencies disconnected

  /api/admin/kpi/coverage:
    get:
      operationId: getCoverageKpi
      summary: Catalog coverage KPI
      description: |
        Computes catalog coverage breadth (edition counts, coverage rate vs NDL)
        and depth (metadata completeness scores, field fill rates).
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: Coverage KPI metrics
          content:
            application/json:
              schema:
                type: object
                properties:
                  breadth:
                    type: object
                    properties:
                      totalEditions:
                        type: integer
                      bySource:
                        type: object
                        properties:
                          jpro:
                            type: integer
                          nippan:
                            type: integer
                          both:
                            type: integer
                      newThisMonth:
                        type: integer
                      newLastMonth:
                        type: integer
                      coverageRate:
                        type: object
                        properties:
                          ndlTotal:
                            type: integer
                          overallPct:
                            type: number
                          jproContributionPct:
                            type: number
                          nippanContributionPct:
                            type: number
                          gap:
                            type: integer
                  depth:
                    type: object
                    properties:
                      overallScore:
                        type: number
                      scoreBySource:
                        type: object
                        properties:
                          jpro:
                            type: number
                          nippan:
                            type: number
                      fieldFillRates:
                        type: object
                        additionalProperties:
                          type: number
                  computedAt:
                    type: string
                    format: date-time

  /api/admin/api-clients:
    post:
      summary: Mint a new per-consumer API key
      description: |
        Creates a new `api_clients` row and returns the plaintext key ONCE.
        The plaintext is never stored or retrievable later.
      operationId: mintApiClient
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 64
                  pattern: "^[a-zA-Z0-9._-]+$"
                  example: dev-store
                isAdmin:
                  type: boolean
                  default: false
      responses:
        "201":
          description: API key minted; plaintext shown only in this response.
          content:
            application/json:
              schema:
                type: object
                required: [id, name, keyPrefix, plaintextKey, isAdmin, createdBy, createdAt]
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  keyPrefix:
                    type: string
                    example: biblon_dev_ab12
                  plaintextKey:
                    type: string
                    description: Bearer token; never returned again.
                  isAdmin:
                    type: boolean
                  createdBy:
                    type: string
                    description: Server-derived from the minting client's name.
                  createdAt:
                    type: string
                    format: date-time
        "400":
          description: Validation error
        "401":
          description: Missing or invalid bearer token
        "403":
          description: Bearer token is not an admin key
    get:
      summary: List active API keys
      description: Returns non-revoked clients. Never includes hashes or plaintext.
      operationId: listApiClients
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: Active clients.
          content:
            application/json:
              schema:
                type: object
                required: [clients]
                properties:
                  clients:
                    type: array
                    items:
                      type: object
                      required: [id, name, keyPrefix, createdBy, createdAt, lastUsedAt, isAdmin]
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        keyPrefix: { type: string, example: biblon_dev_ab12 }
                        createdBy: { type: string }
                        createdAt: { type: string, format: date-time }
                        lastUsedAt: { type: [string, "null"], format: date-time }
                        isAdmin: { type: boolean }
        "401": { description: Missing or invalid credentials }
        "403": { description: Caller is not an admin }

  /api/admin/api-clients/{prefix}:
    delete:
      summary: Revoke an active API key
      operationId: revokeApiClient
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: prefix
          in: path
          required: true
          schema:
            type: string
            pattern: "^biblon_(dev|prod)_[A-Za-z0-9_-]{4,5}$"
          example: biblon_dev_ab12
      responses:
        "204": { description: Revoked. }
        "400": { description: Malformed prefix. }
        "401": { description: Missing or invalid credentials. }
        "403": { description: Caller is not an admin. }
        "404": { description: No active key matches the prefix. }

  /api/auth/login:
    post:
      summary: Log in with email + password
      operationId: authLogin
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 12 }
      responses:
        "200":
          description: Session cookie set
          headers:
            Set-Cookie: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      email: { type: string }
                      name: { type: string }
                  mustChangePassword: { type: boolean }
        "401": { description: Invalid credentials }
        "429": { description: Too many attempts — wait 15 minutes }
  /api/auth/logout:
    post:
      summary: Revoke current session and clear cookie
      operationId: authLogout
      tags: [Auth]
      security:
        - cookieAuth: []
      responses:
        "204": { description: Logged out }
  /api/auth/me:
    get:
      summary: Return current session actor
      operationId: authMe
      tags: [Auth]
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Actor info
          content:
            application/json:
              schema:
                type: object
                properties:
                  kind: { type: string, enum: [user] }
                  id: { type: string, format: uuid }
                  email: { type: string }
                  name: { type: string }
                  mustChangePassword: { type: boolean }
        "401": { description: Not signed in }

  /api/auth/change-password:
    post:
      summary: Change the authenticated user's password.
      description: |
        Session-cookie auth only. Verifies `currentPassword` then sets the new
        password and clears `must_change_password`. Bearer auth returns 403.
      operationId: authChangePassword
      tags: [Auth]
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentPassword, newPassword]
              properties:
                currentPassword: { type: string }
                newPassword:
                  type: string
                  minLength: 12
      responses:
        "204": { description: Password updated. Session is preserved. }
        "400": { description: Validation error (length, equality with current). }
        "401": { description: Wrong current password or no session. }
        "403": { description: Caller is authenticated via Bearer, not session. }
        "429": { description: Too many failed attempts in the rolling window. }

  /api/admin/users:
    get:
      summary: List admin users (including disabled).
      operationId: listAdminUsers
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: OK.
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      type: object
                      required: [id, email, name, createdAt, createdBy, mustChangePassword]
                      properties:
                        id: { type: string, format: uuid }
                        email: { type: string }
                        name: { type: string }
                        createdAt: { type: string, format: date-time }
                        createdBy: { type: string }
                        lastLoginAt: { type: string, format: date-time, nullable: true }
                        disabledAt: { type: string, format: date-time, nullable: true }
                        mustChangePassword: { type: boolean }
        "401": { description: Authentication required. }
        "403": { description: Admin access required. }
    post:
      summary: Create an admin user with a server-generated temporary password.
      description: |
        The temporary password is returned exactly once in the response and is
        not stored in plaintext anywhere. The new user must change it on first
        login.
      operationId: createAdminUser
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, name]
              properties:
                email: { type: string }
                name:
                  type: string
                  minLength: 1
                  maxLength: 64
      responses:
        "201":
          description: Created. The body contains the only copy of `plaintextPassword`.
          content:
            application/json:
              schema:
                type: object
                required: [id, email, name, plaintextPassword, mustChangePassword]
                properties:
                  id: { type: string, format: uuid }
                  email: { type: string }
                  name: { type: string }
                  plaintextPassword: { type: string }
                  createdAt: { type: string, format: date-time }
                  createdBy: { type: string }
                  mustChangePassword: { type: boolean }
        "400": { description: Invalid email or name. }
        "409": { description: Email already exists. }

  /api/admin/users/{id}:
    delete:
      summary: Soft-disable a user. Revokes all of that user's sessions atomically.
      operationId: disableAdminUser
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Disabled. }
        "400": { description: Self-disable attempted, or malformed id. }
        "404": { description: Not found or already disabled. }

  /api/admin/users/{id}/reset-password:
    post:
      summary: Reset another user's password to a fresh server-generated temp password.
      operationId: resetAdminUserPassword
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Reset succeeded. Body holds the only copy of the new password.
          content:
            application/json:
              schema:
                type: object
                required: [plaintextPassword]
                properties:
                  plaintextPassword: { type: string }
        "400": { description: Self-reset attempted, or malformed id. }
        "404": { description: Not found or disabled. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: biblon_<env>_<random>
      description: |
        Per-consumer API key issued by the ValueBooks platform team.
        Send as `Authorization: Bearer biblon_<env>_<...>`. Contact
        platform@valuebooks to obtain a key.
    cookieAuth:
      type: apiKey
      in: cookie
      name: biblon_session
      description: |
        Session cookie issued by POST /api/auth/login. HttpOnly, Secure,
        SameSite=Lax. Used by the admin UI. Not used by service consumers.

  schemas:
    Book:
      type: object
      description: A book edition in the catalog
      required:
        - id
        - catalogId
        - isbn
        - title
        - author
        - publisher
        - coverUrl
        - description
        - cCode
        - language
        - source
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
          description: Internal PostgreSQL UUID
        catalogId:
          type: string
          pattern: "^VC[a-z0-9]{8}$"
          description: Domain identifier (VC + 8 alphanumeric chars)
          examples: [VC4f7kx2q9]
        isbn:
          type: string
          description: ISBN-13 (empty string if no ISBN)
        title:
          type: string
        author:
          type: string
        publisher:
          type: string
        year:
          type: [integer, "null"]
          description: Publication year
        publishedDate:
          type: [string, "null"]
          format: date
          description: Publication date (YYYY-MM-DD)
        pages:
          type: [integer, "null"]
          description: Page count
        coverUrl:
          type: string
          description: CDN URL for cover image
        description:
          type: string
          description: Book description or summary
        cCode:
          type: string
          description: Japanese C-code classification
        price:
          type: [integer, "null"]
          description: Price in JPY
        language:
          type: string
          description: ISO 639-2 language code (default "jpn")
        source:
          type: string
          enum: [jpro, nippan, api]
          description: Data origin
        amazonRank:
          type: [integer, "null"]
          description: Amazon.co.jp sales rank (lower = better seller). Null if not yet ranked.
        amazonRankCategory:
          type: [string, "null"]
          description: |
            Raw upstream category value for the rank. Typically
            "book_display_on_website" or a numeric Amazon browse-node id
            (e.g. "2278488051" for manga). Null if not ranked.
        amazonRankFetchedAt:
          type: [string, "null"]
          format: date-time
          description: |
            Timestamp when the rank was observed from Amazon by the upstream
            marketing-ETL pipeline (source column `prev_dtm`). Not the API's
            write time — see `updatedAt` for that. Null if not ranked.
        vb:
          oneOf:
            - $ref: "#/components/schemas/VbBlock"
            - { type: "null" }
          description: |
            Value Books market data. Present only when the request was made with
            `?vb=1`. Null on ACL miss or failure.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    SearchResult:
      type: object
      description: A search hit (from OpenSearch or external fallback providers)
      required:
        - catalogId
        - isbn
        - title
        - author
        - publisher
        - coverUrl
        - description
        - cCode
        - source
        - score
      properties:
        catalogId:
          type: string
          description: Domain identifier (empty string for external fallback results)
        isbn:
          type: string
        title:
          type: string
        author:
          type: string
        publisher:
          type: string
        year:
          type: [integer, "null"]
        publishedDate:
          type: [string, "null"]
          format: date
          description: Publication date (YYYY-MM-DD)
        pages:
          type: [integer, "null"]
        price:
          type: [integer, "null"]
          description: Price in JPY
        coverUrl:
          type: string
        description:
          type: string
        cCode:
          type: string
        source:
          type: string
          enum: [jpro, nippan, api, openbd, google_books]
          description: Data origin (openbd/google_books for external fallback results)
        score:
          type: number
          format: float
          description: BM25 relevance score (0 for external fallback results)
        amazonRank:
          type: [integer, "null"]
          description: Amazon.co.jp sales rank (lower = better seller). Null if not yet ranked.
        amazonRankCategory:
          type: [string, "null"]
          description: |
            Raw upstream category value for the rank. Typically
            "book_display_on_website" or a numeric Amazon browse-node id
            (e.g. "2278488051" for manga). Null if not ranked.
        amazonRankFetchedAt:
          type: [string, "null"]
          format: date-time
          description: |
            Timestamp when the rank was observed from Amazon by the upstream
            marketing-ETL pipeline (source column `prev_dtm`). Not the API's
            write time — see `updatedAt` for that. Null if not ranked.
        vb:
          oneOf:
            - $ref: "#/components/schemas/VbBlock"
            - { type: "null" }
          description: |
            Value Books market data. Present only when the request was made with
            `?vb=1`. Null on ACL miss or failure.

    ThemeResult:
      type: object
      description: |
        Compact projection of an edition for curation/selection use cases.
        Smaller than `SearchResult`: drops ISBN/price/source/Amazon-rank fields and
        replaces `c_code` with decoded `topTags`. Use `GET /api/books?catalog=`
        for full operational data on a chosen result.
      required: [catalogId, title, author, publisher, year, publishedDate, coverUrl, themeBlurb, topTags, score]
      properties:
        catalogId:     { type: string }
        title:         { type: string }
        author:        { type: string }
        publisher:     { type: string }
        year:          { type: [integer, "null"] }
        publishedDate: { type: [string, "null"], format: date }
        coverUrl:      { type: string }
        themeBlurb:    { type: string }
        topTags:       { type: array, items: { type: string } }
        score:         { type: number }

    Refinement:
      type: object
      description: A suggested next-step filter the caller can re-post.
      required: [label, filter]
      properties:
        label:
          type: string
          description: Human-readable Japanese label, e.g. "新潮社のみ" or "1990年代に絞る".
        filter:
          type: object
          additionalProperties: true
          description: |
            Subset of the same query parameters this endpoint accepts (camelCase
            keys: `publisher`, `publishedFrom`, `publishedTo`, `excludeCategory`).
            Re-post directly as the next request's query string after converting
            keys to snake_case (`published_from`, `exclude_category`).

    SearchResponse:
      type: object
      description: Paginated search results
      required:
        - results
        - total
        - offset
        - limit
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/SearchResult"
        total:
          type: integer
          description: Total number of matching results
        offset:
          type: integer
          description: Current pagination offset
        limit:
          type: integer
          description: Results per page

    CreateBookInput:
      type: object
      description: Input for creating or upserting a book
      required: [title, author]
      properties:
        isbn:
          type: string
          pattern: "^[0-9]{13}$"
          description: ISBN-13 (validated with mod-10 checksum)
        title:
          type: string
        author:
          type: string
        publisher:
          type: string
        year:
          type: integer
        pages:
          type: integer
        coverUrl:
          type: string
        description:
          type: string
        cCode:
          type: string
        price:
          type: integer
          description: Price in JPY
        language:
          type: string
          description: ISO 639-2 language code (defaults to "jpn")

    IsbnValidationResult:
      type: object
      required: [valid]
      properties:
        valid:
          type: boolean
        format:
          type: [string, "null"]
          enum: [isbn10, isbn13, null]
        normalized:
          type: [string, "null"]
          description: Normalized ISBN (hyphens removed) if valid

    VbPrice:
      type: object
      required: [condition, price]
      properties:
        condition: { type: string, description: "Raw vs_data condition keyword" }
        price: { type: integer, minimum: 0 }

    VbBuybackRange:
      type: object
      required: [min, max]
      properties:
        min: { type: integer, minimum: 0 }
        max: { type: integer, minimum: 0 }

    VbBlock:
      type: object
      description: |
        Value Books market data, populated when the API was called with `?vb=1`.
        Sourced via the VB ACL service. Null when the ACL responded with a miss
        or failure (per issue #135's null-on-failure rule).

        **Data semantics** (based on the VB OpenSearch `vs_data` source):
        - `available` is `true` only when the source flag `is_sell="1"` AND
          `stock_count > 0`. A non-zero `stock_count` with `available: false`
          means VB has inventory but the title is not currently configured
          for sale (e.g., buyback-only, hold).
        - `is_original` is often `null` for backlist titles where VB has not
          recorded the original/reprint distinction.
        - `buyback_price_range` is the per-condition assessment spread
          (min / max over `assess.assessed_price_list[].price`). It can
          show non-zero bounds (e.g. `{"min": 0, "max": 10}`) while
          `buyback_available: false` / `buyback_price: null` — because the
          headline comes from `assess.reference_assessed_price`, which VB
          can set to 0 even when individual conditions assess positively.
          Use `buyback_available` / `buyback_price` as the "will VB buy
          this back today" answer; use `buyback_price_range` to show the
          per-condition picture.
      required:
        - vs_catalog_id
        - product_url
        - is_original
        - available
        - stock_count
        - lowest_price
        - highest_price
        - prices
        - buyback_available
        - buyback_price
        - buyback_price_range
        - buyback_calculated_at
      properties:
        vs_catalog_id:
          type: string
          description: VB-side catalog identifier (`VS` + digits).
        product_url:
          type: string
          format: uri
          description: Canonical VB consumer URL for this title.
        is_original:
          oneOf: [{ type: boolean }, { type: "null" }]
          description: Whether the edition is the original release (vs. a reprint). Often null.
        available:
          type: boolean
          description: True iff the source has `is_sell="1"` AND `stock_count > 0`.
        stock_count:
          oneOf: [{ type: integer, minimum: 0 }, { type: "null" }]
          description: Active inventory count. Null when the source has no `genpin` array.
        lowest_price:
          oneOf: [{ type: integer, minimum: 0 }, { type: "null" }]
          description: Lowest current sell price in JPY. Null when no priced inventory.
        highest_price:
          oneOf: [{ type: integer, minimum: 0 }, { type: "null" }]
          description: Highest current sell price in JPY. Null when no priced inventory.
        prices:
          type: array
          description: Per-condition price points (`U20`/`U30`/`U40` are VB grading codes).
          items: { $ref: "#/components/schemas/VbPrice" }
        buyback_available:
          type: boolean
          description: True iff VB will buy this title back today (mirrors `buyback_price !== null`).
        buyback_price:
          oneOf: [{ type: integer, minimum: 0 }, { type: "null" }]
          description: Headline buyback offer in JPY. Null when no offer.
        buyback_price_range:
          oneOf: [{ $ref: "#/components/schemas/VbBuybackRange" }, { type: "null" }]
          description: Min/max from VB's per-condition assessed prices. See data-semantics note.
        buyback_calculated_at:
          oneOf: [{ type: string, format: date-time }, { type: "null" }]
          description: UTC timestamp of the most recent buyback assessment.

    FacetBucket:
      type: object
      required: [value, count]
      properties:
        value: { type: string }
        count: { type: integer }
        label: { type: string }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message

  responses:
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "q query parameter required"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Not found"

tags:
  - name: Health
    description: Liveness and readiness checks
  - name: Books
    description: Book catalog CRUD and search
  - name: ISBN
    description: ISBN validation and conversion utilities
  - name: Admin
    description: Admin operations and KPIs
  - name: Auth
    description: Admin user authentication (session cookie)
