openapi: 3.0.3
info:
  title: FinelySourced Chrome Extension API
  version: 1.0.0
  description: |
    Sanctum Bearer token-authenticated endpoints for the FinelySourced Chrome extension.
    Notes:
    - A fresh extension can provision a scoped anonymous token without signup.
    - POST /api/scans is limited to 12/minute and 120/hour per token, plus 30/minute per IP.
    - Scan reads and similar-product reads are limited to 45/minute and 600/hour per token, plus 120/minute per IP.
    - GET /api/products/similar uses Typesense via Scout when configured; falls back to a bounded, cached DB search otherwise.
servers:
  - url: https://finelysourced.com
    description: Production
  - url: http://finelysourced.test
    description: Local
tags:
  - name: Scans
  - name: Products
security:
  - bearerAuth: []
paths:
  /api/extension/anonymous-token:
    post:
      tags: [Auth]
      security: []
      summary: Provision a zero-signup extension token
      description: |
        Creates a flagged anonymous user and returns a scoped scans:write/scans:read
        Sanctum token. Limited to 5 requests per IP per day. The token expires after
        30 days by default.
      responses:
        '201':
          description: Anonymous token created
          headers:
            Cache-Control:
              description: The response must not be cached.
              schema: { type: string, example: "private, no-store" }
          content:
            application/json:
              schema:
                type: object
                required: [token, expires_at]
                properties:
                  token:
                    type: string
                  expires_at:
                    type: string
                    format: date-time
        '429':
          description: Provisioning rate limit exceeded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
  /api/scans:
    post:
      tags: [Scans]
      summary: Store a page scan log
      description: |
        Persists a page scan log from the extension into `page_scans`.
        Server-enriched fields: `ip` (request IP) and `ua` (request User-Agent, truncated to 512).
        Throttle: 12 per minute and 120 per hour per token, plus 30 per minute per IP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScanCreateRequest'
            examples:
              example:
                value:
                  url: https://www.amazon.com/some-product
                  product_title: Olive Oil Product
                  score: 72
                  matches:
                    - { type: pos, term: organic }
                  ingredients:
                    - Organic Olive Oil
                    - Sea Salt
                  badges:
                    - USDA Organic
                    - Glyphosate Free Certified
                  ingredients_text: Organic Olive Oil, Sea Salt
                  page_text: Certified Organic olive oil. USDA Organic. Glyphosate residue free. No seed oils.
                  detected_badges:
                    - USDA_ORGANIC
                    - GLYPHOSATE_RESIDUE_FREE
                  product_category: oil
                  meta:
                    source: dom
                    site: amazon.com
                  og_image: https://example.com/og.jpg
                  has_product_ld: true
      responses:
        '201':
          description: Created
          headers:
            X-RateLimit-Limit:
              description: The number of allowed requests in the current period
              schema: { type: integer }
            X-RateLimit-Remaining:
              description: The number of remaining requests in the current period
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanCreateResponse'
              examples:
                created:
                  value:
                    ok: true
                    id: 123
                    workflow_token: "9ca73d0a-5c41-4df1-9e97-0c9100f5a3d4"
                    status: processing
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '422':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationError' }
              examples:
                invalid:
                  value:
                    message: The given data was invalid.
                    errors:
                      url: [The url field is required.]
                      score: [The score must be between 0 and 100.]
        '429':
          description: Too Many Requests (rate limited)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '500':
          description: Server error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
  /api/scans/{workflowToken}:
    get:
      tags: [Scans]
      summary: Poll scoring status
      description: |
        Returns the current status of the advanced scoring workflow. Once completed,
        the payload contains the authoritative server score, rationale and flags.
        Throttle: 45 per minute and 600 per hour per token, plus 120 per minute per IP.
      parameters:
        - name: workflowToken
          in: path
          required: true
          description: Workflow token supplied by `POST /api/scans`.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Current scoring status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScanStatusResponse' }
        '404':
          description: Scan not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          description: Too Many Requests (rate limited)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
  /api/products/similar:
    get:
      tags: [Products]
      summary: Get similar products
      description: |
        Returns up to 12 items similar to the query.
        - Uses Typesense via Scout when configured (`SCOUT_DRIVER=typesense` with Typesense envs).
        - Falls back to a bounded, escaped DB LIKE query against title/subtitle/description when not configured.
        - Results are cached briefly to avoid repeated popup queries.
        - Throttle: 45 per minute and 600 per hour per token, plus 120 per minute per IP.
      parameters:
        - name: q
          in: query
          required: true
          description: Query term used to find similar products.
          schema:
            type: string
            minLength: 2
            maxLength: 80
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                maxItems: 12
                items:
                  $ref: '#/components/schemas/SimilarProduct'
              examples:
                example:
                  value:
                    - { id: 99, name: "Olive Item 0", subtitle: "Nice olive 0", image: "https://images.../logo.webp", url: "https://finelysourced.com/products/olive-item-0" }
        '400':
          description: Bad Request (missing or empty q)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              examples:
                missing:
                  value: { message: "Query parameter q is required" }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '500':
          description: Server error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
  schemas:
    ScanCreateRequest:
      type: object
      required: [url, score]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: Page URL scanned.
        product_title:
          type: string
          nullable: true
          maxLength: 255
        score:
          type: integer
          minimum: 0
          maximum: 100
          description: Scan score from 0..100.
        matches:
          type: array
          nullable: true
          maxItems: 50
          items:
            $ref: '#/components/schemas/Match'
        ingredients:
          type: array
          nullable: true
          maxItems: 50
          description: Extracted list of ingredient names (in order of appearance).
          items:
            type: string
            maxLength: 120
        badges:
          type: array
          nullable: true
          maxItems: 10
          description: Detected certification badges or labels on the page.
          items:
            type: string
            maxLength: 120
        ingredients_text:
          type: string
          nullable: true
          maxLength: 2000
          description: Raw ingredients string exactly as captured on the page.
        page_text:
          type: string
          nullable: true
          maxLength: 20000
          description: Normalised page text used for advanced scoring heuristics.
        detected_badges:
          type: array
          nullable: true
          maxItems: 12
          items:
            type: string
            maxLength: 120
          description: Machine-detected certification identifiers (e.g., `USDA_ORGANIC`).
        product_category:
          type: string
          nullable: true
          maxLength: 120
          description: Client-provided category hint used for contextual scoring tweaks.
        asin:
          type: string
          nullable: true
          maxLength: 32
          description: Amazon Standard Identification Number extracted from the product page, when available.
        upc:
          type: string
          nullable: true
          maxLength: 32
          description: UPC/EAN/GTIN captured from the page, if provided.
        manufacturer:
          type: string
          nullable: true
          maxLength: 255
          description: Manufacturer name sourced from the product details section when available.
        meta:
          type: object
          nullable: true
          maxProperties: 25
          additionalProperties: true
          properties:
            source:
              type: string
              nullable: true
              description: Origin of the scan payload (dom, schema, api, etc.).
            site:
              type: string
              nullable: true
              description: Hostname the scan was captured from.
        og_image:
          type: string
          nullable: true
          format: uri
          maxLength: 2048
        has_product_ld:
          type: boolean
          description: Whether product JSON-LD was detected on the page.
          default: false
      description: |
        Server-enriched fields:
        - ip: request IP (stored on server)
        - ua: request User-Agent string (stored on server, truncated to 512 chars)
        Server asynchronously enhances the record with an advanced score, confidence and rationale.
    Match:
      type: object
      required: [type, term]
      properties:
        type:
          type: string
          enum: [pos, neg]
          description: Positive or negative match classification.
        term:
          type: string
          maxLength: 120
          description: Matched term.
    ScanCreateResponse:
      type: object
      required: [ok, id, workflow_token, status]
      properties:
        ok:
          type: boolean
          example: true
        id:
          type: integer
          example: 123
        workflow_token:
          type: string
          format: uuid
          description: Token to poll for the server-calculated score.
        status:
          type: string
          enum: [processing, completed]
          description: Current processing state of the advanced scorer.
          example: processing
    ScanStatusResponse:
      type: object
      required: [id, workflow_token, status, client_score]
      properties:
        id:
          type: integer
        workflow_token:
          type: string
          format: uuid
        status:
          type: string
          enum: [processing, completed]
        client_score:
          type: integer
          description: Initial client-side score supplied at ingestion time.
        asin:
          type: string
          nullable: true
          description: Amazon Standard Identification Number captured for the scan.
        upc:
          type: string
          nullable: true
          description: UPC/EAN/GTIN captured for the scan.
        manufacturer:
          type: string
          nullable: true
          description: Manufacturer name detected for the scanned product.
        server_score:
          type: integer
          nullable: true
          description: Server-calculated score (present when status is `completed`).
        server_score_confidence:
          type: number
          format: float
          nullable: true
          description: Confidence value between 0 and 1.
        server_score_rationale:
          type: array
          nullable: true
          items:
            type: object
            required: [reason, delta]
            properties:
              reason: { type: string }
              delta: { type: number }
        server_score_flags:
          type: object
          nullable: true
          additionalProperties: { type: boolean }
        server_score_breakdown:
          type: object
          nullable: true
          description: Detailed scoring breakdown containing summary totals and the applied positive/negative contributions.
          properties:
            summary:
              type: object
              nullable: true
              additionalProperties: { type: number }
            positives:
              type: array
              items:
                type: object
                required: [reason, delta]
                properties:
                  reason: { type: string }
                  delta: { type: number }
            negatives:
              type: array
              items:
                type: object
                required: [reason, delta]
                properties:
                  reason: { type: string }
                  delta: { type: number }
        processed_at:
          type: string
          nullable: true
          format: date-time
    SimilarProduct:
      type: object
      required: [id, name, subtitle, url]
      properties:
        id:
          type: integer
        name:
          type: string
          description: Product title.
        subtitle:
          type: string
          description: Short descriptive subtitle; may be empty string if not present.
        image:
          type: string
          nullable: true
          format: uri
          description: Logo image URL if available.
        url:
          type: string
          format: uri
          description: Public product page URL.
    ErrorResponse:
      type: object
      properties:
        message:
          type: string
      example:
        message: Unauthorized.
    ValidationError:
      type: object
      required: [message, errors]
      properties:
        message:
          type: string
          example: The given data was invalid.
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          example:
            url: ["The url field is required."]
            score: ["The score must be between 0 and 100."]
