openapi: 3.0.3

# ==============================================================================
# LinkGate Public API — v1 (LIN-31 review-gate spec, source of truth)
# ==============================================================================
# This document is the AUTHORITATIVE contract for the LinkGate Public API v1.
# Endpoint code is expected to conform to this spec. Where the currently shipped
# LIN-29 implementation diverges from this spec, the delta is tracked in
# docs/api/api-design.md > "Conformance gaps" and is subject to CTO review.
#
# Status: DRAFT — pending CTO (Basile) approval per LIN-31 review gate.
# ==============================================================================

info:
  title: LinkGate Public API
  version: "1.0.0"
  description: |
    Programmatic access to LinkGate for power users and third-party integrations:
    manage short links, read analytics, and read/update the authenticated account.

    ## Conventions
    - **Versioning:** URL-prefixed `/api/v1`. v1 is additive-only; breaking
      changes ship under a new prefix (`/api/v2`).
    - **Auth:** per-account API keys via `Authorization: Bearer <key>`. Keys are
      scoped and revocable. See the `ApiKey` security scheme.
    - **Errors:** every error returns the JSON envelope
      `{ "error": { "code", "message", "details" } }`.
    - **Pagination:** cursor-based (`limit` + `cursor`, response `next_cursor`).
    - **Rate limits:** every response carries `X-RateLimit-*` headers; `429`
      responses add `Retry-After`.
    - **Scope:** every resource is scoped to the key owner. No cross-account access.
  contact:
    name: LinkGate Engineering

servers:
  - url: "{appUrl}/api/v1"
    variables:
      appUrl:
        default: https://linkgate.app
        description: Deployment base URL (APP_URL).

security:
  - ApiKey: []

tags:
  - name: Links
    description: CRUD over the authenticated account's short links.
  - name: Analytics
    description: Read-only click and revenue analytics.
  - name: User
    description: The authenticated account.

