openapi: 3.1.0
info:
  title: 21tunnel API
  version: "0.2.0"
  description: |
    REST API for the 21tunnel dashboard, served by `qnt-server`. This
    spec covers the customer-facing surface — the routes a paying user
    or their automation would call. Internal superadmin routes
    (`/superadmin/*`) are intentionally omitted; they're operator
    tooling and not part of the supported integration surface.

    Authentication: every endpoint outside `/health`, `/ready`, and
    `/auth/*` requires a `Authorization: Bearer <jwt>` header. JWTs
    come from `POST /auth/login` and last 15 minutes; refresh via the
    HttpOnly `qnt_refresh` cookie set on login.

    Rate limiting: `/auth/*` is bucketed at 5-burst + 1/12s per source
    IP. Other routes are unlimited at the application layer (subject
    to upstream nginx limits).
servers:
  - url: https://login.21tunnel.com/api
    description: Hosted production
  - url: http://127.0.0.1:9090
    description: Self-hosted (server's API port)

tags:
  - name: auth
    description: Sign-up, login, password reset, sessions, OAuth, CLI auth
  - name: mfa
    description: TOTP enrollment, verification, recovery codes
  - name: tunnels
    description: Per-tunnel operations (list, delete, start/stop, maintenance, basic auth, edge auth)
  - name: tokens
    description: Capability-token mint and revoke; master-key delegation
  - name: projects
    description: Project namespaces and project-scoped key mint (master-key flow)
  - name: domains
    description: Custom-domain claim, DNS-verify, bind/unbind
  - name: reserved-subdomains
    description: Persistent reserved subdomain claims
  - name: connections
    description: Live agent connection management
  - name: inspector
    description: Inbound-request stream, replay, WebSocket live feed
  - name: metrics
    description: Dashboard counters, time-series, Prometheus
  - name: edge-auth
    description: Google-OAuth gate for public tunnel URLs
  - name: events
    description: Audit log (org-scoped) and personal security activity
  - name: orgs
    description: Organization read + member + invitation management
  - name: billing
    description: Stripe Checkout, customer portal, mock-mode cancellation
  - name: webhooks
    description: Inbound webhooks (Stripe)

paths:
  /health:
    get:
      tags: [auth]
      summary: Liveness probe
      description: Stateless. Returns 200 with `{status:"healthy", version:"..."}`. Always public.
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "healthy" }
                  version: { type: string, example: "0.2.0" }

  /auth/signup:
    post:
      tags: [auth]
      summary: Create account
      description: |
        Always returns 201 + `pending_email_verification`, even when
        the email is already in use (anti-enumeration). A 6-digit
        verification OTP is emailed via Resend, valid for 10 minutes
        with a 5-attempt cap. Verify via `POST /auth/verify-email-otp`;
        re-issue via `POST /auth/resend-verify-otp`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SignupRequest"
      responses:
        "201":
          description: Pending email verification
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"

  /auth/login:
    post:
      tags: [auth]
      summary: Exchange email+password for a 15-minute JWT
      description: Sets a HttpOnly `qnt_refresh` cookie alongside the JWT for renewal.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginRequest"
      responses:
        "200":
          description: Authenticated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoginResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /auth/forgot:
    post:
      tags: [auth]
      summary: Request password-reset email (always 202)
      description: |
        Anti-enumeration: always returns 202 regardless of whether
        the email exists. If it does, a token valid for 1 hour is
        emailed via Resend.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { email: { type: string, format: email } }
              required: [email]
      responses:
        "202":
          description: Acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"

  /auth/reset:
    post:
      tags: [auth]
      summary: Consume a reset token, set a new password
      description: |
        Single-use. On success the server revokes every active session
        for the user — they must sign in again everywhere. Min
        password length: 12.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string }
                new_password: { type: string, minLength: 12 }
              required: [token, new_password]
      responses:
        "200":
          description: Password updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"

  /ready:
    get:
      tags: [auth]
      summary: Readiness probe
      description: |
        Returns 200 once DB, JWT keys, and secret manager are loaded.
        Superset of `/health` — used by orchestrators that wait for
        full startup, not just process liveness.
      security: []
      responses:
        "200": { description: Ready }
        "503": { description: Dependencies not yet healthy }

  /auth/verify-email-otp:
    post:
      tags: [auth]
      summary: Verify 6-digit signup OTP
      description: |
        Body `{email, code}` where `code` is exactly 6 decimal digits.
        Atomic attempt counter at the DB level: 5 wrong attempts burn
        the token (further tries — even the correct code — fail with
        the same generic 400). TTL 10 minutes from signup or last
        resend. All failure modes return identical `400 invalid_code`
        responses (anti-enumeration).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                code: { type: string, pattern: '^[0-9]{6}$' }
              required: [email, code]
      responses:
        "200":
          description: Email verified — user can now log in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"

  /auth/resend-verify-otp:
    post:
      tags: [auth]
      summary: Re-issue a signup OTP (invalidates prior unconsumed token)
      description: |
        Always returns `200 {ok:true}` regardless of whether the email
        exists or is already verified (anti-enumeration). Prior
        unconsumed OTPs are invalidated atomically — at most one live
        OTP per user.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { email: { type: string, format: email } }
              required: [email]
      responses:
        "200":
          description: Acknowledged (regardless of email state)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }

  /auth/login/mfa:
    post:
      tags: [auth, mfa]
      summary: Complete second-factor login challenge
      description: |
        After `POST /auth/login` returns `{mfa_required:true, challenge_token}`,
        post the same challenge token plus either a 6-digit `code`
        (TOTP) or a `recovery_code` (one-shot, 10 issued at MFA
        enrollment).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                challenge_token: { type: string }
                code: { type: string, description: "6-digit TOTP, OR pass recovery_code." }
                recovery_code: { type: string }
              required: [challenge_token]
      responses:
        "200":
          description: Authenticated (returns same shape as /auth/login)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/refresh:
    post:
      tags: [auth]
      summary: Renew the 15-min access JWT via refresh cookie
      description: |
        Reads the HttpOnly `qnt_refresh` cookie set on login. Cookie
        rotation: a new refresh cookie is set on every successful
        refresh, and reuse-detection on the old cookie revokes the
        whole session (theft response).
      security: []
      responses:
        "200":
          description: New JWT issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/logout:
    post:
      tags: [auth]
      summary: Revoke the current session
      description: |
        Clears the `qnt_refresh` cookie and revokes the underlying
        session row so the JWT can no longer be refreshed.
      responses:
        "200": { description: Logged out }

  /auth/mfa/enroll:
    post:
      tags: [mfa]
      summary: Start MFA enrollment — returns `otpauth://` URI for QR scan
      description: |
        Returns the `otpauth://` URI for QR rendering plus the raw
        base32 secret. Secret is NOT yet persisted; call
        `POST /auth/mfa/enroll/verify` with the first valid TOTP to
        commit it.
      responses:
        "200":
          description: Enrollment seed
          content:
            application/json:
              schema:
                type: object
                properties:
                  otpauth_uri: { type: string, description: "`otpauth://totp/...` URI for QR code." }
                  secret_base32: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/mfa/enroll/verify:
    post:
      tags: [mfa]
      summary: Confirm MFA enrollment — persist secret, return recovery codes
      description: |
        Verify the user can produce a valid TOTP from the secret
        returned by `/auth/mfa/enroll`. On success the secret is
        encrypted-at-rest (XOR with server master key — upgrade to
        AEAD on roadmap) and 10 one-shot recovery codes are returned.
        Recovery codes are SHOWN ONCE.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code: { type: string, pattern: '^[0-9]{6}$' }
              required: [code]
      responses:
        "200":
          description: MFA enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  recovery_codes:
                    type: array
                    items: { type: string }
                    description: "10 one-shot codes. Save them now."
        "400":
          $ref: "#/components/responses/BadRequest"

  /auth/mfa/disable:
    post:
      tags: [mfa]
      summary: Disable MFA on the current account
      description: |
        Requires re-authentication via either the user's current
        password or a valid recovery code in the body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                password: { type: string }
                recovery_code: { type: string }
              description: "Either password or recovery_code; not both."
      responses:
        "200": { description: MFA disabled }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/oauth/google/start:
    get:
      tags: [auth]
      summary: Initiate Google OAuth signup/login
      description: |
        Redirects the browser to Google's consent screen. After
        consent, Google calls back `/auth/oauth/google/callback`. Use
        `?return_to=/some/path` to control the post-auth destination.
      security: []
      parameters:
        - in: query
          name: return_to
          schema: { type: string, default: "/" }
      responses:
        "302": { description: Redirect to Google }

  /auth/oauth/google/callback:
    get:
      tags: [auth]
      summary: Google OAuth callback — completes signup/login
      description: |
        Google calls this with `code` + `state`. On success the server
        sets the `qnt_refresh` cookie and redirects to the dashboard
        with `?oauth_token=<access_token>` for the SPA to hydrate.
      security: []
      parameters:
        - in: query
          name: code
          required: true
          schema: { type: string }
        - in: query
          name: state
          required: true
          schema: { type: string }
      responses:
        "302": { description: Redirect to dashboard with access token }

  /auth/cli/authorize:
    post:
      tags: [auth]
      summary: Complete CLI device-authorization flow
      description: |
        Backs `mytunnel login`. The CLI opens
        `https://login.21tunnel.com/cli-auth?code=…` in a browser; the
        user clicks **Authorize**; the dashboard POSTs here with the
        device code and the server returns a 90-day capability token
        saved to `~/.config/mytunnel/credentials`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                device_code: { type: string }
              required: [device_code]
      responses:
        "200":
          description: Token issued for the CLI
          content:
            application/json:
              schema:
                type: object
                properties:
                  wire_bytes_b64: { type: string, description: "Base64 of bincode-serialized CapabilityToken." }
                  label: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/me:
    get:
      tags: [auth]
      summary: Current user, active org, entitlements
      responses:
        "200":
          description: User info
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /auth/sessions:
    get:
      tags: [auth]
      summary: List active sessions for the current user
      responses:
        "200":
          description: Sessions list
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items:
                      $ref: "#/components/schemas/Session"

  /auth/sessions/{id}:
    delete:
      tags: [auth]
      summary: Revoke a single session
      parameters: [{ $ref: "#/components/parameters/SessionId" }]
      responses:
        "200": { description: Revoked }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /tunnels:
    get:
      tags: [tunnels]
      summary: List tunnels in the active org
      responses:
        "200":
          description: Tunnel list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Tunnel" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [tunnels]
      summary: Reserve a subdomain (dashboard-driven create)
      description: |
        Most tunnels are created by the agent at register-time
        (`mytunnel http <port>`) — POST is for explicit subdomain
        reservation from the dashboard.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                subdomain: { type: string }
              required: [subdomain]
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tunnel" }

  /tunnels/{id}:
    delete:
      tags: [tunnels]
      summary: Soft-delete a tunnel
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200": { description: Deleted }
        "404":
          $ref: "#/components/responses/NotFound"

  /tunnels/{id}/maintenance:
    put:
      tags: [tunnels]
      summary: Enable maintenance mode for a tunnel
      description: |
        Public traffic to the tunnel returns a branded 503 with the
        operator-supplied message until cleared. In-memory state —
        clears on agent disconnect.
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                message: { type: string, maxLength: 500, description: "Operator-supplied 503 body. HTML-escaped server-side." }
      responses:
        "200":
          description: Maintenance ON
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  maintenance: { type: boolean, example: true }
                  message: { type: string }
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [tunnels]
      summary: Clear maintenance mode
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200":
          description: Maintenance OFF
        "404":
          $ref: "#/components/responses/NotFound"

  /tunnels/{id}/start:
    post:
      tags: [tunnels]
      summary: Mark tunnel row as active (server-side; agent still must connect)
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200": { description: Tunnel marked active }
        "404": { $ref: "#/components/responses/NotFound" }

  /tunnels/{id}/stop:
    post:
      tags: [tunnels]
      summary: Force the tunnel offline + disconnect any live agent
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200": { description: Tunnel stopped }
        "404": { $ref: "#/components/responses/NotFound" }

  /tunnels/{id}/auth:
    put:
      tags: [tunnels]
      summary: Enable HTTP Basic auth on this tunnel
      description: |
        Per-tunnel username/password gate enforced at the edge. Stores
        password hashed with argon2id; the wire form is never persisted.
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username: { type: string }
                password: { type: string }
              required: [username, password]
      responses:
        "200": { description: Basic auth enabled }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [tunnels]
      summary: Disable HTTP Basic auth on this tunnel
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200": { description: Basic auth disabled }
        "404": { $ref: "#/components/responses/NotFound" }

  /tunnels/{id}/edge-auth:
    put:
      tags: [tunnels, edge-auth]
      summary: Enable Google OAuth gate on this tunnel
      description: |
        Visitors hitting the public URL get redirected to Google for
        sign-in before the tunneled service sees them. Configure
        allowed email domains in the body.
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                allowed_domains:
                  type: array
                  items: { type: string }
                  example: ["acme.com"]
                allowed_emails:
                  type: array
                  items: { type: string, format: email }
      responses:
        "200": { description: Edge auth enabled }
        "402":
          description: Edge auth gated to Pro+. Free tier returns 402.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [tunnels, edge-auth]
      summary: Disable Google OAuth gate on this tunnel
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200": { description: Edge auth disabled }
        "404": { $ref: "#/components/responses/NotFound" }

  /tokens:
    get:
      tags: [tokens]
      summary: List capability tokens for the active org
      responses:
        "200":
          description: Tokens
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CapabilityToken" }
    post:
      tags: [tokens]
      summary: Mint a new capability token
      description: |
        Returns the full token including `wireBytes` (base64-encoded
        bincode) — the format `mytunnel --token-file` accepts. This
        is the only call where `wireBytes` is returned; subsequent
        list calls expose only the safe shape.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                ttlHours: { type: integer, minimum: 1, default: 24 }
                allowedSubdomains: { type: array, items: { type: string } }
                bandwidthLimitMbps: { type: integer, minimum: 1, default: 100 }
                tunnelQuota: { type: integer, minimum: 1, default: 5 }
      responses:
        "200":
          description: Minted token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MintedToken" }

  /tokens/{id}:
    delete:
      tags: [tokens]
      summary: Revoke a capability token by its nonce (matched against `key_preview`)
      description: |
        Despite the path parameter being named `id` (matching the axum
        route's `:id`), the value sent must be the token's **nonce**
        (hex), NOT the row UUID. The server matches against the
        `key_preview` column to avoid an extra index.

        Revoking a `mtk_master_*` master key **cascade-revokes** every
        child token that master ever minted. The audit log captures
        both the parent revoke and each child as separate entries.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, description: "Token nonce in hex." }
      responses:
        "200":
          description: Revoked (and cascade-revoked for master keys)
        "404":
          $ref: "#/components/responses/NotFound"

  /tokens/{id}/rotate:
    post:
      tags: [tokens]
      summary: Rotate a capability token — issue a new one with the same scopes
      description: |
        Mints a fresh capability token with the same `allowedSubdomains`
        / `bandwidthLimitMbps` / `tunnelQuota` as the prior, then
        revokes the prior. The new token is returned with `wireBytes`
        included (one-shot, just like `POST /tokens`).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, description: "Token nonce in hex." }
      responses:
        "200":
          description: Rotated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MintedToken" }
        "404":
          $ref: "#/components/responses/NotFound"

  /tokens/master:
    post:
      tags: [tokens]
      summary: Mint an org-level master key (`mtk_master_*`)
      description: |
        Master keys delegate token-minting authority. They CANNOT open
        tunnels themselves — only mint scoped child tokens via
        `POST /projects/{slug}/tokens`. Default TTL 90 days, hard cap
        1 year. Cascade-revokes all minted children on revocation.

        This is the primitive that makes the AI-agent delegation
        pattern safe: the human holds the master key, the AI runs
        with short-lived scoped children it can mint as needed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, description: "Human-readable label, e.g. 'claude-code-laptop'." }
                ttl_days: { type: integer, minimum: 1, maximum: 365, default: 90 }
              required: [name]
      responses:
        "200":
          description: Master key minted (only time `wireBytes` is returned)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MintedToken" }
        "402":
          description: Master keys gated to Pro+ on hosted. Free tier returns 402.

  /projects:
    get:
      tags: [projects]
      summary: List projects in the active org
      responses:
        "200":
          description: Project list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Project" }
    post:
      tags: [projects]
      summary: Create a new project (subdomain namespace)
      description: |
        Slug is 1-63 chars, lowercase alphanumeric + hyphens, no
        leading/trailing dash. Tunnels created within this project
        get subdomain suffix expansion — a tunnel named `api` in
        project `staging` becomes `api-staging.21tunnel.com`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                slug: { type: string, pattern: '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' }
                name: { type: string }
              required: [slug, name]
      responses:
        "201":
          description: Project created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          description: Slug already in use within this org

  /projects/{slug}:
    parameters:
      - in: path
        name: slug
        required: true
        schema: { type: string }
    get:
      tags: [projects]
      summary: Read a single project
      responses:
        "200":
          description: Project details
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [projects]
      summary: Rename a project (slug is immutable)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { name: { type: string } }
              required: [name]
      responses:
        "200":
          description: Renamed
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [projects]
      summary: Soft-delete a project (default project is not deletable)
      responses:
        "200":
          description: Deleted
        "400":
          description: Cannot delete default project
        "404":
          $ref: "#/components/responses/NotFound"

  /projects/{slug}/tokens:
    parameters:
      - in: path
        name: slug
        required: true
        schema: { type: string }
    post:
      tags: [projects, tokens]
      summary: Mint a project-scoped child token via master key
      description: |
        Requires `Authorization: Bearer mtk_master_*` OR a user JWT.
        The minted token can ONLY open tunnels within this project's
        subdomain namespace. Token is associated with the master via
        `token_metadata.minted_by_master_id` — when the master is
        revoked, this token (and every sibling) dies in cascade.

        Default TTL when called by a master key: 1 hour. When called
        by user JWT: 24 hours.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                ttl_hours: { type: integer, minimum: 1, default: 1 }
                allowed_subdomains: { type: array, items: { type: string } }
              required: [name]
      responses:
        "200":
          description: Project-scoped token minted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MintedToken" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /domains:
    get:
      tags: [domains]
      summary: List custom domains for the org
      responses:
        "200":
          description: Domain list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CustomDomain" }
    post:
      tags: [domains]
      summary: Claim a custom domain (issues DNS verification token)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { domain: { type: string, example: "tunnels.example.com" } }
              required: [domain]
      responses:
        "201":
          description: Created (pending verification)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomDomain" }

  /domains/{id}:
    delete:
      tags: [domains]
      summary: Release a domain claim
      parameters: [{ $ref: "#/components/parameters/DomainId" }]
      responses:
        "200": { description: Domain released }
        "404": { $ref: "#/components/responses/NotFound" }

  /domains/{id}/verify:
    post:
      tags: [domains]
      summary: Trigger DNS-TXT verification for this domain
      description: |
        Looks up the `_21tunnel-challenge.<domain>` TXT record and
        compares it to the `verificationToken` returned at claim time.
        Idempotent — re-running after success is a no-op.
      parameters: [{ $ref: "#/components/parameters/DomainId" }]
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomDomain" }
        "400":
          description: TXT record missing or wrong value
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /domains/{id}/bind:
    put:
      tags: [domains]
      summary: Bind a verified domain to a tunnel
      parameters: [{ $ref: "#/components/parameters/DomainId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { tunnel_id: { type: string, format: uuid } }
              required: [tunnel_id]
      responses:
        "200":
          description: Bound
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomDomain" }
        "400":
          description: Domain not yet verified
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [domains]
      summary: Unbind a domain from its tunnel
      parameters: [{ $ref: "#/components/parameters/DomainId" }]
      responses:
        "200": { description: Unbound }
        "404": { $ref: "#/components/responses/NotFound" }

  /reserved-subdomains:
    get:
      tags: [reserved-subdomains]
      summary: List reserved subdomains for the org
      responses:
        "200":
          description: Reserved-subdomain list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ReservedSubdomain" }
    post:
      tags: [reserved-subdomains]
      summary: Claim a globally-unique reserved subdomain
      description: |
        Persistent claims — survive agent restarts. Subject to plan
        quota (`max_reserved_subdomains`). On Free this is 0; on Pro
        it's 5; on Team it's unlimited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                subdomain: { type: string, pattern: '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' }
              required: [subdomain]
      responses:
        "201":
          description: Reserved
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReservedSubdomain" }
        "402":
          description: Plan quota exceeded
        "409":
          description: Already taken (globally unique)

  /reserved-subdomains/{id}:
    delete:
      tags: [reserved-subdomains]
      summary: Release a reserved subdomain
      parameters: [{ $ref: "#/components/parameters/ReservedSubdomainId" }]
      responses:
        "200": { description: Released }
        "404": { $ref: "#/components/responses/NotFound" }

  /connections:
    get:
      tags: [connections]
      summary: List live agent connections for this org
      description: |
        Snapshot of in-memory connection table on the server. Returns
        per-connection `agent_id`, `remote_ip`, `connected_at`, and
        the tunnel IDs registered through that connection.
      responses:
        "200":
          description: Connections list
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    agent_id: { type: string, format: uuid }
                    remote_ip: { type: string }
                    connected_at: { type: string, format: date-time }
                    tunnels: { type: array, items: { type: string, format: uuid } }

  /connections/{id}:
    delete:
      tags: [connections]
      summary: Force-disconnect a live agent
      description: |
        Terminates the TLS+yamux session immediately. Agent will
        auto-reconnect within ~5s unless its token is also revoked.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid, description: "agent_id from /connections" }
      responses:
        "200": { description: Disconnected }
        "404": { $ref: "#/components/responses/NotFound" }

  /inspector:
    get:
      tags: [inspector]
      summary: List recent inbound requests captured by the inspector
      description: |
        Returns request metadata (method, path, status, latency,
        size). Retention is plan-dependent: 7d Free, 30d Pro, 90d
        Team, 365d Enterprise.
      parameters:
        - in: query
          name: tunnel_id
          schema: { type: string, format: uuid }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: Request list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/InspectedRequest" }

  /inspector/{id}:
    get:
      tags: [inspector]
      summary: Get full request/response bodies for a captured event
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Full event
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/InspectedRequest"
                  - type: object
                    properties:
                      request_body: { type: string, description: "Base64 of raw body bytes (binary-safe)." }
                      response_body: { type: string, description: "Base64." }
                      request_headers: { type: object, additionalProperties: { type: string } }
                      response_headers: { type: object, additionalProperties: { type: string } }
        "404":
          $ref: "#/components/responses/NotFound"

  /inspector/{id}/replay:
    post:
      tags: [inspector]
      summary: Re-send a captured request against its tunnel
      description: |
        Useful for webhook debugging — replay a Stripe/GitHub call
        against your local handler without waiting for the next live
        event. Feature-flagged off by default (`api.allow_replay`);
        when on, a per-tunnel allowlist still gates which tunnels can
        be targeted.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Replayed
        "501":
          description: Replay not enabled on this server

  /inspector/ws:
    get:
      tags: [inspector]
      summary: WebSocket stream of live inbound requests
      description: |
        Upgrade from HTTP to WebSocket. Authentication via the
        `Sec-WebSocket-Protocol: bearer.<jwt>` header (cookies don't
        survive the upgrade reliably). Streams `InspectedRequest`
        JSON messages.
      responses:
        "101":
          description: WebSocket upgrade
        "401":
          $ref: "#/components/responses/Unauthorized"

  /metrics:
    get:
      tags: [metrics]
      summary: Aggregated dashboard counters
      responses:
        "200":
          description: Counters snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_tunnels: { type: integer }
                  active_tunnels: { type: integer }
                  requests_last_24h: { type: integer }
                  bytes_in_last_24h: { type: integer }
                  bytes_out_last_24h: { type: integer }

  /metrics/timeseries:
    get:
      tags: [metrics]
      summary: Time-bucketed counters for charting
      parameters:
        - in: query
          name: metric
          required: true
          schema: { type: string, enum: [requests, bytes_in, bytes_out] }
        - in: query
          name: window
          schema: { type: string, enum: [1h, 24h, 7d, 30d], default: 24h }
      responses:
        "200":
          description: Bucketed series
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    timestamp: { type: string, format: date-time }
                    value: { type: number }

  /metrics/prometheus:
    get:
      tags: [metrics]
      summary: Prometheus-format scrape endpoint (text exposition format)
      description: |
        Returns `text/plain` Prometheus exposition. Org-scoped — each
        org sees only its own counters. Self-hosted operators
        typically point a Prometheus job at this URL.
      responses:
        "200":
          description: Prometheus text
          content:
            text/plain:
              schema: { type: string }

  /edge-auth/start:
    get:
      tags: [edge-auth]
      summary: Initiate Google OAuth for a tunnel's edge gate (visitor flow)
      description: |
        Called by visitors hitting a tunnel with edge-auth enabled.
        Redirects to Google; on consent, redirects back to
        `/edge-auth/callback`, then to the original tunnel URL.
      security: []
      parameters:
        - in: query
          name: tunnel_id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: return_to
          schema: { type: string }
      responses:
        "302": { description: Redirect to Google }

  /edge-auth/callback:
    get:
      tags: [edge-auth]
      summary: Google OAuth callback for tunnel edge gate
      security: []
      parameters:
        - in: query
          name: code
          required: true
          schema: { type: string }
        - in: query
          name: state
          required: true
          schema: { type: string }
      responses:
        "302": { description: Redirect back to the gated tunnel URL with auth cookie set }
        "403": { description: Visitor's Google email not in the tunnel's allowlist }

  /billing/checkout-session:
    post:
      tags: [billing]
      summary: Create a Stripe Checkout session for plan upgrade
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                plan: { type: string, enum: [pro, team], description: "Target plan." }
                billing_cycle: { type: string, enum: [monthly, yearly], default: monthly }
              required: [plan]
      responses:
        "200":
          description: Checkout URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  checkout_url: { type: string }
        "402":
          description: Billing not configured on this server (self-hosted without Stripe)

  /billing/portal:
    post:
      tags: [billing]
      summary: Create a Stripe Customer Portal session
      description: |
        Redirects the user to Stripe's hosted portal to manage their
        subscription, payment methods, and invoices.
      responses:
        "200":
          description: Portal URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  portal_url: { type: string }

  /billing/cancel:
    post:
      tags: [billing]
      summary: Cancel subscription (mock-mode only — real customers use the portal)
      description: |
        Active only when the server is running in `billing_mode = "mock"`
        (no Stripe). In `stripe` mode this returns 405; cancellation
        must happen via `/billing/portal`.
      responses:
        "200": { description: Cancelled (mock) }
        "405": { description: Use `/billing/portal` in Stripe mode }

  /webhooks/stripe:
    post:
      tags: [webhooks]
      summary: Stripe webhook inbound (subscription state changes)
      description: |
        Stripe-signed webhook. Verifies the `Stripe-Signature` header
        against the configured webhook secret. Drives plan upgrades,
        downgrades, and dunning state transitions on the org row.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: "Raw Stripe event payload." }
      responses:
        "200": { description: Event processed }
        "400": { description: Signature verification failed }

  /invitations/accept:
    post:
      tags: [orgs]
      summary: Accept a pending org membership invitation (public)
      description: |
        Public endpoint — invitee follows the email link and posts the
        token here. Server validates the token, creates the user row
        if needed, and adds them to the inviting org.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string }
                password: { type: string, minLength: 12, description: "Required only if the user is being created for the first time." }
              required: [token]
      responses:
        "200":
          description: Accepted (and signed in, if password supplied)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "400":
          $ref: "#/components/responses/BadRequest"

  /events:
    get:
      tags: [events]
      summary: Org-scoped audit log
      description: |
        Returns audit rows for the active org. Tenant users see only
        their own org; superadmins see global. Personal auth events
        (forgot/reset password) live on `/me/events` instead.
      parameters:
        - in: query
          name: action
          schema: { type: string }
          description: Comma-separated action names (e.g. `login,signup`).
        - in: query
          name: actor
          schema: { type: string }
          description: Free-text substring on actor email.
        - in: query
          name: start_time
          schema: { type: string, format: date-time }
          description: RFC 3339 lower bound on `timestamp` (inclusive).
        - in: query
          name: end_time
          schema: { type: string, format: date-time }
          description: RFC 3339 upper bound (exclusive).
        - in: query
          name: resource_type
          schema: { type: string }
          description: Filter by `resource_type` (e.g. `tunnel`, `user`).
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
      responses:
        "200":
          description: Event list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AuditLogEntry" }

  /me/events:
    get:
      tags: [events]
      summary: Personal security activity (your events across all orgs)
      description: |
        Returns audit rows where `actor_id = current_user`, including
        NULL-org rows (`forgot_password`, `reset_password`) that the
        org-scoped `/events` endpoint correctly hides. Powers the
        "Recent activity" panel on `/profile`.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Personal events
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AuditLogEntry" }

  /orgs/current:
    get:
      tags: [orgs]
      summary: Active organization for the current user
      responses:
        "200":
          description: Org metadata + members
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Organization" }

  /orgs/current/members:
    get:
      tags: [orgs]
      summary: List members of the active org
      responses:
        "200":
          description: Members list
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    user_id: { type: string, format: uuid }
                    email: { type: string, format: email }
                    role: { type: string, enum: [owner, admin, member, viewer] }
                    joined_at: { type: string, format: date-time }

  /orgs/current/members/{user_id}:
    parameters:
      - in: path
        name: user_id
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [orgs]
      summary: Change a member's role
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { type: string, enum: [owner, admin, member, viewer] }
              required: [role]
      responses:
        "200": { description: Role updated }
        "403": { description: Only owners can promote to owner }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [orgs]
      summary: Remove a member from the org
      responses:
        "200": { description: Removed }
        "404": { $ref: "#/components/responses/NotFound" }

  /orgs/current/invitations:
    get:
      tags: [orgs]
      summary: List pending invitations for the active org
      responses:
        "200":
          description: Invitations list
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    email: { type: string, format: email }
                    role: { type: string }
                    invited_at: { type: string, format: date-time }
                    expires_at: { type: string, format: date-time }
    post:
      tags: [orgs]
      summary: Invite someone to the org by email
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [admin, member, viewer], default: member }
              required: [email]
      responses:
        "201":
          description: Invitation sent
        "402":
          description: "Plan member quota exceeded (Free=1 user, Pro=3, Team=20)"

  /orgs/current/invitations/{id}:
    delete:
      tags: [orgs]
      summary: Revoke a pending invitation
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: Invitation revoked }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  security:
    - bearerAuth: []
  parameters:
    TunnelId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    SessionId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    DomainId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    ReservedSubdomainId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    Unauthorized:
      description: Unauthorized (missing / invalid bearer)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    NotFound:
      description: Not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/ApiError"
              - type: object
                properties:
                  retry_after_seconds: { type: integer, example: 12 }
  schemas:
    ApiError:
      type: object
      properties:
        error: { type: string, description: "Machine-readable code, e.g. `invalid_credentials`." }
        message: { type: string, description: "Human-readable explanation." }
      required: [message]
    StatusResponse:
      type: object
      properties:
        status: { type: string }
        message: { type: string }
    SignupRequest:
      type: object
      properties:
        email: { type: string, format: email }
        password: { type: string, minLength: 12 }
        organization_name: { type: string }
      required: [email, password]
    LoginRequest:
      type: object
      properties:
        email: { type: string, format: email }
        password: { type: string }
      required: [email, password]
    LoginResponse:
      type: object
      properties:
        access_token: { type: string, description: "15-minute JWT." }
        user: { $ref: "#/components/schemas/User" }
        active_org: { $ref: "#/components/schemas/OrgRef" }
        is_superadmin: { type: boolean }
    AccountSummary:
      type: object
      properties:
        user: { $ref: "#/components/schemas/User" }
        active_org: { $ref: "#/components/schemas/OrgRef" }
        is_superadmin: { type: boolean }
        entitlements: { type: object, description: "Plan + trial state, used by the SPA to gate UI." }
    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        last_login_at: { type: string, format: date-time, nullable: true }
        signup_method: { type: string, enum: [password, google], nullable: true }
    OrgRef:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        role: { type: string, enum: [owner, admin, member] }
        approval_status: { type: string, enum: [pending, approved, rejected, suspended] }
    Organization:
      allOf:
        - $ref: "#/components/schemas/OrgRef"
        - type: object
          properties:
            members:
              type: array
              items:
                type: object
                properties:
                  user_id: { type: string, format: uuid }
                  email: { type: string, format: email }
                  role: { type: string }
    Session:
      type: object
      properties:
        id: { type: string, format: uuid }
        ip_address: { type: string, nullable: true }
        user_agent: { type: string, nullable: true }
        last_seen_at: { type: string, format: date-time }
        current: { type: boolean, description: "True for the session backing this request." }
    Tunnel:
      type: object
      properties:
        id: { type: string, format: uuid, description: "Wire tunnel_id; same as `tunnels.id`." }
        subdomain: { type: string }
        publicUrl: { type: string }
        status: { type: string, enum: [active, inactive] }
        createdAt: { type: string, format: date-time }
        lastActiveAt: { type: string, format: date-time, nullable: true }
        bytesIn: { type: integer }
        bytesOut: { type: integer }
        totalRequests: { type: integer }
        authEnabled: { type: boolean }
        localPort: { type: integer }
        protocol: { type: string, enum: [http, https, tcp] }
    CapabilityToken:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        nonce: { type: string, description: "Hex; pass to `DELETE /tokens/<nonce>` to revoke." }
        subject: { type: string, description: "Hex of the token's signing subject." }
        allowedSubdomains: { type: array, items: { type: string } }
        bandwidthLimitMbps: { type: integer }
        tunnelQuota: { type: integer }
        issuedAt: { type: integer, description: "Unix seconds." }
        expiresAt: { type: integer, description: "Unix seconds." }
        revoked: { type: boolean }
    MintedToken:
      allOf:
        - $ref: "#/components/schemas/CapabilityToken"
        - type: object
          properties:
            wireBytes:
              type: string
              description: Base64-encoded bincode. Save to a file for `mytunnel --token-file`.
            wireBytesLen: { type: integer }
    CustomDomain:
      type: object
      properties:
        id: { type: string, format: uuid }
        domain: { type: string }
        verified: { type: boolean }
        verificationToken: { type: string, description: "TXT record value to publish for ACME-style proof." }
        boundTunnelId: { type: string, format: uuid, nullable: true }
    AuditLogEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        timestamp: { type: string, format: date-time }
        organizationId: { type: string, format: uuid, nullable: true }
        userId: { type: string, format: uuid, nullable: true }
        userEmail: { type: string, format: email, nullable: true }
        action: { type: string, example: "tunnel_created" }
        resourceType: { type: string }
        resourceId: { type: string }
        details: { type: object, nullable: true }
        ipAddress: { type: string }
        userAgent: { type: string }
    Project:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string, description: "1-63 chars, [a-z0-9-], no leading/trailing dash." }
        name: { type: string }
        is_default: { type: boolean, description: "True for the org's auto-created default project." }
        created_at: { type: string, format: date-time }
        deleted_at: { type: string, format: date-time, nullable: true }
    ReservedSubdomain:
      type: object
      properties:
        id: { type: string, format: uuid }
        subdomain: { type: string }
        organization_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        bound_tunnel_id: { type: string, format: uuid, nullable: true, description: "Currently-bound tunnel, if any." }
    InspectedRequest:
      type: object
      properties:
        id: { type: string, format: uuid }
        tunnel_id: { type: string, format: uuid }
        timestamp: { type: string, format: date-time }
        method: { type: string, example: "POST" }
        path: { type: string, example: "/webhooks/stripe" }
        status: { type: integer, example: 200 }
        latency_ms: { type: integer }
        request_size: { type: integer }
        response_size: { type: integer }
        client_ip: { type: string }
        host: { type: string, description: "Public host the request was made against." }

security:
  - bearerAuth: []
