openapi: 3.1.0
info:
  title: rustunnel platform API
  version: 0.2.24
  summary: Accounts, API keys, usage, and billing for rustunnel.
  description: |
    The platform API behind rustunnel.com — user registration, JWT auth,
    API-key management, tunnel usage history, and pay-as-you-go billing.

    Tunnels themselves are NOT created over this API: tunnel creation happens
    over the rustunnel control plane (WebSocket, port 4040) via the `rustunnel`
    CLI or the `rustunnel-mcp` MCP server. Use this API to create the API keys
    those clients authenticate with, and to inspect usage afterwards.
    Agent-oriented recipes: https://rustunnel.com/agents.md

    Authentication: call `POST /auth/login` to obtain a short-lived JWT access
    token (15 minutes) plus an httpOnly `refresh_token` cookie (30 days), then
    send `Authorization: Bearer <access_token>` on user-scoped endpoints and
    `POST /auth/refresh` (with the cookie) when the access token expires.

    Errors are always `{"error": "<message>"}` with a 4xx/5xx status code.
    All JSON fields use snake_case.
  contact:
    name: rustunnel
    url: https://rustunnel.com
    email: support@rustunnel.com
  license:
    name: AGPL-3.0
    identifier: AGPL-3.0-only
servers:
  - url: https://api.rustunnel.com
    description: Production
tags:
  - name: auth
    description: Registration, login, JWT refresh, and password reset.
  - name: keys
    description: API keys used by the rustunnel CLI and MCP server to open tunnels.
  - name: usage
    description: Tunnel history and traffic statistics.
  - name: load-balancing
    description: Live state of the caller's load-balanced tunnel pools.
  - name: billing
    description: Pay-as-you-go subscription, payment methods, and invoices.
  - name: health
    description: Service health probes.