paths:
  # ─────────────────────────────── Links ────────────────────────────────────
  /links:
    get:
      tags: [Links]
      summary: List links
      operationId: listLinks
      description: Cursor-paginated list of the caller's links, newest first.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of links.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Link' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Links]
      summary: Create a link
      operationId: createLink
      description: Requires the `links:write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LinkCreate' }
      responses:
        '201':
          description: Link created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Link' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /links/{id}:
    parameters:
      - $ref: '#/components/parameters/LinkId'
    get:
      tags: [Links]
      summary: Get a link
      operationId: getLink
      responses:
        '200':
          description: The link.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Link' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Links]
      summary: Update a link
      operationId: updateLink
      description: Requires the `links:write` scope. Partial update.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LinkUpdate' }
      responses:
        '200':
          description: The updated link.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Link' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Links]
      summary: Delete a link
      operationId: deleteLink
      description: Requires the `links:write` scope.
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Analytics ──────────────────────────────────
  /analytics/summary:
    get:
      tags: [Analytics]
      summary: Account analytics summary
      operationId: getAccountAnalyticsSummary
      description: >
        Account rollup: click/revenue totals, a per-day clicks time series, and
        top-N referrer/geo/device breakdowns scoped to the requested window
        (default: trailing 30 days). Requires `analytics:read`.
      parameters:
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: Account analytics.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AccountAnalytics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /analytics:
    get:
      tags: [Analytics]
      summary: Account analytics summary (deprecated alias of /analytics/summary)
      operationId: getAccountAnalytics
      deprecated: true
      description: Back-compatible alias for `GET /analytics/summary`.
      parameters:
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: Account analytics.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AccountAnalytics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /links/{id}/analytics:
    parameters:
      - $ref: '#/components/parameters/LinkId'
    get:
      tags: [Analytics]
      summary: Per-link analytics
      operationId: getLinkAnalytics
      description: >
        Per-link click/revenue totals, a per-day clicks time series, and top-N
        referrer/geo/device breakdowns scoped to the requested window
        (default: trailing 30 days). Requires `analytics:read`.
      parameters:
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: Link analytics.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/LinkAnalytics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────────── User ─────────────────────────────────────
  # NOTE: board plan (LIN-31) names this GET /v1/users/me. Shipped LIN-29 code
  # exposes /v1/user. The path below reflects the board naming; the /user alias
  # divergence is tracked as a conformance gap pending CTO decision.
  /users/me:
    get:
      tags: [User]
      summary: Get the authenticated account
      operationId: getCurrentUser
      description: Requires `user:read`.
      responses:
        '200':
          description: The account.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [User]
      summary: Update the authenticated account
      operationId: updateCurrentUser
      description: |
        Update `full_name` and/or `default_currency`. Requires `user:write`.
        Team/other-user management is DEFERRED to v1.1 — no endpoints write to
        other users in v1.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UserUpdate' }
      responses:
        '200':
          description: The updated account.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      bearerFormat: LinkGate API key (opaque, prefix `lg_`)
      description: |
        Per-account API key. Issue, rotate, and revoke keys from the LinkGate
        dashboard (Settings → API keys). Keys are scoped; a request that touches
        a resource outside the key's scopes is rejected with `403 E_FORBIDDEN`.
        Only the key hash is stored server-side; the plaintext is shown once at
        creation. See docs/api/api-design.md for the key lifecycle.

  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Page size. 1–100, default 20.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor from a previous response's `meta.next_cursor`.
      schema: { type: string }
    LinkId:
      name: id
      in: path
      required: true
      description: Link id (cuid2).
      schema: { type: string, example: h690g9ndk2d32lj79ppfnmgu }
    From:
      name: from
      in: query
      required: false
      description: >
        Inclusive start of the analytics window (ISO date/datetime, treated as
        UTC). Defaults to 30 days before `to`. Clamped to a max 366-day span.
      schema: { type: string, format: date-time }
    To:
      name: to
      in: query
      required: false
      description: >
        Inclusive end of the analytics window (ISO date/datetime, treated as
        UTC). Defaults to now.
      schema: { type: string, format: date-time }

  headers:
    XRateLimitLimit:
      description: Requests permitted in the current window.
      schema: { type: integer }
    XRateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    XRateLimitReset:
      description: Unix epoch seconds when the window resets.
      schema: { type: integer }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  responses:
    Unauthorized:
      description: Missing, invalid, or expired API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: E_UNAUTHORIZED, message: "Missing or invalid API key.", details: null } }
    Forbidden:
      description: Account disabled, or key lacks the required scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: E_FORBIDDEN, message: "This API key is not permitted to perform this action.", details: null } }
    NotFound:
      description: Resource not found or not owned by the caller.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: E_LINK_NOT_FOUND, message: "Link not found.", details: null } }
    ValidationError:
      description: Request body failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: E_VALIDATION
              message: "The request is invalid."
              details:
                - field: original_url
                  rule: url
                  message: The original_url field must be a valid URL.
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: E_RATE_LIMITED, message: "Rate limit exceeded. Retry later.", details: null } }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable error code (E_*).
              example: E_LINK_NOT_FOUND
            message:
              type: string
              description: Human-readable explanation.
            details:
              nullable: true
              description: |
                Optional structured context. For `E_VALIDATION` this is an array
                of `{ field, rule, message }`. Null when no extra context applies.

    CursorMeta:
      type: object
      required: [limit, next_cursor]
      properties:
        limit:
          type: integer
          description: The page size that was applied.
          example: 20
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null on the last page.
          example: eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0wNCJ9

    Link:
      type: object
      required:
        - id
        - code
        - short_url
        - original_url
        - requires_payment
        - price_cents
        - currency
        - is_active
        - click_count
        - paid_click_count
      properties:
        id: { type: string, example: h690g9ndk2d32lj79ppfnmgu }
        code: { type: string, example: Gfp4dGK }
        short_url: { type: string, format: uri, example: "https://linkgate.app/s/Gfp4dGK" }
        original_url: { type: string, format: uri, example: "https://example.com" }
        title: { type: string, nullable: true, example: "Docs" }
        requires_payment: { type: boolean }
        price_cents:
          type: integer
          description: Price in minor units (cents). 0 when not paywalled.
          example: 500
        currency: { type: string, enum: [usd, eur, gbp], example: usd }
        is_active: { type: boolean }
        click_count: { type: integer, example: 0 }
        paid_click_count: { type: integer, example: 0 }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    LinkCreate:
      type: object
      required: [original_url]
      properties:
        original_url: { type: string, format: uri, description: Destination URL. }
        title: { type: string, maxLength: 200 }
        requires_payment:
          type: boolean
          default: false
          description: Whether the redirect is paywalled.
        price:
          type: number
          minimum: 0.5
          maximum: 999
          description: Price in MAJOR units. Serialized back as `price_cents`.
        currency: { type: string, enum: [usd, eur, gbp] }
        expires_at: { type: string, format: date-time }

    LinkUpdate:
      type: object
      description: Partial update. All fields optional.
      properties:
        original_url: { type: string, format: uri }
        title: { type: string, maxLength: 200, nullable: true }
        requires_payment: { type: boolean }
        price: { type: number, minimum: 0.5, maximum: 999 }
        currency: { type: string, enum: [usd, eur, gbp] }
        is_active: { type: boolean }
        expires_at: { type: string, format: date-time }

    Revenue:
      type: object
      description: Revenue for one currency; only `completed` paid sessions count.
      required: [currency, gross_cents, net_cents, platform_fee_cents, stripe_fee_cents, paid_clicks]
      properties:
        currency: { type: string, enum: [usd, eur, gbp] }
        gross_cents: { type: integer, example: 10500 }
        net_cents: { type: integer, example: 9135 }
        platform_fee_cents: { type: integer, example: 1050 }
        stripe_fee_cents: { type: integer, example: 315 }
        paid_clicks: { type: integer, example: 21 }

    DateRange:
      type: object
      description: The resolved analytics window (UTC).
      required: [from, to]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }

    TimeBucket:
      type: object
      description: Click counts for a single UTC day.
      required: [date, clicks, paid_clicks]
      properties:
        date: { type: string, format: date, example: '2026-07-01' }
        clicks: { type: integer, example: 12 }
        paid_clicks: { type: integer, example: 1 }

    BreakdownRow:
      type: object
      description: >
        One entry of a top-N breakdown. `value` is a referrer host ("direct"
        when none), a lowercase ISO country code ("unknown" when absent), or a
        device class (mobile/tablet/desktop/bot/unknown).
      required: [value, clicks]
      properties:
        value: { type: string, example: twitter.com }
        clicks: { type: integer, example: 40 }

    AccountAnalytics:
      type: object
      required: [total_links, total_clicks, total_paid_clicks, revenue, range, clicks_over_time, referrers, geo, devices]
      properties:
        total_links: { type: integer, example: 12 }
        total_clicks: { type: integer, example: 340 }
        total_paid_clicks: { type: integer, example: 21 }
        revenue:
          type: array
          items: { $ref: '#/components/schemas/Revenue' }
        range: { $ref: '#/components/schemas/DateRange' }
        clicks_over_time:
          type: array
          items: { $ref: '#/components/schemas/TimeBucket' }
        referrers:
          type: array
          description: Top 50 referrer hosts by clicks, descending.
          items: { $ref: '#/components/schemas/BreakdownRow' }
        geo:
          type: array
          description: Top 50 countries by clicks, descending.
          items: { $ref: '#/components/schemas/BreakdownRow' }
        devices:
          type: array
          description: Device-class breakdown by clicks, descending.
          items: { $ref: '#/components/schemas/BreakdownRow' }

    LinkAnalytics:
      type: object
      required: [link_id, code, total_clicks, paid_clicks, range, clicks_over_time, referrers, geo, devices]
      properties:
        link_id: { type: string, example: h690g9ndk2d32lj79ppfnmgu }
        code: { type: string, example: Gfp4dGK }
        total_clicks: { type: integer, example: 40 }
        paid_clicks: { type: integer, example: 3 }
        revenue:
          allOf:
            - $ref: '#/components/schemas/Revenue'
          nullable: true
          description: Null when the link has no completed paid sessions.
        range: { $ref: '#/components/schemas/DateRange' }
        clicks_over_time:
          type: array
          items: { $ref: '#/components/schemas/TimeBucket' }
        referrers:
          type: array
          description: Top 50 referrer hosts by clicks, descending.
          items: { $ref: '#/components/schemas/BreakdownRow' }
        geo:
          type: array
          description: Top 50 countries by clicks, descending.
          items: { $ref: '#/components/schemas/BreakdownRow' }
        devices:
          type: array
          description: Device-class breakdown by clicks, descending.
          items: { $ref: '#/components/schemas/BreakdownRow' }

    User:
      type: object
      required: [id, email, role, default_currency, is_active]
      properties:
        id: { type: string, example: usr_h690g9ndk2d32lj }
        email: { type: string, format: email }
        full_name: { type: string, nullable: true }
        role: { type: string, enum: [user, admin] }
        default_currency: { type: string, enum: [usd, eur, gbp] }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    UserUpdate:
      type: object
      description: Partial update of the authenticated account.
      properties:
        full_name: { type: string, maxLength: 255, nullable: true }
        default_currency: { type: string, enum: [usd, eur, gbp] }
