openapi: 3.1.0
info:
  title: 21tunnel API
  version: "0.3.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: private-networks
    description: |
      Managed relay-native WireGuard networks (Private VPN). 21tunnel generates
      WG server/client configs and relays the server's UDP port; the WG server
      runs on the customer's own box. Free on all plans (member role); only
      public keys are stored.
  - 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: Dodo Payments Checkout, customer portal, mock-mode cancellation
  - name: webhooks
    description: Inbound webhooks (Dodo Payments billing events)
  - name: webhook-receivers
    description: |
      Agent-grade tenant webhook ingestion (Phase 2). Customers provision
      per-receiver URLs, vendors POST signed deliveries, agents poll
      validated events. Validators: github, slack, generic_hmac_sha256, dodo.

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/oauth/github/start:
    get:
      tags: [auth]
      summary: Initiate GitHub OAuth signup/login
      description: |
        Redirects the browser to GitHub's authorize screen. After
        consent, GitHub calls back `/auth/oauth/github/callback`. Use
        `?return_to=/some/path` to control the post-auth destination.

        Returns 503 if the server has no GitHub OAuth credentials
        configured (`oauth.github.client_id` empty).
      security: []
      parameters:
        - in: query
          name: return_to
          schema: { type: string, default: "/" }
      responses:
        "302": { description: Redirect to GitHub }
        "503": { description: GitHub OAuth not configured on this server }

  /auth/oauth/github/callback:
    get:
      tags: [auth]
      summary: GitHub OAuth callback — completes signup/login
      description: |
        GitHub 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.

        If the GitHub account has no verified primary email,
        returns 403 with `error=email_not_verified`. Same find-or-create
        flow as Google OAuth — an existing email-matched account gets
        linked rather than duplicated.
      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 }
        "400": { description: Bad state cookie / state mismatch }
        "403": { description: No verified primary email on the GitHub account }
        "503": { description: GitHub OAuth not configured }

  /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:
                localPort:
                  type: integer
                  minimum: 1
                  maximum: 65535
                  description: Local TCP port the tunnel forwards to. Required.
                subdomain:
                  type: string
                  description: >-
                    Optional. Omit to auto-generate a random subdomain.
                    Must be 1..=63 chars of [a-z0-9-] with no leading/trailing dash.
                protocol:
                  type: string
                  enum: [http, https, websocket, tcp]
                  default: http
                  description: Optional. Defaults to http.
                ttlSeconds:
                  type: integer
                  description: >-
                    Optional auto-expiry in seconds, capped at the plan max.
                    Omit for the plan default (Free 1h, Pro 24h, Team 7d).
                projectSlug:
                  type: string
                  description: Optional project to create the tunnel under. Omit for the org default project.
              required: [localPort]
      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" }

  /tunnels/{id}/policy:
    get:
      tags: [tunnels, traffic-policy]
      summary: Read the per-tunnel traffic policy
      description: |
        Returns the current policy (or `null` when none is set). The
        policy applies at the public HTTP edge BEFORE traffic reaches
        the agent — so denies and rate-limits never burn customer
        bandwidth, and header injections are seen by the upstream
        service exactly as a regular client would set them.
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200":
          description: Current policy
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  policy:
                    nullable: true
                    $ref: "#/components/schemas/TrafficPolicy"
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [tunnels, traffic-policy]
      summary: Set / replace the per-tunnel traffic policy
      description: |
        Replaces any existing policy. Validated server-side; bad
        shapes return 400 `bad_policy` with a human-readable
        `message`. Takes effect on the NEXT request — the in-memory
        registry hot-cache flips synchronously.

        Four actions:
          - `header_set`     — inject `{name}: {value}` into the request
            forwarded to the agent (overwrites if already present).
          - `deny`           — 403 any request whose URI path starts with
            `path_prefix`.
          - `jwt_validation` — 401 unless the request carries a valid
            bearer JWT (signature + `exp`, plus optional issuer/audience
            allowlists). One per policy. Runs before `rate_limit`.
          - `rate_limit`     — 429 once `requests_per_minute` is
            exceeded in the current 60-second window. One per policy.
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TrafficPolicy" }
      responses:
        "200": { description: Policy installed }
        "400":
          description: Policy failed validation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [tunnels, traffic-policy]
      summary: Drop the per-tunnel traffic policy
      parameters: [{ $ref: "#/components/parameters/TunnelId" }]
      responses:
        "200": { description: Policy cleared }
        "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/{id}/budget:
    put:
      tags: [tokens]
      summary: Set or clear the monthly USD budget cap on an existing token
      description: |
        Lets an org owner / admin edit the budget cap on an issued token
        without rotating. Atomic ceiling — agents using the token cannot
        raise their own cap; only this endpoint can.

        Passing `monthlyBudgetUsdCents = 0` blocks all spend on this key
        immediately (useful for incident response). Passing `null` clears
        the cap (uncapped).

        Range: `0..=1_000_000_000` (up to $10M / month) or `null`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, description: "Token nonce in hex (same id as revoke / rotate)." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [monthlyBudgetUsdCents]
              properties:
                monthlyBudgetUsdCents:
                  type: integer
                  format: int64
                  nullable: true
                  description: "USD cents. `null` clears the cap."
      responses:
        "200":
          description: Budget updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  monthlyBudgetUsdCents: { type: integer, format: int64, nullable: true }
        "400":
          description: Value out of range
        "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" }

  /private-networks:
    get:
      tags: [private-networks]
      summary: List the org's private networks
      description: |
        Managed relay-native WireGuard networks (Private VPN). Available on ALL
        plans — requires only an authenticated member (no Pro/entitlement gate).
      responses:
        "200":
          description: Private-network list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/PrivateNetwork" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [private-networks]
      summary: Create a private network
      description: |
        Reserves an overlay subnet. Keys are generated on the client; only the
        public key is ever stored. Free on all plans (member role).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateNetworkRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivateNetwork" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: A network with that name already exists in the org
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /private-networks/{id}:
    get:
      tags: [private-networks]
      summary: Get one private network
      parameters: [{ $ref: "#/components/parameters/NetworkId" }]
      responses:
        "200":
          description: Private network
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivateNetwork" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [private-networks]
      summary: Delete a private network
      description: Frees its reserved relay UDP port and cascades to enrolled devices.
      parameters: [{ $ref: "#/components/parameters/NetworkId" }]
      responses:
        "200": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /private-networks/{id}/server:
    put:
      tags: [private-networks]
      summary: Register the WireGuard server identity
      description: |
        Stores the server's PUBLIC key and reserves a sticky relay UDP port,
        deriving `public_endpoint = <tcp_public_host>:<reserved_udp_port>`. Pass
        `public_endpoint` only to self-relay. The private key never leaves the
        customer's box.
      parameters: [{ $ref: "#/components/parameters/NetworkId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetServerRequest" }
      responses:
        "200":
          description: Server registered; endpoint reserved
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PrivateNetwork" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /private-networks/{id}/server-config:
    get:
      tags: [private-networks]
      summary: Render the server wg0.conf
      description: |
        Returns `wg0.conf` text with a `<PASTE_SERVER_PRIVATE_KEY>` placeholder,
        the server `.1` address, ListenPort, a `# mytunnel udp` relay hint, and a
        `[Peer]` per enrolled client. 409 until the server identity is set.
      parameters: [{ $ref: "#/components/parameters/NetworkId" }]
      responses:
        "200":
          description: wg0.conf text
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WgConfigResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Server identity not yet set
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /private-networks/{id}/devices:
    get:
      tags: [private-networks]
      summary: List enrolled clients
      parameters: [{ $ref: "#/components/parameters/NetworkId" }]
      responses:
        "200":
          description: Device list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/WgDevice" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [private-networks]
      summary: Enroll a client (device)
      description: |
        Allocates the next free overlay `/32` and records the client's PUBLIC
        key. Idempotent by public key. Keys are generated on-device.
      parameters: [{ $ref: "#/components/parameters/NetworkId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EnrollDeviceRequest" }
      responses:
        "201":
          description: Enrolled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnrollDeviceResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: No free addresses left in the network CIDR
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /private-networks/{id}/devices/{device_id}:
    delete:
      tags: [private-networks]
      summary: Revoke a client
      parameters:
        - { $ref: "#/components/parameters/NetworkId" }
        - { $ref: "#/components/parameters/DeviceId" }
      responses:
        "200": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /private-networks/{id}/devices/{device_id}/config:
    get:
      tags: [private-networks]
      summary: Render a client .conf
      description: |
        Returns the client WireGuard `.conf` text (Interface with a private-key
        placeholder + Address, Peer = server public key, Endpoint = relayed
        `host:port`, AllowedIPs = subnet, PersistentKeepalive=25).
      parameters:
        - { $ref: "#/components/parameters/NetworkId" }
        - { $ref: "#/components/parameters/DeviceId" }
      responses:
        "200":
          description: Client .conf text
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WgConfigResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "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 Dodo/GitHub/Slack 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 Dodo Payments 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 Dodo Payments)

  /billing/portal:
    post:
      tags: [billing]
      summary: Create a Dodo Payments Customer Portal session
      description: |
        Redirects the user to Dodo Payments' 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 Dodo). In `dodo` mode this returns 405; cancellation
        must happen via `/billing/portal`.
      responses:
        "200": { description: Cancelled (mock) }
        "405": { description: Use `/billing/portal` in Dodo mode }

  /webhooks/dodo:
    post:
      tags: [webhooks]
      summary: Dodo Payments webhook inbound (subscription state changes)
      description: |
        Standard-Webhooks-signed inbound from Dodo Payments. Verifies
        `webhook-id`, `webhook-timestamp`, and `webhook-signature`
        headers against the configured signing secret. Drives plan
        upgrades, downgrades, and dunning state transitions on the org
        row. Replaces the legacy `/webhooks/stripe` endpoint (removed
        2026-06-08 when billing migrated to Dodo).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: "Raw Dodo Payments 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" }

  # ----------------------------------------------------------------------
  # Webhook receivers (Phase 2 / 2.5, 2026-06-08)
  # ----------------------------------------------------------------------
  /webhook-receivers:
    get:
      tags: [webhook-receivers]
      summary: List active webhook receivers for the active org
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  receivers:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookReceiver" }
    post:
      tags: [webhook-receivers]
      summary: Provision a new webhook receiver
      description: |
        Returns `{id, url}` — paste `url` into the vendor's webhook
        configuration. Validators supported in v1: `github`, `slack`,
        `generic_hmac_sha256`, `dodo`.

        For `generic_hmac_sha256`, also supply `signature_header` (the
        inbound header name carrying the signature) and `signature_encoding`
        (`hex` or `base64`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [validator, secret]
              properties:
                validator:
                  type: string
                  enum: [github, slack, generic_hmac_sha256, dodo]
                secret:
                  type: string
                  description: "HMAC signing secret from the vendor."
                name:
                  type: string
                signature_header:
                  type: string
                  description: "Required only for `generic_hmac_sha256`."
                signature_encoding:
                  type: string
                  enum: [hex, base64]
                  description: "Required only for `generic_hmac_sha256`."
                expires_at:
                  type: string
                  format: date-time
                  nullable: true
      responses:
        "200":
          description: Receiver created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookReceiver" }
        "400":
          description: Validation failed

  /webhook-receivers/{id}:
    delete:
      tags: [webhook-receivers]
      summary: Soft-delete a webhook receiver
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhook-receivers/{id}/events:
    get:
      tags: [webhook-receivers]
      summary: Poll validated webhook deliveries
      description: |
        Returns events oldest-first. `since` filters to deliveries with
        `received_at > since`. `mark_polled=false` reads without
        acknowledging (next poll returns the same events).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: since
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          schema: { type: integer, default: 100, maximum: 1000 }
        - in: query
          name: mark_polled
          schema: { type: boolean, default: true }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEvent" }

  /webhooks/{receiver_id}:
    post:
      tags: [webhook-receivers]
      summary: Public ingestion endpoint — third parties POST here
      description: |
        Unauthenticated — the configured per-receiver HMAC signature
        IS the authentication. We always return 200 on a known receiver
        (vendor retry queues stop on first 2xx), even when the signature
        is invalid. Inspect `signature_valid` in the response (and on the
        stored event) to decide whether to trust the payload.

        Idempotent: vendor delivery IDs (e.g. `x-github-delivery`,
        `webhook-id`) dedup retries. Body sha256 is the fallback dedup key.
      parameters:
        - in: path
          name: receiver_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Stored (signature_valid may be true or false)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "received" }
                  signature_valid: { type: boolean }
                  dedup_key: { type: string }
                  request_id: { type: string, format: uuid }
        "404":
          description: Unknown / deleted / expired receiver

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 }
    NetworkId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    DeviceId:
      in: path
      name: device_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" }
    Forbidden:
      description: Forbidden (insufficient role)
      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]
    WebhookReceiver:
      type: object
      description: Agent-configured webhook ingestion endpoint (Phase 2).
      properties:
        id: { type: string, format: uuid }
        url: { type: string, description: "Public ingestion URL — paste into vendor webhook config." }
        validator:
          type: string
          enum: [github, slack, generic_hmac_sha256, dodo]
        name: { type: string }
        signature_header:
          type: string
          nullable: true
          description: "Set only for generic_hmac_sha256."
        signature_encoding:
          type: string
          enum: [hex, base64]
          nullable: true
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
      required: [id, url, validator, name, created_at]
    WebhookEvent:
      type: object
      description: One inbound webhook delivery captured by a receiver.
      properties:
        id: { type: string, format: uuid }
        receiver_id: { type: string, format: uuid }
        signature_valid:
          type: boolean
          description: "True iff the configured validator verified the request signature."
        received_at: { type: string, format: date-time }
        polled_at: { type: string, format: date-time, nullable: true }
        headers:
          type: object
          additionalProperties: { type: string }
        body: { type: string }
        body_json:
          nullable: true
          description: "Pre-parsed JSON body when the body is JSON; null otherwise."
      required: [id, receiver_id, signature_valid, received_at, headers, body]
    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, udp], description: "Transport. `udp` allocates from the server's `udp_proxy_port_range` and exposes `udp://<subdomain>.<host>:<port>`; clients send UDP datagrams directly to that port." }
    TrafficPolicy:
      type: object
      description: |
        Per-tunnel traffic policy applied at the public HTTP edge.
        Stored in `tunnels.traffic_policy` as JSONB; NULL means no
        policy.
      properties:
        actions:
          type: array
          maxItems: 16
          items: { $ref: "#/components/schemas/PolicyAction" }
      required: [actions]
    PolicyAction:
      oneOf:
        - type: object
          required: [kind, name, value]
          properties:
            kind: { type: string, enum: [header_set] }
            name:
              type: string
              maxLength: 64
              pattern: '^[A-Za-z0-9_-]+$'
              example: "X-Forwarded-For"
            value:
              type: string
              maxLength: 1024
              description: "Verbatim header value. Must not contain CR or LF (validated server-side)."
        - type: object
          required: [kind, path_prefix]
          properties:
            kind: { type: string, enum: [deny] }
            path_prefix:
              type: string
              maxLength: 256
              pattern: '^/.*'
              example: "/admin"
        - type: object
          required: [kind, requests_per_minute]
          properties:
            kind: { type: string, enum: [rate_limit] }
            requests_per_minute:
              type: integer
              minimum: 1
              maximum: 60000
              example: 60
              description: |
                Fixed 60-second window. Once `requests_per_minute`
                requests have been served in the current window,
                further requests get 429 until the window rolls.
                At most one `rate_limit` per policy.
            per_client_ip:
              type: boolean
              default: false
              description: |
                When false (default), the budget is shared across the
                whole tunnel. When true, each distinct client IP gets its
                own independent budget — so one noisy caller can't exhaust
                everyone's quota. Client IP is resolved from
                `CF-Connecting-IP` / `X-Real-IP` / `X-Forwarded-For`
                (in that order).
        - type: object
          required: [kind, algorithm, key]
          properties:
            kind: { type: string, enum: [jwt_validation] }
            algorithm:
              type: string
              enum: [HS256, RS256]
              description: |
                Signature algorithm. `HS256` → `key` is the shared
                secret. `RS256` → `key` is the issuer's PEM-encoded
                RSA public key.
            key:
              type: string
              maxLength: 8192
              description: |
                HS256 shared secret, or RS256 PEM public key. For RS256
                the value must contain a `-----BEGIN` PEM header.
            allowed_issuers:
              type: array
              maxItems: 16
              items: { type: string }
              description: "Allowlist of acceptable `iss` claims. Empty = don't check issuer."
              example: ["https://your-tenant.auth0.com/"]
            allowed_audiences:
              type: array
              maxItems: 16
              items: { type: string }
              description: "Allowlist of acceptable `aud` claims. Empty = don't check audience."
              example: ["https://api.acme.com"]
          description: |
            Validate a bearer JWT (`Authorization: Bearer <jwt>`) at the
            edge before the request reaches the agent. Missing,
            malformed, expired, wrong-signature, or
            wrong-issuer/audience tokens get 401 with
            `WWW-Authenticate: Bearer`. Expiry (`exp`) is always
            enforced. At most one `jwt_validation` per policy. Key
            material is configured inline; JWKS auto-discovery is not
            yet supported.
    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/dodo" }
        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." }
    PrivateNetwork:
      type: object
      description: A managed relay-native WireGuard network (Private VPN). Only public keys are stored.
      properties:
        id: { type: string, format: uuid }
        organization_id: { type: string, format: uuid }
        name: { type: string, maxLength: 64 }
        cidr: { type: string, example: "10.99.0.0/24", description: "Overlay subnet (IPv4, prefix 8..=30). The server takes `.1`." }
        server_public_key: { type: string, nullable: true, description: "WG server public key (base64, 32B). Null until the server is registered." }
        public_endpoint: { type: string, nullable: true, example: "agent.21tunnel.com:31099", description: "Relayed public endpoint clients dial. Null until the server is set." }
        server_listen_port: { type: integer, nullable: true, example: 51820, description: "Port the WG server listens on locally." }
        reserved_udp_port: { type: integer, nullable: true, example: 31099, description: "Sticky relay UDP port reserved for this network." }
        created_at: { type: string, format: date-time }
    WgDevice:
      type: object
      description: An enrolled WireGuard client (phone/laptop). Only its public key is stored.
      properties:
        id: { type: string, format: uuid }
        private_network_id: { type: string, format: uuid }
        name: { type: string }
        public_key: { type: string, description: "Client WG public key (base64, 32B)." }
        assigned_ip: { type: string, example: "10.99.0.2", description: "Overlay /32 allocated to this client." }
        created_at: { type: string, format: date-time }
        last_seen_at: { type: string, format: date-time, nullable: true }
    CreateNetworkRequest:
      type: object
      properties:
        name: { type: string, maxLength: 64 }
        cidr: { type: string, example: "10.99.0.0/24", description: "IPv4 network, prefix 8..=30." }
      required: [name, cidr]
    EnrollDeviceRequest:
      type: object
      properties:
        name: { type: string }
        public_key: { type: string, description: "Client WG public key (base64, 32B), generated on-device." }
      required: [name, public_key]
    EnrollDeviceResponse:
      type: object
      properties:
        device: { $ref: "#/components/schemas/WgDevice" }
        network_cidr: { type: string, example: "10.99.0.0/24" }
      required: [device, network_cidr]
    SetServerRequest:
      type: object
      properties:
        server_public_key: { type: string, description: "WG server PUBLIC key (base64, 32B). The private key stays on the customer's box." }
        public_endpoint: { type: string, nullable: true, description: "Optional `host:port` override. Omit to let 21tunnel reserve a sticky relay UDP port and derive the endpoint." }
        server_listen_port: { type: integer, default: 51820 }
      required: [server_public_key]
    WgConfigResponse:
      type: object
      description: Rendered WireGuard config text (server `wg0.conf` or a client `.conf`). The private key is a placeholder to be filled in on-device.
      properties:
        config: { type: string }
      required: [config]

security:
  - bearerAuth: []