paths:
  /auth/register:
    post:
      operationId: register
      tags: [auth]
      summary: Register a new account
      description: >
        Creates a user with email + password (minimum 8 characters) and sends a
        verification email. The account cannot log in until the email is
        verified.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  minLength: 8
                display_name:
                  type: [string, "null"]
      responses:
        "201":
          description: Account created; verification email sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: Email already registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /auth/verify-email/{token}:
    get:
      operationId: verifyEmail
      tags: [auth]
      summary: Verify an email address
      description: Consumes the token from the verification email.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Email verified.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/BadRequest"

  /auth/login:
    post:
      operationId: login
      tags: [auth]
      summary: Log in with email and password
      description: >
        Returns a 15-minute JWT access token in the body and sets a 30-day
        httpOnly `refresh_token` cookie. Accounts created via Google sign-in
        must use the OAuth flow on rustunnel.com instead.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
      responses:
        "200":
          description: "Authenticated. `Set-Cookie: refresh_token=...` is included."
          content:
            application/json:
              schema:
                type: object
                required: [access_token, user]
                properties:
                  access_token:
                    type: string
                  user:
                    $ref: "#/components/schemas/UserPublic"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /auth/logout:
    post:
      operationId: logout
      tags: [auth]
      summary: Log out
      description: Clears the `refresh_token` cookie.
      security: []
      responses:
        "204":
          description: Logged out.

  /auth/refresh:
    post:
      operationId: refreshAccessToken
      tags: [auth]
      summary: Exchange the refresh cookie for a new access token
      description: >
        Requires the httpOnly `refresh_token` cookie set by login. Returns a
        fresh 15-minute access token.
      security: []
      responses:
        "200":
          description: New access token.
          content:
            application/json:
              schema:
                type: object
                required: [access_token]
                properties:
                  access_token:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /auth/me:
    get:
      operationId: getCurrentUser
      tags: [auth]
      summary: Get the authenticated user
      description: Returns the profile of the caller identified by the bearer token.
      responses:
        "200":
          description: The authenticated user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserPublic"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/forgot-password:
    post:
      operationId: forgotPassword
      tags: [auth]
      summary: Request a password-reset email
      description: >
        Always returns 200 to avoid revealing whether the email is registered.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "200":
          description: Reset email sent if the address exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"

  /auth/reset-password:
    post:
      operationId: resetPassword
      tags: [auth]
      summary: Set a new password with a reset token
      description: Consumes the token from the password-reset email.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token:
                  type: string
                password:
                  type: string
                  minLength: 8
      responses:
        "200":
          description: Password updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "400":
          $ref: "#/components/responses/BadRequest"

  /keys:
    get:
      operationId: listApiKeys
      tags: [keys]
      summary: List API keys
      description: >
        Returns the caller's API keys (hashed — the raw token is only shown
        once, at creation).
      responses:
        "200":
          description: The caller's API keys.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiToken"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createApiKey
      tags: [keys]
      summary: Create an API key
      description: >
        Creates a tunnel API key. The response contains the raw token exactly
        once — store it (e.g. as `RUSTUNNEL_TOKEN`); it cannot be retrieved
        again.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label:
                  type: string
                  description: Human-readable name for the key.
      responses:
        "201":
          description: Key created. `token` is shown only in this response.
          content:
            application/json:
              schema:
                type: object
                required: [id, token]
                properties:
                  id:
                    type: string
                  token:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /keys/{id}:
    delete:
      operationId: deleteApiKey
      tags: [keys]
      summary: Delete an API key
      description: Revokes the key; tunnel clients using it can no longer authenticate.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Key deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /usage:
    get:
      operationId: getUsageSummary
      tags: [usage]
      summary: Get a 30-day usage summary
      description: Active tunnels plus 30-day totals for tunnels, requests, and bytes.
      responses:
        "200":
          description: Usage summary.
          content:
            application/json:
              schema:
                type: object
                required:
                  [active_tunnels, total_tunnels_30d, total_requests_30d, total_bytes_30d, plan]
                properties:
                  active_tunnels:
                    type: integer
                  total_tunnels_30d:
                    type: integer
                  total_requests_30d:
                    type: integer
                  total_bytes_30d:
                    type: integer
                  plan:
                    type: string
                  tunnel_limit:
                    type: [integer, "null"]
        "401":
          $ref: "#/components/responses/Unauthorized"

  /usage/chart:
    get:
      operationId: getUsageChart
      tags: [usage]
      summary: Get daily tunnel/request counts
      description: Daily data points for charting, most recent N days.
      parameters:
        - name: days
          in: query
          required: false
          schema:
            type: integer
            default: 30
            minimum: 1
            maximum: 90
      responses:
        "200":
          description: One point per day.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [date, tunnels, requests]
                  properties:
                    date:
                      type: string
                      description: YYYY-MM-DD
                    tunnels:
                      type: integer
                    requests:
                      type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"

  /usage/tunnels:
    get:
      operationId: listTunnels
      tags: [usage]
      summary: List tunnel history
      description: >
        The caller's tunnels, newest first. Active tunnels have
        `unregistered_at: null`.
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        "200":
          description: Tunnel log entries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/TunnelLogEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /usage/tunnels/{id}:
    get:
      operationId: getTunnel
      tags: [usage]
      summary: Get one tunnel
      description: A single tunnel-log entry owned by the caller.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The tunnel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TunnelLogEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /usage/tunnels/{id}/requests:
    get:
      operationId: listTunnelRequests
      tags: [usage]
      summary: List captured requests for a tunnel
      description: >
        Proxies the request-capture log from the edge region the tunnel ran in.
        Shape follows the edge dashboard API and may evolve.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: Captured requests (opaque edge-server JSON).
          content:
            application/json:
              schema:
                type: object
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /load-balancing/groups:
    get:
      operationId: listLoadBalancingGroups
      tags: [load-balancing]
      summary: List the caller's load-balanced pools
      description: >
        Live state of every load-balancing group that contains at least one of
        the caller's active tunnels, fanned out across all edge regions.
        Regions that fail to answer are reported in `warnings` instead of
        failing the request.
      responses:
        "200":
          description: Groups and any per-region warnings.
          content:
            application/json:
              schema:
                type: object
                required: [groups, warnings]
                properties:
                  groups:
                    type: array
                    items:
                      $ref: "#/components/schemas/GroupSummary"
                  warnings:
                    type: array
                    items:
                      type: string
        "401":
          $ref: "#/components/responses/Unauthorized"

  /billing:
    get:
      operationId: getBillingStatus
      tags: [billing]
      summary: Get billing status
      description: Plan, month-to-date estimate, spend cap, and payment-method state.
      responses:
        "200":
          description: Billing status.
          content:
            application/json:
              schema:
                type: object
                required:
                  [billing_model, plan_name, estimated_mtd_cents, has_payment_method, allow_custom_subdomains, monthly_minimum_cents]
                properties:
                  billing_model:
                    type: string
                  plan_name:
                    type: string
                  estimated_mtd_cents:
                    type: integer
                  spend_cap_cents:
                    type: [integer, "null"]
                  subscription_status:
                    type: [string, "null"]
                  current_period_end:
                    type: [string, "null"]
                    format: date-time
                  stripe_customer_id:
                    type: [string, "null"]
                  has_payment_method:
                    type: boolean
                  allow_custom_subdomains:
                    type: boolean
                  monthly_minimum_cents:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"

  /billing/subscribe:
    post:
      operationId: subscribePayg
      tags: [billing]
      summary: Subscribe to pay-as-you-go
      description: >
        Starts the PAYG subscription ($3/month minimum credited toward usage).
        Requires a payment method on file — create one via
        `POST /billing/setup-intent` first.
      responses:
        "200":
          description: Subscription created.
          content:
            application/json:
              schema:
                type: object
                required: [status, plan]
                properties:
                  status:
                    type: string
                  plan:
                    type: string
                  stripe_subscription_id:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /billing/setup-intent:
    post:
      operationId: createSetupIntent
      tags: [billing]
      summary: Create a Stripe SetupIntent
      description: Returns a client secret for collecting a payment method in the browser.
      responses:
        "200":
          description: SetupIntent created.
          content:
            application/json:
              schema:
                type: object
                required: [client_secret]
                properties:
                  client_secret:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"

  /billing/portal:
    post:
      operationId: createBillingPortalSession
      tags: [billing]
      summary: Open the Stripe customer portal
      description: Returns a URL to Stripe's hosted billing portal.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [return_url]
              properties:
                return_url:
                  type: string
                  format: uri
      responses:
        "200":
          description: Portal session created.
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    format: uri
        "401":
          $ref: "#/components/responses/Unauthorized"

  /billing/invoices:
    get:
      operationId: listInvoices
      tags: [billing]
      summary: List invoices
      description: The caller's Stripe invoices (raw Stripe invoice objects).
      responses:
        "200":
          description: Invoices, empty array if none.
          content:
            application/json:
              schema:
                type: object
                required: [invoices]
                properties:
                  invoices:
                    type: array
                    items:
                      type: object
        "401":
          $ref: "#/components/responses/Unauthorized"

  /billing/spend-cap:
    patch:
      operationId: updateSpendCap
      tags: [billing]
      summary: Set or remove the monthly spend cap
      description: PAYG plans only. Pass `null` to remove the cap.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                spend_cap_cents:
                  type: [integer, "null"]
                  minimum: 0
      responses:
        "200":
          description: New cap value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  spend_cap_cents:
                    type: [integer, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /health:
    get:
      operationId: getHealth
      tags: [health]
      summary: Liveness probe
      description: Always returns ok while the process is up.
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ok"

  /health/db:
    get:
      operationId: getDbHealth
      tags: [health]
      summary: Database health probe
      description: Verifies the PostgreSQL connection.
      security: []
      responses:
        "200":
          description: Database reachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Ok"
        "503":
          description: Database unreachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        15-minute access token from `POST /auth/login` or `POST /auth/refresh`,
        sent as `Authorization: Bearer <token>`.
  responses:
    BadRequest:
      description: Validation failed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: Account suspended or not permitted.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found or not owned by the caller.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable cause.
    Message:
      type: object
      required: [message]
      properties:
        message:
          type: string
    Ok:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
    UserPublic:
      type: object
      required: [id, email, email_verified, status, auth_method, created_at]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        display_name:
          type: [string, "null"]
        email_verified:
          type: boolean
        status:
          type: string
        auth_method:
          type: string
        created_at:
          type: string
          format: date-time
    ApiToken:
      type: object
      required: [id, label, created_at, status, unlimited, tunnel_count]
      properties:
        id:
          type: string
        label:
          type: string
        token_hash:
          type: string
          description: SHA-256 of the raw token; the raw token is never stored.
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: [string, "null"]
          format: date-time
        scope:
          type: [string, "null"]
        tier:
          type: [string, "null"]
        tunnel_limit:
          type: [integer, "null"]
        status:
          type: string
        unlimited:
          type: boolean
        tunnel_count:
          type: integer
    TunnelLogEntry:
      type: object
      required:
        [id, tunnel_id, protocol, label, registered_at, request_count, bytes_proxied]
      properties:
        id:
          type: string
        tunnel_id:
          type: string
        protocol:
          type: string
          enum: [http, tcp, udp, p2p]
        label:
          type: string
        registered_at:
          type: string
          format: date-time
        unregistered_at:
          type: [string, "null"]
          format: date-time
          description: Null while the tunnel is still active.
        region_id:
          type: [string, "null"]
        request_count:
          type: integer
        bytes_proxied:
          type: integer
    GroupMemberSummary:
      type: object
      required:
        [tunnel_id, session_id, client_addr, request_count, bytes_proxied, healthy, consecutive_failures, total_health_failures, connected_since, has_alert_webhook]
      properties:
        tunnel_id:
          type: string
        session_id:
          type: string
        client_addr:
          type: string
        request_count:
          type: integer
        bytes_proxied:
          type: integer
        healthy:
          type: boolean
        consecutive_failures:
          type: integer
        total_health_failures:
          type: integer
        connected_since:
          type: string
        health_check_kind:
          type: string
        has_alert_webhook:
          type: boolean
    GroupSummary:
      type: object
      required:
        [protocol, label, name, key_hash_short, region_id, member_count, healthy_count, unhealthy_count, total_dispatches, total_health_failures, members]
      properties:
        protocol:
          type: string
        label:
          type: string
        name:
          type: string
        key_hash_short:
          type: string
        region_id:
          type: string
        member_count:
          type: integer
        healthy_count:
          type: integer
        unhealthy_count:
          type: integer
        total_dispatches:
          type: integer
        total_health_failures:
          type: integer
        members:
          type: array
          items:
            $ref: "#/components/schemas/GroupMemberSummary"
