openapi: 3.0.3
info:
  title: TunnelMesh Server API
  version: 1.0.0
  description: |
    Versioned management API for agents, policies, routes, tunnels and audit logs.

    Managed HTTP proxy routes (`protocol: http-proxy`, `tp-*` domains) are
    configured here but served on a separate entry listener. The proxy-specific
    statuses - 407 Proxy Authentication Required, 403 source or target denial,
    502 egress unavailable, 503 tunnel capacity exhausted or credential secret
    store unavailable, 504 connect timeout - are returned by that entry and never
    by `/api/v1`. See `docs/user-guide/http-proxy-entry.md` for the full table.

    Every `/api/v1` response carries a `Traceparent` header. An inbound one is reused
    when it is a valid W3C trace parent, otherwise a fresh trace is minted; the same
    id is stamped into the `traceId` field of any audit event the request writes, so a
    console error, a log line and an audit row line up without a correlation table.
servers:
  - url: /
paths:
  /api/v1/auth/login:
    post:
      summary: Login with a password and issue an opaque bearer token
      description: |
        Public endpoint; no bearer token is required. This is the v2 login entry point and it
        answers with one of three shapes, all inside the usual `{code,msg,data}` envelope.

        1. Authenticated - `data` carries `token`, `user` and `deviceTrusted`. That is the legacy
           shape plus `deviceTrusted`, so an existing client or script keeps working unchanged.
        2. Second factor required - HTTP 200 with `data.mfaRequired=true`, a single-use
           `challengeId`, the accepted `methods` and `expiresAt`. Complete it with
           `POST /api/v1/auth/mfa/verify`.
        3. Throttled - HTTP 429 with `data.error=login_throttled` and a `Retry-After` header in
           seconds.

        A wrong password, an unknown username, a disabled account, a deleted account and a
        passwordless SSO-only account all collapse into HTTP 401 `invalid_credentials`, so the
        response cannot be used to enumerate accounts. An unknown username still costs one
        Argon2id verification so the two stay indistinguishable by timing. The failure that trips
        the brute-force block is itself reported as an ordinary 401; only the next request is
        throttled.

        When `trustDevice` is true and the effective policy allows it, a trusted-device token is
        issued and set as an HttpOnly cookie whose `Max-Age` never outlives the stored device row.
        That token is a bearer secret: it is never echoed in a response body, never logged, and
        there is no reveal endpoint for it. Only its SHA-256 digest is stored. Asking for a
        trusted device while device trust is disabled is ignored and the login still succeeds with
        `deviceTrusted=false`. A valid trusted-device cookie presented on this request satisfies
        the second factor instead of raising a challenge, but only while the policy still allows
        the bypass.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/LoginRequest'}
      responses:
        '200':
          description: Authenticated, or a second factor is required before a session can be issued
          content:
            application/json:
              schema:
                oneOf:
                  - {$ref: '#/components/schemas/LoginSuccessEnvelope'}
                  - {$ref: '#/components/schemas/MFAChallengeEnvelope'}
        '400': {description: 'Invalid JSON, or a field the handler does not declare', content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '401': {description: invalid_credentials, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '405': {description: Method not allowed}
        '429': {description: 'login_throttled; carries a Retry-After header in seconds', content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '503': {description: identity_services_unavailable, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
  /api/v1/auth/me:
    get:
      security: [{ bearerAuth: [] }]
      responses: { '200': { description: Current principal } }
  /api/v1/auth/password:
    put:
      summary: Change the current user's password without revoking existing login tokens
      security: [{ bearerAuth: [] }]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PasswordChangeRequest'}}}}
      responses: {'200': {description: Password changed}, '400': {description: Password policy violation}, '401': {description: Unauthenticated}, '403': {description: Current password is invalid}}
  /api/v1/auth/mfa/verify:
    post:
      summary: Complete an MFA challenge raised by a password or OIDC login
      description: |
        Public endpoint. Accepts one current TOTP code or one single-use recovery code against the
        `challengeId` returned by `POST /api/v1/auth/login`. On success the response is exactly the
        authenticated-login shape, including a newly issued trusted device when `trustDevice` was
        requested and the policy allows it.

        The challenge is single-use and bounded by a short TTL and an attempt budget. A wrong code
        burns one attempt; when the budget is exhausted the challenge dies with
        `mfa_attempts_exceeded` and the caller must start the login over. Replaying a TOTP step that
        was already accepted returns `mfa_code_reused`. A missing, expired or already consumed
        challenge returns `mfa_challenge_invalid`. Only a wrong second factor burns the budget: a
        missing enrollment is a setup problem and charging it would lock out an account an
        administrator just forced into required mode.

        The challenge is consumed only after the code verifies, and the consume result is checked,
        so two racing requests against the same challenge cannot both succeed. The challenge
        identifier is a bearer secret for the remainder of the login and is never written to the
        audit log.

        A recovery-code login sets `recoveryCodesExhausted=true` in the success payload when it
        consumed the last remaining bypass code. That is the only moment the user learns it.
      security: []
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/MFAVerifyRequest'}}}}
      responses:
        '200': {description: Authenticated, content: {application/json: {schema: {$ref: '#/components/schemas/LoginSuccessEnvelope'}}}}
        '400': {description: 'Invalid JSON, or a field the handler does not declare', content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '401': {description: 'mfa_code_invalid, mfa_code_reused, mfa_challenge_invalid, mfa_attempts_exceeded, mfa_not_enrolled or invalid_credentials', content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable or secret_storage_unavailable, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
  /api/v1/auth/mfa:
    get:
      summary: Read the caller's own second-factor status and the policy that applies to it
      description: |
        `status` is `none`, `pending` (an enrollment exists but was never confirmed with a live
        code, so it does not yet satisfy the policy) or `enabled`. `policy.mode` is the global
        operator setting and `policy.required` is the effective decision for this account: true
        when the global mode is `required`, when the mode is `optional` and a factor is enabled, or
        when the account carries the per-account `mfaRequired` override.

        The response never carries the shared secret or any code. `remainingRecoveryCodes` is a
        count only, because the codes are stored as one-way hashes.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: MFA status, content: {application/json: {schema: {$ref: '#/components/schemas/MFAStatusEnvelope'}}}}
        '401': {description: Unauthenticated}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
    delete:
      summary: Remove the caller's second factor after proving possession of it
      description: |
        Requires one live proof of possession in the request body, a current TOTP code or a
        recovery code, because removing a second factor is the highest-impact self-service action.
        It also revokes every trusted device for the account, since each one was trusted on the
        strength of the factor being removed.

        Refused with `mfa_required_by_policy` while the effective policy still demands a second
        factor, and with `mfa_not_confirmed` while the enrollment is still `pending`. No
        `Idempotency-Key` is required: this is a self-service mutation, not an administrative one.
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/MFACodeRequest'}}}}
      responses:
        '200': {description: Second factor removed; data.status is none}
        '400': {description: 'Invalid JSON, or a field the handler does not declare'}
        '401': {description: 'Unauthenticated, mfa_code_invalid, mfa_code_reused or mfa_not_enrolled'}
        '405': {description: Method not allowed}
        '409': {description: mfa_required_by_policy or mfa_not_confirmed}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/auth/mfa/enroll:
    post:
      summary: Start a TOTP enrollment and receive the shared secret and recovery codes once
      description: |
        Creates a `pending` enrollment and returns the base32 shared secret, an `otpauth://` URL
        for QR scanning, the recovery codes and the enrollment deadline. The response is
        `Cache-Control: no-store`.

        The secret and the recovery codes are returned exactly once and are never readable again.
        The secret is sealed with AES-256-GCM before it reaches storage and the recovery codes are
        stored as one-way hashes. There is no reveal endpoint for either of them, unlike
        `POST /api/v1/tokens/{tokenId}/reveal`; losing them means re-enrolling. Neither value ever
        reaches the audit log, which records only the TOTP digits, period and code count.

        An account that has a local password must re-prove it with `currentPassword`, so a hijacked
        session cannot be made durable by enrolling an attacker's authenticator. A passwordless
        SSO-only account has no password to re-prove and may omit the body entirely. Enrollment
        fails closed with `secret_storage_unavailable` when no encryption key is configured, rather
        than degrading to plaintext storage.
      security: [{bearerAuth: []}]
      requestBody: {required: false, content: {application/json: {schema: {$ref: '#/components/schemas/MFAEnrollRequest'}}}}
      responses:
        '200': {description: 'Pending enrollment; response uses Cache-Control no-store', content: {application/json: {schema: {$ref: '#/components/schemas/MFAEnrollmentEnvelope'}}}}
        '400': {description: 'current_password_required, invalid JSON, or an unknown sub-resource'}
        '401': {description: Unauthenticated}
        '403': {description: current_password_invalid}
        '404': {description: Method not allowed on this sub-resource}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/auth/mfa/enable:
    post:
      summary: Confirm a pending enrollment with one live TOTP code
      description: |
        Moves `status` from `pending` to `enabled`. The code is verified against the stored secret
        and the TOTP step is recorded, so replaying a step that was already accepted returns
        `mfa_code_reused`. A pending enrollment that is never confirmed expires and stops being
        usable.
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/MFACodeRequest'}}}}
      responses:
        '200': {description: 'Enabled; data carries status and enabledAt'}
        '400': {description: 'Invalid JSON, or a field the handler does not declare'}
        '401': {description: 'Unauthenticated, mfa_code_invalid, mfa_code_reused or mfa_not_enrolled'}
        '404': {description: Method not allowed on this sub-resource}
        '409': {description: mfa_already_enabled}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/auth/mfa/recovery-codes:
    post:
      summary: Replace the recovery codes of an enabled second factor and return the new ones once
      description: |
        Requires a current TOTP code, invalidates every previously issued recovery code and returns
        the new plaintext codes exactly once with `Cache-Control: no-store`. The codes are stored
        as one-way hashes, so they are never re-readable and there is no reveal endpoint for them.
        Refused with `mfa_not_confirmed` while the enrollment is still `pending`.
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/MFACodeRequest'}}}}
      responses:
        '200': {description: 'Replacement codes; response uses Cache-Control no-store', content: {application/json: {schema: {$ref: '#/components/schemas/RecoveryCodesEnvelope'}}}}
        '400': {description: 'Invalid JSON, or a field the handler does not declare'}
        '401': {description: 'Unauthenticated, mfa_code_invalid, mfa_code_reused or mfa_not_enrolled'}
        '404': {description: Method not allowed on this sub-resource}
        '409': {description: mfa_not_confirmed}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/auth/devices:
    get:
      summary: List the caller's own active trusted devices
      description: |
        Returns only unrevoked and unexpired devices. `current` marks the device that presented the
        trusted-device cookie on this request. Neither the device token nor its hash is part of the
        response.

        The list is bounded by the `maxTrustedDevices` policy, so a single page is always complete:
        `nextCursor` is always the empty string and `hasMore` is always false. The cursor envelope
        is still used so callers can treat every list uniformly. This collection accepts GET only.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: One complete page of devices, content: {application/json: {schema: {$ref: '#/components/schemas/DevicePageEnvelope'}}}}
        '401': {description: Unauthenticated}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
  /api/v1/auth/devices/{deviceId}:
    parameters: [{name: deviceId, in: path, required: true, schema: {type: string}}]
    patch:
      summary: Rename one of the caller's trusted devices
      description: |
        Sets a user-facing label. Only the caller's own devices may be renamed; any other
        identifier answers `device_not_found`. A missing device is reported as not found rather
        than forbidden so an identifier cannot be used to probe whose device it is. The label is
        truncated to 191 characters and an empty value clears it. No `Idempotency-Key` is required.
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DeviceRenameRequest'}}}}
      responses:
        '200': {description: 'Renamed; data carries id and name'}
        '400': {description: 'Invalid JSON, or a field the handler does not declare'}
        '401': {description: Unauthenticated}
        '404': {description: device_not_found}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable}
    delete:
      summary: Untrust one of the caller's devices
      description: |
        Revokes the device and audits the action. When the device making the request is the one
        being revoked the trusted-device cookie is cleared as well, so a browser cannot keep
        presenting a dead token. No `Idempotency-Key` is required for this self-service mutation.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: 'Revoked; data carries revoked and id'}
        '401': {description: Unauthenticated}
        '404': {description: device_not_found}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable}
  /api/v1/auth/identities:
    get:
      summary: List the external identities linked to the caller's account
      description: |
        At most one link per provider, so a single page is always complete: `nextCursor` is the
        empty string and `hasMore` is false. `subject` is the IdP-side identifier; it is not a
        credential and is included so an unlink target can be recognised. This collection accepts
        GET only.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: One complete page of identities, content: {application/json: {schema: {$ref: '#/components/schemas/IdentityPageEnvelope'}}}}
        '401': {description: Unauthenticated}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
  /api/v1/auth/identities/{identityId}:
    parameters: [{name: identityId, in: path, required: true, schema: {type: string}}]
    delete:
      summary: Unlink one external identity from the caller's account
      description: |
        Refused with `identity_required_for_login` when the account has no local password and this
        is its only remaining credential, because the unlink would leave it reachable only through
        a database edit. An account that keeps a local password and ends with no external link is
        relabelled `local`, since the mixed label would otherwise be a lie. An identifier that does
        not belong to the caller answers `identity_not_found`. No `Idempotency-Key` is required.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: 'Unlinked; data carries unlinked and id'}
        '401': {description: Unauthenticated}
        '404': {description: identity_not_found}
        '405': {description: Method not allowed}
        '409': {description: identity_required_for_login}
        '503': {description: identity_services_unavailable}
  /api/v1/auth/oidc/providers:
    get:
      summary: List the SSO buttons the public login page may render
      description: |
        Public endpoint. Returns only `id`, `name` and `displayName`, and only for providers that
        are both enabled and flagged `publicListed`. The listing is gated by a process-level switch
        so a deployment can require users to already know the URL before the existence of its IdP
        is disclosed. When the switch is off the route answers 404 `not_found` rather than 403, so
        a disabled feature is indistinguishable from a route that never existed.

        The response is a bare `items` array with no cursor: the set is small and always complete.
      security: []
      responses:
        '200': {description: Public provider list, content: {application/json: {schema: {$ref: '#/components/schemas/PublicOIDCProviderListEnvelope'}}}}
        '404': {description: 'not_found; the public provider listing is switched off'}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable}
  /api/v1/auth/oidc/{providerName}/authorize:
    parameters:
      - {name: providerName, in: path, required: true, schema: {type: string, pattern: '^[a-z0-9][a-z0-9-]{1,62}$'}, description: Provider slug. It is part of the published redirect URI and is immutable after creation.}
    get:
      summary: Begin an OIDC authorization-code login and redirect the browser to the IdP
      description: |
        Public endpoint. Answers 302 with a `Location` pointing at the IdP authorization endpoint
        and `Cache-Control: no-store`, so neither a browser nor a proxy caches a URL that carries a
        single-use state.

        The state, the nonce and the PKCE verifier are stored encrypted in one challenge row, so
        any Server node in the cluster can complete the redirect. The challenge identifier doubles
        as the state value: it is already a 192-bit random token, and reusing it removes a second
        lookup path that could disagree with the first. The challenge is created with
        `max_attempts=1`, so a second presentation of the same state must fail rather than retry.
      security: []
      responses:
        '302':
          description: Redirect to the IdP authorization endpoint
          headers:
            Location: {description: 'Absolute IdP authorization URL carrying state, nonce, the PKCE code challenge and the provider redirect_uri', schema: {type: string, format: uri}}
            Cache-Control: {description: Always no-store, schema: {type: string}}
        '400': {description: oidc_provider_invalid}
        '404': {description: oidc_provider_not_found or oidc_provider_disabled}
        '405': {description: Method not allowed}
        '502': {description: 'oidc_discovery_failed; the IdP metadata document could not be retrieved'}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/auth/oidc/{providerName}/callback:
    parameters:
      - {name: providerName, in: path, required: true, schema: {type: string, pattern: '^[a-z0-9][a-z0-9-]{1,62}$'}}
      - {name: code, in: query, schema: {type: string}, description: Authorization code issued by the IdP}
      - {name: state, in: query, schema: {type: string}, description: Challenge identifier minted by the authorize step}
      - {name: error, in: query, schema: {type: string}, description: IdP-side refusal. When it is present the callback fails without reading code or state.}
      - {name: error_description, in: query, schema: {type: string}, description: Accepted and deliberately ignored. IdP-controlled text is never reflected onto this origin.}
    get:
      summary: Complete the OIDC redirect and hand the browser a single-use ticket or an MFA challenge
      description: |
        Public endpoint. Exchanges the code with PKCE, verifies the id_token signature, issuer,
        audience, nonce and expiry, resolves the local account, and links or just-in-time
        provisions it. On success it answers 302 to the configured login path with either
        `?ticket=<single-use>` or, when the account still owes a second factor,
        `?mfa=<challengeId>`.

        The redirect target is a server constant, never derived from request data or from the
        provider row, and the value is URL-escaped. That is what makes an open redirect through the
        callback impossible even if a provider's `redirectUri` points somewhere else. The state row
        names the provider it was minted for, so a state cannot be replayed into another provider's
        callback. The state is burned on a token-exchange failure too, so a captured code cannot be
        retried against this redirect; on the happy path it is consumed only after provisioning
        succeeds, so a transient database error leaves the redirect retryable inside its TTL.

        Provisioning checks an existing link first, then a username match, then creation. That
        order makes a username change at the IdP harmless and lets an organisation adopt SSO
        without re-creating every account. The ticket is a bearer secret: it is never written to
        the audit log and never appears in a response body.
      security: []
      responses:
        '302':
          description: Redirect to the login page with a single-use ticket or an MFA challenge id
          headers:
            Location: {description: 'Relative login path with exactly one of ticket or mfa set', schema: {type: string}}
            Cache-Control: {description: Always no-store, schema: {type: string}}
        '400': {description: oidc_state_invalid or oidc_provider_error, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '401': {description: 'oidc_id_token_invalid, oidc_unsupported_algorithm, oidc_no_matching_key or oidc_claim_missing'}
        '403': {description: 'oidc_user_not_provisioned when auto-creation is off or the resolved username is not acceptable; account_disabled when the resolved local account is disabled or soft-deleted'}
        '404': {description: oidc_provider_not_found or oidc_provider_disabled}
        '405': {description: Method not allowed}
        '502': {description: 'oidc_token_exchange_failed, oidc_discovery_failed or oidc_jwks_fetch_failed'}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/auth/oidc/exchange:
    post:
      summary: Exchange a single-use OIDC login ticket for a console session
      description: |
        Public endpoint. A POST rather than a GET so the ticket never lands in browser history, a
        `Referer` header or a proxy access log. The response is the authenticated-login shape, and
        a trusted-device cookie is set when the redirect already established trust or when
        `trustDevice` is requested and the policy allows it.

        The ticket is consumed before the session is issued, so a second exchange of the same
        ticket finds no live row even when the two requests race. An unknown, expired or already
        consumed ticket, and one whose account has since been disabled or deleted, all answer 401
        `login_ticket_invalid`. An MFA challenge is never returned here: the callback redirects to
        the login page with `mfa=` instead, and the challenge is completed through
        `POST /api/v1/auth/mfa/verify`.
      security: []
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCTicketExchangeRequest'}}}}
      responses:
        '200': {description: Authenticated, content: {application/json: {schema: {$ref: '#/components/schemas/LoginSuccessEnvelope'}}}}
        '400': {description: 'Invalid JSON, or a field the handler does not declare'}
        '401': {description: login_ticket_invalid, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable}
  /api/v1/auth/policy:
    get:
      summary: Read the authoritative authentication policy
      description: |
        Administrator-only. Returns the stored `auth_settings` row, which is the single
        authoritative source: process configuration only seeds it the first time it is read, so an
        operator decision made through the console survives a restart and a config edit. `updatedAt`
        is an empty string while the row has never been written.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Current policy, content: {application/json: {schema: {$ref: '#/components/schemas/AuthPolicyEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable}
    put:
      summary: Replace the authentication policy
      description: |
        Administrator-only and audited with the list of changed field names and their new values,
        none of which is a secret. The body is a full replacement rather than a patch. Requires
        `Idempotency-Key`. A replay of the same key by the same user returns the stored response
        verbatim with its original status code instead of running the update twice. A key that
        belongs to a different user answers 409 `idempotency_key_conflict`, and one whose first
        request is still running answers 409 `idempotency_in_progress`; neither is a server
        defect, so neither is reported as `internal_error`.

        Bounds are enforced so a typo cannot produce a policy that is technically valid and
        operationally absurd: `deviceTrustTtlSeconds` must be 3600-7776000, `maxTrustedDevices`
        must be 1-100 and `sessionTokenTtlSeconds` must not be negative, where 0 keeps the
        historical non-expiring behaviour. `allowTrustedDeviceBypass` requires
        `deviceTrustEnabled`, because storing a bypass while trust is off would silently grant it
        the moment trust is re-enabled without anybody re-reading the row. A violation answers 400
        `auth_policy_invalid` and persists nothing.

        Disabling MFA globally while an account still carries the per-account `mfaRequired`
        override is allowed: the override is a floor rather than a suggestion and keeps being
        enforced. Disabling device trust takes effect immediately on every node and revokes every
        bypass without a token or session change.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AuthPolicyUpdateRequest'}}}}
      responses:
        '200': {description: Updated policy, content: {application/json: {schema: {$ref: '#/components/schemas/AuthPolicyEnvelope'}}}}
        '400': {description: 'auth_policy_invalid, idempotency_key_required or invalid JSON'}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '405': {description: Method not allowed}
        '409': {description: 'idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable}
  /api/v1/sso/providers:
    get:
      summary: List OIDC identity providers
      description: |
        Administrator-only cursor page. Every branch of `/api/v1/sso` is admin-gated because a
        provider row decides who may become an administrator, so a non-admin who could edit one
        could promote themselves.

        Unlike the public listing this returns the full configuration. The client secret is never
        part of it; only the `hasSecret` boolean is.
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {$ref: '#/components/parameters/ListKeyword'}
      responses:
        '200': {description: Cursor page, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCProviderPageEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '422': {description: Invalid sort or filter value}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable}
    post:
      summary: Register an OIDC identity provider
      description: |
        Administrator-only and audited. Requires `Idempotency-Key`, because a retried request would
        otherwise create a second provider.

        `name` is a URL-safe slug matching `^[a-z0-9][a-z0-9-]{1,62}$` and becomes part of the
        published redirect URI, so it is immutable afterwards. The literal `providers` is reserved
        because the public login router matches that segment before it treats one as a provider
        name; a provider stored under it would be permanently unreachable, so the name is refused
        with 400 `oidc_provider_invalid` instead. `scopes` must include `openid`; the
        stored list is deduplicated with `openid` first, which keeps what an operator reads in the
        console identical to what the IdP receives. `issuer` must be an absolute https URL and must
        equal the id_token `iss` claim. `redirectUri` must be an absolute https URL inside one of
        the server's configured allowed bases, which bounds where an assertion code can be sent
        even by a compromised administrator. `idTokenAlgs` must be a subset of RS256/384/512,
        PS256/384/512, ES256/384/512 or EdDSA and defaults to RS256. `defaultRole` must be `admin`
        or `user`. Endpoints left empty are resolved from discovery at login time. `displayName`
        defaults to `name` and `usernameClaim` defaults to `preferred_username`. The boolean
        toggles default to `authoritativeRoles=true`, `autoCreateUsers=true`, `fetchUserinfo=false`,
        `publicListed=true` and `enabled=true`.

        `clientSecret` is write-only. It is sealed with AES-256-GCM before the row is written, the
        plaintext is dropped as soon as the request value goes out of scope, and no read API ever
        returns it: responses carry `hasSecret` instead. Omit it entirely for a public client that
        authenticates with PKCE alone. There is no reveal endpoint for it, unlike
        `POST /api/v1/tokens/{tokenId}/reveal`; a lost secret has to be re-entered. Creation fails
        closed with `secret_storage_unavailable` when no encryption key is configured.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCProviderCreateRequest'}}}}
      responses:
        '201': {description: Created, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCProviderEnvelope'}}}}
        '400': {description: 'oidc_provider_invalid, oidc_role_mapping_invalid, idempotency_key_required or invalid JSON'}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '405': {description: Method not allowed}
        '409': {description: 'conflict (the provider name is already taken), idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/sso/providers/{providerId}:
    parameters: [{name: providerId, in: path, required: true, schema: {type: string}, description: Opaque provider ID. The login routes address a provider by name instead.}]
    get:
      summary: Read one OIDC identity provider
      description: Administrator-only. The client secret is not part of the response; `hasSecret` reports whether one is stored.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Provider, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCProviderEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '404': {description: oidc_provider_not_found}
        '503': {description: identity_services_unavailable}
    patch:
      summary: Partially update one OIDC identity provider
      description: |
        Administrator-only and audited with the changed field names plus the idempotency key.
        Values are deliberately omitted from the audit row, which removes any chance of recording a
        secret. `PUT` is accepted with the same body and the same semantics. Requires
        `Idempotency-Key`.

        Every field is optional and an omitted field keeps the stored value instead of switching it
        off. `name` is immutable because it is part of the published callback URL: supplying a
        different one answers 400 `oidc_provider_invalid`, while omitting it or repeating the
        stored value is fine. The reserved literal `providers` is refused here too. An empty or omitted `clientSecret` keeps the stored secret and
        supplying one reseals it, which requires the encryption key. The merged result is
        revalidated as a whole before anything is written.

        Disabling the last enabled provider is refused with `oidc_provider_in_use` when at least
        one account has no other credential, because those accounts would lose every way in.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCProviderUpdateRequest'}}}}
      responses:
        '200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCProviderEnvelope'}}}}
        '400': {description: 'oidc_provider_invalid, oidc_role_mapping_invalid, idempotency_key_required or invalid JSON'}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '404': {description: oidc_provider_not_found}
        '409': {description: 'oidc_provider_in_use, idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
    delete:
      summary: Delete one OIDC identity provider
      description: |
        Administrator-only and audited. Requires `Idempotency-Key`. Refused with
        `oidc_provider_in_use` while the provider is the last enabled one and at least one account
        has no other credential.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses:
        '200': {description: 'Deleted; data carries deleted and id'}
        '400': {description: idempotency_key_required}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '404': {description: oidc_provider_not_found}
        '405': {description: Method not allowed}
        '409': {description: 'oidc_provider_in_use, idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable}
  /api/v1/sso/providers/{providerId}/test:
    parameters: [{name: providerId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Run discovery and a JWKS retrieval against the live IdP
      description: |
        Administrator-only connectivity check. Drops any cached metadata first so the report
        describes this run rather than a previous one, resolves the endpoints, then probes the
        signing keys. It returns `discoveryOk`, `jwksOk`, the resolved `algorithms` and the
        resolved `endpoints` map (`authorization`, `token`, `jwks` and optionally `userinfo`), plus
        an `error` code when a step failed. A connectivity failure is reported inside the 200 body
        rather than as an HTTP error, because surfacing that failure is the whole point of the
        call.

        The IdP's own error text is attacker-influenced and is never echoed, so only stable codes
        and booleans leave the server, and only the booleans are audited. No secret is returned.
        The call changes no configuration but is still treated as a mutation and requires
        `Idempotency-Key`, because it produces an outbound request an operator must be able to
        account for.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses:
        '200': {description: Connectivity report, content: {application/json: {schema: {$ref: '#/components/schemas/OIDCProviderTestEnvelope'}}}}
        '400': {description: idempotency_key_required}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '404': {description: oidc_provider_not_found}
        '405': {description: Method not allowed}
        '409': {description: 'idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable or secret_storage_unavailable}
  /api/v1/users:
    get:
      summary: List ordinary child accounts
      security: [{ bearerAuth: [] }]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {$ref: '#/components/parameters/ListKeyword'}
        - {$ref: '#/components/parameters/ListRoleFilter'}
        - {name: status, in: query, schema: {type: string, enum: [active, deleted, all], default: active}}
      responses: {'200': {description: Cursor page}, '401': {description: Unauthenticated}, '403': {description: Administrator required}, '422': {description: Invalid sort or filter value}}
    post:
      summary: Create an ordinary child account and return its temporary password once
      security: [{ bearerAuth: [] }]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/UserCreateRequest'}}}}
      responses: {'201': {description: Created; response uses Cache-Control no-store}, '400': {description: Invalid username}, '401': {description: Unauthenticated}, '403': {description: Administrator required}, '409': {description: Username conflict}}
  /api/v1/users/{userId}:
    parameters: [{name: userId, in: path, required: true, schema: {type: string}}]
    patch:
      summary: Toggle an ordinary account's disabled flag or its per-account MFA requirement
      description: |
        Administrator-only. Accepts `disabled`, `mfaRequired`, or both; at least one must be
        present, because a body that changes nothing is a client bug rather than a no-op to accept
        silently. Each field is a pointer on the wire, so an explicit `false` is distinguishable
        from an omitted field. Unknown fields are rejected.

        `mfaRequired` is a per-account floor rather than a suggestion: it keeps being enforced even
        when the global `mfaMode` is `disabled` or `optional`. Setting it on an account that has no
        second factor yet means that account cannot complete a login until it enrolls, which is the
        intended way to force enrollment. No `Idempotency-Key` is required for this endpoint.
      security: [{ bearerAuth: [] }]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/UserStatusRequest'}}}}
      responses: {'200': {description: 'Updated account, including mfaRequired and authSource', content: {application/json: {schema: {$ref: '#/components/schemas/UserEnvelope'}}}}, '400': {description: 'Invalid request, or neither disabled nor mfaRequired was supplied'}, '401': {description: Unauthenticated}, '403': {description: Administrator account is protected or administrator required}, '404': {description: Not found}, '409': {description: Account is deleted}}
    delete:
      summary: Logically delete an ordinary account while retaining all related resources
      security: [{ bearerAuth: [] }]
      responses: {'200': {description: Logically deleted}, '403': {description: Administrator account is protected}, '404': {description: Not found}}
  /api/v1/users/{userId}/reset-password:
    parameters: [{name: userId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Reset an ordinary account password and return the temporary password once
      security: [{ bearerAuth: [] }]
      responses: {'200': {description: Reset; response uses Cache-Control no-store}, '403': {description: Administrator account is protected}, '404': {description: Not found}, '409': {description: Account is deleted}}
  /api/v1/users/{userId}/restore:
    parameters: [{name: userId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Restore a logically deleted ordinary account
      security: [{ bearerAuth: [] }]
      responses: {'200': {description: Restored}, '403': {description: Administrator account is protected}, '404': {description: Not found}}
  /api/v1/users/{userId}/devices:
    parameters: [{name: userId, in: path, required: true, schema: {type: string}}]
    get:
      summary: List another account's active trusted devices
      description: |
        Administrator-only. The account is always taken from the path and never from a request
        body, so an administrator cannot be tricked into acting on an account the URL does not
        name.

        `current` is false on every row: the administrator is not the subject of the request, so
        none of these devices belongs to this session. The list is bounded by the
        `maxTrustedDevices` policy, so one page is always complete and `nextCursor` is always the
        empty string. Device tokens and their hashes are never returned.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: One complete page of devices, content: {application/json: {schema: {$ref: '#/components/schemas/DevicePageEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
  /api/v1/users/{userId}/devices/{deviceId}:
    parameters:
      - {name: userId, in: path, required: true, schema: {type: string}}
      - {name: deviceId, in: path, required: true, schema: {type: string}}
    delete:
      summary: Untrust one of another account's devices
      description: |
        Administrator-only incident-response action for a lost laptop. Audited with the owning
        account. Revoking an already revoked device succeeds, so the action is safe to retry, but
        `Idempotency-Key` is still required because this is an administrative mutation.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses:
        '200': {description: 'Revoked; data carries revoked and id'}
        '400': {description: idempotency_key_required}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '404': {description: device_not_found}
        '405': {description: Method not allowed}
        '409': {description: 'idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable}
  /api/v1/users/{userId}/mfa:
    parameters: [{name: userId, in: path, required: true, schema: {type: string}}]
    get:
      summary: Read another account's second-factor status
      description: |
        Administrator-only. Returns only `status` (`none`, `pending` or `enabled`) and
        `remainingRecoveryCodes`. The shared secret, the codes themselves and the enrollment
        timestamps are never exposed for somebody else's account.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: MFA summary, content: {application/json: {schema: {$ref: '#/components/schemas/AdminMFAStatusEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable}
  /api/v1/users/{userId}/mfa/reset:
    parameters: [{name: userId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Clear another account's second factor so it can re-enroll
      description: |
        Administrator-only lockout recovery for a user who lost their authenticator. Deliberately
        destructive: it deletes the enrollment, invalidates every recovery code and revokes every
        device that was trusted under the removed factor, because each of those devices was
        trusted on the strength of a factor that no longer exists. The account must re-enroll,
        which is the only way to be sure the person re-enrolling is the account owner.

        Succeeds even when the account had no enrollment, so the call is safe to retry, but
        `Idempotency-Key` is still required because this is an administrative mutation. Audited
        with the number of devices revoked.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses:
        '200': {description: 'Reset; data carries reset and userId'}
        '400': {description: idempotency_key_required}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '404': {description: Not found}
        '405': {description: Method not allowed}
        '409': {description: 'idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable}
  /api/v1/users/{userId}/identities:
    parameters: [{name: userId, in: path, required: true, schema: {type: string}}]
    get:
      summary: List another account's linked external identities
      description: |
        Administrator-only. At most one link per provider, so one page is always complete and
        `nextCursor` is the empty string. `subject` is included because unlinking the wrong row is
        unrecoverable without it, and a subject is not a credential.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: One complete page of identities, content: {application/json: {schema: {$ref: '#/components/schemas/IdentityPageEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '405': {description: Method not allowed}
        '503': {description: identity_services_unavailable, content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
  /api/v1/users/{userId}/identities/{identityId}:
    parameters:
      - {name: userId, in: path, required: true, schema: {type: string}}
      - {name: identityId, in: path, required: true, schema: {type: string}}
    delete:
      summary: Unlink one external identity from another account
      description: |
        Administrator-only and audited with the owning account and the fact that an administrator
        performed it. Refused with `identity_required_for_login` when the account is passwordless
        and this is its only credential, which is what stops an administrator from locking a
        colleague out by accident. Requires `Idempotency-Key`.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses:
        '200': {description: 'Unlinked; data carries unlinked and id'}
        '400': {description: idempotency_key_required}
        '401': {description: Unauthenticated}
        '403': {description: Administrator required}
        '404': {description: identity_not_found or account_not_found}
        '405': {description: Method not allowed}
        '409': {description: 'identity_required_for_login, idempotency_key_conflict or idempotency_in_progress'}
        '500': {description: internal_error}
        '503': {description: identity_services_unavailable}
  /api/v1/dashboard/summary:
    get:
      summary: Read resource counts and recent events within the current principal's scope
      description: The ten most recent audit events the caller may see, using the same projection as the audit list (actor, action, resource, details, time). An administrator sees every actor; any other principal sees only events they caused, filtered in SQL rather than trimmed after paging.
      security: [{ bearerAuth: [] }]
      responses:
        '200': {description: Dashboard summary, content: {application/json: {schema: {$ref: '#/components/schemas/DashboardSummaryEnvelope'}}}}
        '401': {description: Unauthenticated}
  /api/v1/agents:
    get:
      summary: List agents the caller may administer
      description: Each item carries `enabled`, the administrative switch, and `status`, which is `online` while the agent holds an unexpired connection lease and `offline` otherwise. The two are independent facts, so `enabled` alone never implies connectivity; an agent created in the console lists as `offline` until it first dials in. Items also carry `createdAt` and `updatedAt`. Rows are ordered by `created_at DESC, id DESC`, so the first page is the most recently registered agents; the cursor is that same pair, which is why `id` stays in the key. `keyword` narrows to agents whose name or id contains the value, applied in SQL before paging, so a match beyond the first page is still reachable. Matching is a substring test, case-sensitive on SQLite and collation-dependent on MySQL.
      security: [{ bearerAuth: [] }]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 128}, description: Substring of the agent name or id; longer input is truncated to 128 bytes}
      responses: {'200': {description: Cursor page}, '422': {description: Invalid sort or filter value}}
    post:
      security: [{ bearerAuth: [] }]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AgentRequest'}}}}
      responses: {'201': {description: Created}, '403': {description: Forbidden}, '409': {description: Conflict}}
  /api/v1/agents/{agentId}:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}]
    get: {security: [{bearerAuth: []}], responses: {'200': {description: Agent}, '404': {description: Not found}}}
    put: {security: [{bearerAuth: []}], parameters: [{$ref: '#/components/parameters/IdempotencyKey'}], requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/AgentRequest'}}}}, responses: {'200': {description: Updated}}}
    patch:
      summary: Update the supplied agent fields
      description: Administrators only. Omitted fields keep their stored value, so a rename does not have to resend the switch. Setting `enabled` to false rejects new connections to this agent and client tokens that reference it, while sessions already established keep running until the agent reconnects.
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AgentPatchRequest'}}}}
      responses: {'200': {description: Updated}, '403': {description: Forbidden}, '404': {description: Not found}}
    delete: {security: [{bearerAuth: []}], responses: {'200': {description: Deleted}}}
  /api/v1/agents/{agentId}/metadata:
    parameters:
      - {name: agentId, in: path, required: true, schema: {type: string}}
      - {$ref: '#/components/parameters/IncludeStale'}
    get:
      summary: Read the latest runtime metadata reported by an agent
      description: Stale snapshots are omitted by default. Set includeStale=true to read the last stale snapshot marked with stale=true. An agent that exists but never reported returns 200 with empty `items` and `instances` arrays, which is a legitimate empty collection rather than a missing resource. Runtime metadata is read-only through this API.
      security: [{bearerAuth: []}]
      responses:
        '200':
          description: Runtime metadata snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentMetadataEnvelope'
        '401': {description: Unauthenticated}
        '403': {description: Agent is not owned by the caller}
        '404': {description: Agent not found, or snapshot is stale without includeStale=true}
  /api/v1/agents/{agentId}/connections:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}]
    get:
      summary: List live physical Agent connections across Server nodes
      description: Returns durable registry leases for one Agent and overlays fresher process-local state for connections owned by the responding Server. Only the Agent owner or an administrator may read this list.
      security: [{bearerAuth: []}]
      responses:
        '200':
          description: Cluster connection list
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AgentConnectionClusterEnvelope'}
        '401': {description: Unauthenticated}
        '403': {description: Agent is not owned by the caller}
        '404': {description: Agent not found}
        '503': {description: Cluster connection registry is unavailable}
  /api/v1/agents/{agentId}/connections/{connectionId}:
    parameters:
      - {name: agentId, in: path, required: true, schema: {type: string}}
      - {name: connectionId, in: path, required: true, schema: {type: string}}
      - {name: connectionEpoch, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}, description: Registry lease epoch returned by the connection list}
    delete:
      summary: Close one exact physical Agent connection
      description: Local connections are closed through the exact-fenced local controller. Remote connections are closed through authenticated Server-node mTLS relay control. If the exact lease has already disappeared, the request succeeds idempotently. If the owning Server is unreachable, the lease remains intact and the API returns 503.
      security: [{bearerAuth: []}]
      responses:
        '200':
          description: Connection closed or already absent
          content:
            application/json:
              schema: {$ref: '#/components/schemas/AgentConnectionCloseEnvelope'}
        '400': {description: connectionEpoch is missing or invalid}
        '401': {description: Unauthenticated}
        '403': {description: Agent is not owned by the caller}
        '404': {description: Agent not found}
        '409': {description: Connection epoch is stale; a replacement connection was not closed}
        '500': {description: Connection close failed}
        '503': {description: Owning Server node or cluster registry is unavailable; the lease is preserved}
  /api/v1/clients:
    get:
      summary: List Client instances within the caller's ownership scope
      description: Results are ordered by last heartbeat descending, with the instance ID as a deterministic tie-breaker, so the Clients that reported most recently are on the first page. The stale and expiry sweeps update the row timestamp without any Client activity, which is why that timestamp is not the sort key. Administrators may filter across owners. Ordinary users are always restricted to their own Clients in the repository query, before cursor pagination.
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {name: ownerUserId, in: query, schema: {type: string}, description: Administrator-only owner filter}
        - {name: tokenId, in: query, schema: {type: string}}
        - {name: serverNodeId, in: query, schema: {type: string}}
        - name: status
          in: query
          description: Client presence only, decided by live WebSocket leases. The legacy values stale and metadata_unavailable are accepted as deprecated aliases of metadataState=expired|unavailable.
          schema: {type: string, enum: [online, offline, stale, metadata_unavailable]}
          deprecated: true
        - name: metadataState
          in: query
          description: Metadata freshness, independent from presence. reported matches every Client that ever accepted CLIENT_HELLO (fresh or expired).
          schema: {type: string, enum: [fresh, expired, unavailable, reported]}
        - {name: agentId, in: query, schema: {type: string}}
        - {name: keyword, in: query, schema: {type: string, maxLength: 255}}
      responses:
        '200': {description: Cursor page plus whole-population summary, content: {application/json: {schema: {$ref: '#/components/schemas/ClientPageEnvelope'}}}}
        '400': {description: Invalid status or metadataState filter}
        '422': {description: Invalid sort or filter value}
        '401': {description: Unauthenticated}
        '403': {description: Non-administrator requested another owner}
  /api/v1/clients/{clientInstanceId}:
    parameters: [{name: clientInstanceId, in: path, required: true, schema: {type: string}}]
    get:
      summary: Read one Client instance and its observability metadata
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Client, content: {application/json: {schema: {$ref: '#/components/schemas/ClientEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Client is not owned by the caller}
        '404': {description: Client not found}
  /api/v1/clients/{clientInstanceId}/connections:
    parameters: [{name: clientInstanceId, in: path, required: true, schema: {type: string}}]
    get:
      summary: List physical Client WebSocket leases
      description: Returns durable leases across Server nodes, ordered by most recent heartbeat first. The list does not expose credentials or client metadata values. Expired leases are kept for 24 hours for postmortems and swept every minute, so history is an explicit view rather than the default one.
      security: [{bearerAuth: []}]
      parameters:
        - name: state
          in: query
          description: 'live returns leases that have not expired (default); all also returns expired leases within the retention window. Ownership rules are identical for both.'
          schema: {type: string, enum: [live, all], default: live}
        - {name: limit, in: query, description: Maximum rows, applied to the requested state. Values above 200 are clamped; non-numeric values are rejected., schema: {type: integer, minimum: 1, maximum: 200, default: 50}}
      responses:
        '200': {description: Connection leases, content: {application/json: {schema: {$ref: '#/components/schemas/ClientConnectionClusterEnvelope'}}}}
        '401': {description: Unauthenticated}
        '403': {description: Client is not owned by the caller}
        '404': {description: Client not found}
        '422': {description: 'Invalid state or limit; the body carries `error` plus the `allowed` state values'}
  /api/v1/clients/{clientInstanceId}/connections/{connectionId}:
    parameters:
      - {name: clientInstanceId, in: path, required: true, schema: {type: string}}
      - {name: connectionId, in: path, required: true, schema: {type: string}}
      - {name: connectionEpoch, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}, description: Durable lease epoch used for cross-node close fencing}
    delete:
      summary: Close one exact physical Client WebSocket
      description: The API verifies Client ownership, connection ownership, and epoch before delegation. Local leases are closed directly; remote leases use authenticated Server-node relay control. An absent lease is idempotent; an unreachable owner Server preserves the lease and returns 503.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Connection closed or already absent, content: {application/json: {schema: {$ref: '#/components/schemas/ClientConnectionCloseEnvelope'}}}}
        '400': {description: connectionEpoch is missing or invalid}
        '401': {description: Unauthenticated}
        '403': {description: Client is not owned by the caller}
        '404': {description: Client or connection not found}
        '409': {description: Connection epoch is stale}
        '500': {description: Connection close failed}
        '503': {description: Owning Server node is unavailable; the lease is preserved}
  /api/v1/client/agents:
    get:
      summary: List the agents this client token may target
      description: |
        Client-scoped surface, authenticated with a client service token rather than a
        console login. The singular `/api/v1/client/` namespace is deliberately separate
        from the plural, admin-facing `/api/v1/clients/`: the two authenticate with
        different credentials, and merging them would give a tunnel credential a
        management view of every client instance in the deployment.

        Only agents the calling token is authorized for are returned. Rows are filtered by
        token owner in SQL, then by `enabled` and by the token's `scope.agentIds`; an empty
        scope means unrestricted within the owner. Both filters run while the page is being
        accumulated rather than after it is truncated, so a page is never silently
        shortened and the returned cursor always resumes correctly.

        Each item carries `online`, which reflects an unexpired connection lease. An agent
        listed as offline can still be configured, but a tunnel pointing at it fails to
        open until that agent dials in - which is exactly what the caller's picker needs to
        be able to say.

        The response is deliberately minimal. It exposes no capabilities, no reported
        metadata and no ownership detail, because the only consumer is a picker and every
        extra field would widen what a leaked tunnel token discloses.

        Every rejection answers the same opaque `401 unauthenticated`: revoked, expired,
        wrong-type and unknown tokens are indistinguishable from outside, so this endpoint
        cannot be used as a token oracle.
      security: [{ clientTokenAuth: [] }]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
      responses:
        '200': {description: Cursor page of the agents in this token's scope, content: {application/json: {schema: {$ref: '#/components/schemas/ClientAgentPageEnvelope'}}}}
        '401': {description: 'Bearer token missing, revoked, expired, or of a type other than client. The reason is not distinguished.', content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
        '405': {description: Any method other than GET on this path}
        '503': {description: 'The Server was started without a client token validator. This is a wiring fault rather than an authentication result, which is why it is not folded into 401.', content: {application/json: {schema: {$ref: '#/components/schemas/ApiErrorEnvelope'}}}}
  /api/v1/client/updates/latest:
    get:
      summary: Read the release version the Server distributes to clients
      description: |
        Used by a client that upgrades itself with `update.source: server`. Answers only the
        published release version and the Server's own version, so a token learns nothing
        else about the Server. Answers `404` until `server.updates.dir` is configured and
        a release is published there.
      security: [{ clientTokenAuth: [] }]
      responses:
        '200': {description: The published release version and the Server version, content: {application/json: {schema: {$ref: '#/components/schemas/UpdateLatestEnvelope'}}}}
        '401': {description: 'Bearer token missing, revoked, expired, or of a type other than client. The reason is not distinguished.'}
        '404': {description: Distribution is not configured or no release is published}
        '405': {description: Any method other than GET or HEAD}
        '500': {description: The release manifest is unreadable or names a version that is not a release}
        '503': {description: The token validator is unavailable}
  /api/v1/client/updates/files/{name}:
    parameters: [{name: name, in: path, required: true, schema: {type: string}, description: 'One of SHA256SUMS, manifest.json or a tunnelmesh-vX.Y.Z-os-arch archive. Any other name answers 404.'}]
    get:
      summary: Download one release file
      description: |
        Serves the named file from `server.updates.dir` with Range support so an
        interrupted download resumes. The name must be exactly one of the release layout's
        files; nothing else in the directory is reachable, and a symlink that leads outside
        the directory is refused. Downloads are limited per token and server-wide.
      security: [{ clientTokenAuth: [] }]
      responses:
        '200': {description: The file, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
        '206': {description: The requested byte range}
        '401': {description: 'Bearer token missing, revoked, expired, or of a type other than client.'}
        '404': {description: Distribution is not configured, or the name or file does not exist}
        '405': {description: Any method other than GET or HEAD}
        '503': {description: Too many concurrent downloads (retry after the Retry-After delay) or the token validator is unavailable}
  /api/v1/agent/updates/latest:
    get:
      summary: Read the release version the Server distributes to agents
      description: |
        Used by a agent that upgrades itself with `update.source: server`. Answers only the
        published release version and the Server's own version, so a token learns nothing
        else about the Server. Answers `404` until `server.updates.dir` is configured and
        a release is published there.
      security: [{ agentTokenAuth: [] }]
      responses:
        '200': {description: The published release version and the Server version, content: {application/json: {schema: {$ref: '#/components/schemas/UpdateLatestEnvelope'}}}}
        '401': {description: 'Bearer token missing, revoked, expired, or of a type other than agent. The reason is not distinguished.'}
        '404': {description: Distribution is not configured or no release is published}
        '405': {description: Any method other than GET or HEAD}
        '500': {description: The release manifest is unreadable or names a version that is not a release}
        '503': {description: The token validator is unavailable}
  /api/v1/agent/updates/files/{name}:
    parameters: [{name: name, in: path, required: true, schema: {type: string}, description: 'One of SHA256SUMS, manifest.json or a tunnelmesh-vX.Y.Z-os-arch archive. Any other name answers 404.'}]
    get:
      summary: Download one release file
      description: |
        Serves the named file from `server.updates.dir` with Range support so an
        interrupted download resumes. The name must be exactly one of the release layout's
        files; nothing else in the directory is reachable, and a symlink that leads outside
        the directory is refused. Downloads are limited per token and server-wide.
      security: [{ agentTokenAuth: [] }]
      responses:
        '200': {description: The file, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
        '206': {description: The requested byte range}
        '401': {description: 'Bearer token missing, revoked, expired, or of a type other than agent.'}
        '404': {description: Distribution is not configured, or the name or file does not exist}
        '405': {description: Any method other than GET or HEAD}
        '503': {description: Too many concurrent downloads (retry after the Retry-After delay) or the token validator is unavailable}
  /api/v1/agent/self:
    get:
      summary: Read the Agent identity bound to the presented Agent token
      description: Used by a standalone Agent whose local `agent.id` is empty. The server returns only the bound ID and display name; it never creates an Agent or returns token metadata.
      security: [{ agentTokenAuth: [] }]
      responses:
        '200': {description: Bound Agent identity, content: {application/json: {schema: {$ref: '#/components/schemas/AgentSelfEnvelope'}}}}
        '401': {description: Missing, invalid, revoked, expired or wrong-type token}
        '403': {description: The token has no enabled Agent binding}
        '404': {description: Unknown path or method}
        '503': {description: Agent token validator unavailable}
  /api/v1/agents/{agentId}/trace:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Run a logical client-to-agent traceroute
      description: Follows authenticated client, server, relay and agent hops. Sensitive fields are administrator-only and secrets are never returned by this endpoint.
      security: [{bearerAuth: []}]
      requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/TraceRequest'}}}}
      responses: {'200': {description: Trace result, content: {application/json: {schema: {$ref: '#/components/schemas/TraceEnvelope'}}}}, '403': {description: Forbidden sensitive request}}
  /api/v1/agents/{agentId}/diagnose:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Run an authorized TCP, HTTP, or UDP network probe
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DiagnoseRequest'}}}}
      responses: {'200': {description: Bounded probe result}, '403': {description: Agent is not owned by caller}}
  /api/v1/agents/{agentId}/probes:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}]
    get:
      summary: List bounded probe history
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}]
      responses: {'200': {description: Cursor page of probe summaries}}
  /api/v1/agents/{agentId}/policies:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}]
    get:
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {name: status, in: query, schema: {type: string, enum: [active, deleted, all], default: active}, description: Non-active filters require the administrator role}
      responses:
        '200': {description: Policies, content: {application/json: {schema: {$ref: '#/components/schemas/PolicyPageEnvelope'}}}}
        '400': {description: Invalid status filter}
        '403': {description: Non-administrator requested deleted or all policies}
    post: {security: [{bearerAuth: []}], parameters: [{$ref: '#/components/parameters/IdempotencyKey'}], requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PolicyRequest'}}}}, responses: {'201': {description: Created}}}
  /api/v1/agents/{agentId}/policies/{policyId}:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}, {name: policyId, in: path, required: true, schema: {type: string}}]
    get: {security: [{bearerAuth: []}], responses: {'200': {description: Policy, content: {application/json: {schema: {$ref: '#/components/schemas/PolicyEnvelope'}}}}}}
    put: {security: [{bearerAuth: []}], requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/PolicyRequest'}}}}, responses: {'200': {description: Updated}}}
    patch: {security: [{bearerAuth: []}], requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/PolicyRequest'}}}}, responses: {'200': {description: Updated}, '409': {description: Policy is logically deleted}}}
    delete:
      security: [{bearerAuth: []}]
      description: Logically deletes the policy. It immediately stops participating in authorization and can be restored by an administrator.
      responses:
        '200': {description: Logically deleted policy, content: {application/json: {schema: {$ref: '#/components/schemas/PolicyEnvelope'}}}}
        '403': {description: Administrator required}
  /api/v1/agents/{agentId}/policies/{policyId}/restore:
    parameters: [{name: agentId, in: path, required: true, schema: {type: string}}, {name: policyId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Restore a logically deleted Agent Policy
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Restored policy, content: {application/json: {schema: {$ref: '#/components/schemas/PolicyEnvelope'}}}}
        '403': {description: Administrator required}
        '404': {description: Policy is not deleted}
  /api/v1/routes:
    get:
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}, {$ref: '#/components/parameters/ListSort'}, {$ref: '#/components/parameters/ListOrder'}, {$ref: '#/components/parameters/ListKeyword'}]
      responses: {'200': {description: Managed routes}, '422': {description: Invalid sort or filter}}
    post:
      security: [{bearerAuth: []}]
      description: Creates a managed reverse-proxy route, or a whole-host HTTP proxy entry when `protocol` is `http-proxy`. Replaying the same `Idempotency-Key` returns the stored 201 response instead of a 409.
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/TunnelRequest'}}}}
      responses:
        '201': {description: Created}
        '400': {description: Invalid proxy route policy, credential reference or tp-* domain shape}
        '403': {description: Administrator required, or the referenced credential is not usable by the caller}
        '404': {description: Unknown agentId or credentialId}
        '409': {description: 'Duplicate route. A tp-* domain is claimed as a whole hostname: it conflicts with any other route on the same domain regardless of pathPrefix, and case variants count as the same claim.'}
        '503': {description: Credential service unavailable while validating a basic-auth proxy route}
  /api/v1/credentials:
    get:
      summary: List SSH credentials visible to the caller
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {$ref: '#/components/parameters/ListKeyword'}
        - {name: type, in: query, description: Comma-separated credential types., schema: {type: string, enum: [ssh_public_key, password, proxy_basic]}}
        - {name: status, in: query, schema: {type: string, enum: [active, deleted, all], default: active}}
      responses:
        '200': {description: Cursor page, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialPageEnvelope'}}}}
        '400': {description: Invalid filter}
        '422': {description: Invalid sort or filter value}
        '401': {description: Unauthenticated}
    post:
      summary: Create an SSH credential (public key or password)
      description: A `password` credential requires `secret.password`; an `ssh_public_key` credential requires `publicKey` and may optionally store `secret.privateKey` plus `secret.passphrase` for browser auto-authentication. Secrets are sealed with AES-256-GCM. The response reports `hasSecret` only and never echoes the submitted material.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialCreateRequest'}}}}
      responses:
        '201': {description: Created, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialEnvelope'}}}}
        '400': {description: Invalid public key, secret shape or fields}
        '401': {description: Unauthenticated}
        '409': {description: Idempotency conflict}
        '503': {description: A secret was supplied but TUNNELMESH_TOKEN_ENCRYPTION_KEY is not configured; nothing is stored}
  /api/v1/credentials/{credentialId}:
    parameters:
      - {name: credentialId, in: path, required: true, schema: {type: string}}
    get:
      summary: Read one credential
      security: [{bearerAuth: []}]
      responses: {'200': {description: Credential, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
    put:
      summary: Replace an SSH credential
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialCreateRequest'}}}}
      responses: {'200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialEnvelope'}}}}, '400': {description: Invalid fields}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}, '503': {description: A secret was supplied but TUNNELMESH_TOKEN_ENCRYPTION_KEY is not configured}}
    patch:
      summary: Update supplied credential fields
      description: Omitted fields keep their stored value; an omitted or empty `secret` keeps the stored secret, while changing `type` clears it.
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialPatchRequest'}}}}
      responses: {'200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialEnvelope'}}}}, '400': {description: Invalid fields}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}, '503': {description: A secret was supplied but TUNNELMESH_TOKEN_ENCRYPTION_KEY is not configured}}
    delete:
      summary: Logically delete a credential
      security: [{bearerAuth: []}]
      responses: {'200': {description: Deleted, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
  /api/v1/credentials/{credentialId}/restore:
    parameters:
      - {name: credentialId, in: path, required: true, schema: {type: string}}
    post:
      summary: Restore a logically deleted credential
      security: [{bearerAuth: []}]
      responses: {'200': {description: Restored, content: {application/json: {schema: {$ref: '#/components/schemas/CredentialEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
  /api/v1/credentials/extract-ssh-public-key:
    post:
      summary: Extract an SSH public key from an uploaded private key
      description: One-time in-memory parse. The private key and passphrase are never persisted, logged, or relayed. Responses are never cached.
      security: [{bearerAuth: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ExtractSSHPublicKeyRequest'}
      responses:
        '200':
          description: Extracted public key
          headers:
            Cache-Control: {schema: {type: string, const: no-store}}
          content: {application/json: {schema: {$ref: '#/components/schemas/ExtractSSHPublicKeyEnvelope'}}}
        '400': {description: Invalid private key}
        '401': {description: Unauthenticated}
  /api/v1/remote-servers:
    get:
      summary: List managed remote servers visible to the caller
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {$ref: '#/components/parameters/ListKeyword'}
        - {name: agentId, in: query, schema: {type: string}}
        - {name: status, in: query, schema: {type: string, enum: [enabled, disabled, deleted, all], default: enabled}}
      responses:
        '200': {description: Cursor page, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerPageEnvelope'}}}}
        '400': {description: Invalid filter}
        '422': {description: Invalid sort or filter value}
        '401': {description: Unauthenticated}
    post:
      summary: Create a managed remote server record
      description: Stores only connection metadata. Passwords and private keys are never accepted or persisted.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerCreateRequest'}}}}
      responses:
        '201': {description: Created, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerEnvelope'}}}}
        '400': {description: Invalid fields or Agent policy denied the target}
        '401': {description: Unauthenticated}
        '403': {description: Forbidden}
        '409': {description: Idempotency conflict}
  /api/v1/remote-servers/{serverId}:
    parameters:
      - {name: serverId, in: path, required: true, schema: {type: string}}
    get:
      summary: Read one managed remote server
      security: [{bearerAuth: []}]
      responses: {'200': {description: Remote server, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
    put:
      summary: Replace a managed remote server
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerCreateRequest'}}}}
      responses: {'200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerEnvelope'}}}}, '400': {description: Invalid fields}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
    patch:
      summary: Update supplied remote-server fields
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerPatchRequest'}}}}
      responses: {'200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerEnvelope'}}}}, '400': {description: Invalid fields}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
    delete:
      summary: Logically delete a managed remote server
      security: [{bearerAuth: []}]
      responses: {'200': {description: Deleted, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
  /api/v1/remote-servers/{serverId}/restore:
    parameters:
      - {name: serverId, in: path, required: true, schema: {type: string}}
    post:
      summary: Restore a logically deleted remote server
      security: [{bearerAuth: []}]
      responses: {'200': {description: Restored, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteServerEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
  /api/v1/remote-servers/{serverId}/ssh-sessions:
    parameters:
      - {name: serverId, in: path, required: true, schema: {type: string}}
    post:
      summary: Create a one-time browser WebSSH/SFTP ticket
      description: Returns a single-use ticket for the browser WebSocket. The Server brokers encrypted bytes only and never receives or stores terminal output or SFTP file content. When the bound credential stores a secret and the caller owns it, the response additionally carries a one-time `auth` payload so the browser can authenticate without prompting; that payload is attached after the idempotency record is written, so a replay returns the ticket without any secret. Responses that may contain `auth` carry a `Cache-Control` value of `no-store` and are audited as `credential.revealed` (credential id only). When no usable secret exists the `auth` field is omitted and the console falls back to the manual password dialog.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/WebSSHSessionCreateRequest'}}}}
      responses:
        '201': {description: One-time ticket, content: {application/json: {schema: {$ref: '#/components/schemas/WebSSHTicketEnvelope'}}}}
        '400': {description: Invalid username or credential}
        '401': {description: Unauthenticated}
        '403': {description: Forbidden}
        '404': {description: Remote server not found}
        '409': {description: Idempotency conflict or active-session limit reached}
  /api/v1/ssh-sessions:
    get:
      summary: List the caller's own active browser SSH sessions
      description: Lets a user release a stuck active-session quota from the console without database access. Only sessions owned by the caller are returned, and administrators also see only their own sessions. Closed and expired sessions are omitted; history remains in the audit log.
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
      responses:
        '200': {description: Cursor page, content: {application/json: {schema: {$ref: '#/components/schemas/WebSSHSessionPageEnvelope'}}}}
        '401': {description: Unauthenticated}
  /api/v1/ssh-sessions/{sessionId}:
    parameters:
      - {name: sessionId, in: path, required: true, schema: {type: string}}
    get:
      summary: Read one browser SSH session
      security: [{bearerAuth: []}]
      responses: {'200': {description: Session, content: {application/json: {schema: {$ref: '#/components/schemas/WebSSHSessionEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}}
    delete:
      summary: Close one browser SSH session
      description: Closes the process-local or cluster-owned WebSocket bridge and records a durable closed state. This API never exposes SSH bytes or credentials.
      security: [{bearerAuth: []}]
      responses: {'200': {description: Closed session, content: {application/json: {schema: {$ref: '#/components/schemas/WebSSHSessionEnvelope'}}}}, '401': {description: Unauthenticated}, '403': {description: Forbidden}, '404': {description: Not found}, '503': {description: Owning Server node is unavailable}}
  /api/v1/routes/{routeId}:
    parameters: [{name: routeId, in: path, required: true, schema: {type: string}}]
    get: {security: [{bearerAuth: []}], responses: {'200': {description: Route}}}
    put:
      security: [{bearerAuth: []}]
      requestBody:
        required: true
        content: {application/json: {schema: {$ref: '#/components/schemas/TunnelRequest'}}}
      responses: {'200': {description: Updated route}, '400': {description: 'Invalid route fields, or a protocol switch to or from http-proxy'}, '403': {description: Caller cannot manage the current or target Agent}, '409': {description: 'Duplicate domain and path. An http-proxy route claims its whole tp-* domain, so any other route on that domain conflicts.'}}
    patch:
      security: [{bearerAuth: []}]
      description: Updates only supplied route fields. Agent, protocol, domain, path, target, public port, status, and config are supported. For an `http-proxy` route the proxy policy fields are supported instead of `config`, `hostHeader`, `targetScheme` and `tlsServerName`, and `targetHost`/`targetPort` are fixed to the `*` / `0` sentinel.
      requestBody:
        required: true
        content: {application/json: {schema: {$ref: '#/components/schemas/TunnelUpdateRequest'}}}
      responses: {'200': {description: Updated route}, '400': {description: 'Invalid field, or a protocol switch to or from http-proxy'}, '403': {description: Caller cannot manage the current or target Agent}, '409': {description: 'Duplicate domain and path. An http-proxy route claims its whole tp-* domain, so renaming another route onto it conflicts.'}}
    delete: {security: [{bearerAuth: []}], responses: {'200': {description: Deleted}}}
  /api/v1/tunnels:
    get:
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/Cursor'}, {$ref: '#/components/parameters/Limit'}, {$ref: '#/components/parameters/ListSort'}, {$ref: '#/components/parameters/ListOrder'}, {$ref: '#/components/parameters/ListKeyword'}]
      responses: {'200': {description: Cursor page of tunnels}, '422': {description: Invalid sort or filter value}}
    post: {security: [{bearerAuth: []}], parameters: [{$ref: '#/components/parameters/IdempotencyKey'}], requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/TunnelRequest'}}}}, responses: {'201': {description: Created}}}
  /api/v1/tunnels/{tunnelId}:
    parameters: [{name: tunnelId, in: path, required: true, schema: {type: string}}]
    get: {security: [{bearerAuth: []}], responses: {'200': {description: Tunnel}}}
    put: {security: [{bearerAuth: []}], requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/TunnelRequest'}}}}, responses: {'200': {description: Updated}}}
    delete: {security: [{bearerAuth: []}], responses: {'200': {description: Deleted}}}
  /api/v1/tokens:
    get:
      summary: List scoped service Token metadata
      description: Admins may filter all Tokens by owner and type. Non-admin callers can list only Tokens they own. Stored hashes and issued secrets are never returned. Status is derived from lifecycle timestamps and current owner/resource availability.
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/ListMultiTokenTypeFilter'}
        - {$ref: '#/components/parameters/TokenOwnerFilter'}
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {$ref: '#/components/parameters/ListMultiStatusFilter'}
      responses:
        '200': {description: Cursor page of redacted Token metadata, content: {application/json: {schema: {$ref: '#/components/schemas/TokenPageEnvelope'}}}}
        '400': {description: Invalid Token type or pagination input}
        '422': {description: Invalid sort or filter value}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: A non-admin requested another owner's Tokens}
    post:
      summary: Create a scoped service Token
      description: |
        The plaintext `secret` is returned exactly once on the first successful request. When `TUNNELMESH_TOKEN_ENCRYPTION_KEY` is configured it is encrypted at rest and can be recovered only through the administrator reveal endpoint; otherwise existing hash-only behavior applies. Reusing the same `Idempotency-Key` with the same request returns the same Token ID and metadata with `secret` omitted and `replayed=true`. Reusing a key for another request, or observing a request still in progress, returns 409.

        Admins may create `agent`, `client`, and `server_node` Tokens. Non-admin owners may create an `agent` Token only for an enabled Agent they own, or a `client` Token whose scoped Agents are all enabled and owned by them. Non-admin callers cannot create `server_node` Tokens.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/TokenCreateRequest'}
      responses:
        '201': {description: Token created, or safely replayed without its secret, content: {application/json: {schema: {$ref: '#/components/schemas/TokenMutationEnvelope'}}}}
        '400': {description: Invalid type, binding, expiry, scope, JSON, or Idempotency-Key}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Owner/resource authorization denied}
        '409': {description: Idempotency-Key conflict or request in progress}
  /api/v1/server-nodes:
    get:
      summary: List Server node inventory and runtime status
      description: Administrator-only inventory. Status is derived from logical lifecycle, heartbeat expiry, and active Agent connection leases. Token secrets and private keys are never returned.
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
      responses:
        '200': {description: Cursor page, content: {application/json: {schema: {$ref: '#/components/schemas/ServerNodePageEnvelope'}}}}
        '422': {description: Invalid sort or filter value}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Administrator required}
  /api/v1/server-nodes/{nodeId}:
    parameters:
      - {name: nodeId, in: path, required: true, schema: {type: string}}
    get:
      summary: Read one Server node
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Server node, content: {application/json: {schema: {$ref: '#/components/schemas/ServerNodeEnvelope'}}}}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Administrator required}
        '404': {description: Not found}
    patch:
      summary: Update a Server node display name or enabled state
      security: [{bearerAuth: []}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ServerNodeUpdateRequest'}}}}
      responses:
        '200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/ServerNodeEnvelope'}}}}
        '400': {description: Invalid name or empty update}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Administrator required}
        '404': {description: Not found}
    delete:
      summary: Logically delete a Server node
      description: Sets deleted_at and disables the node while retaining its inventory record for restoration.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Logically deleted, content: {application/json: {schema: {$ref: '#/components/schemas/ServerNodeEnvelope'}}}}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Administrator required}
        '404': {description: Not found}
  /api/v1/server-nodes/{nodeId}/restore:
    parameters:
      - {name: nodeId, in: path, required: true, schema: {type: string}}
    post:
      summary: Restore a logically deleted Server node
      description: Clears deleted_at and enables the node. Relay mTLS and epoch checks still apply.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Restored, content: {application/json: {schema: {$ref: '#/components/schemas/ServerNodeEnvelope'}}}}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Administrator required}
        '404': {description: Not found}
  /api/v1/tokens/{tokenId}:
    parameters:
      - {name: tokenId, in: path, required: true, schema: {type: string}}
    get:
      summary: Read redacted scoped service Token metadata
      description: Returns dynamically derived status without the Token hash or plaintext secret.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Redacted Token metadata, content: {application/json: {schema: {$ref: '#/components/schemas/TokenMetadataEnvelope'}}}}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Token is not owned by the non-admin caller}
        '404': {description: Token not found}
    patch:
      summary: Update an active service Token
      description: 'Updates `expiresAt` and/or authorization scope fields. Send `expiresAt: null` to make the Token non-expiring. Scope fields that are omitted remain unchanged; empty arrays remove that restriction. `agentIds` is valid only for client Tokens, and `serverNodeIds` is valid only for server_node Tokens. Token type, owner, and Agent/Node binding cannot be changed. Revoked and already expired Tokens must be rotated instead.'
      security: [{bearerAuth: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/TokenUpdateRequest'}
      responses:
        '200': {description: Updated redacted Token metadata, content: {application/json: {schema: {$ref: '#/components/schemas/TokenMetadataEnvelope'}}}}
        '400': {description: Missing update field, invalid or non-future expiresAt, or invalid scope}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Token is not owned by the non-admin caller}
        '404': {description: Token not found}
        '409': {description: Token is revoked or already expired}
  /api/v1/tokens/{tokenId}/rotate:
    parameters:
      - {name: tokenId, in: path, required: true, schema: {type: string}}
      - {$ref: '#/components/parameters/IdempotencyKey'}
    post:
      summary: Atomically rotate and revoke a scoped service Token
      description: Creates a replacement and revokes the old Token in one transaction. The replacement secret is returned once; an idempotent replay returns the same replacement metadata with no secret and `replayed=true`.
      security: [{bearerAuth: []}]
      responses:
        '201': {description: Replacement Token created, or safely replayed, content: {application/json: {schema: {$ref: '#/components/schemas/TokenMutationEnvelope'}}}}
        '400': {description: Invalid Idempotency-Key}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Token is not owned by the non-admin caller}
        '404': {description: Token not found}
        '409': {description: Token cannot be rotated or idempotency request conflicts/is in progress}
  /api/v1/tokens/{tokenId}/revoke:
    parameters:
      - {name: tokenId, in: path, required: true, schema: {type: string}}
    post:
      summary: Revoke a scoped service Token
      description: Revocation is idempotent and returns redacted metadata with dynamically derived `revoked` status.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Token revoked, content: {application/json: {schema: {$ref: '#/components/schemas/TokenMetadataEnvelope'}}}}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Token is not owned by the non-admin caller}
        '404': {description: Token not found}
        '409': {description: Concurrent lifecycle conflict}
  /api/v1/tokens/{tokenId}/reveal:
    parameters: [{name: tokenId, in: path, required: true, schema: {type: string}}]
    post:
      summary: Reveal an encrypted service-token secret
      description: Administrator-only, audited, no-store response. Requires X-Token-Reveal-Confirm and Idempotency-Key. Hash-only legacy tokens must be rotated first.
      security: [{bearerAuth: []}]
      parameters:
        - {name: X-Token-Reveal-Confirm, in: header, required: true, schema: {type: string}}
        - {$ref: '#/components/parameters/IdempotencyKey'}
      responses: {'200': {description: One-time secret, content: {application/json: {schema: {$ref: '#/components/schemas/TokenRevealEnvelope'}}}}, '403': {description: Admin permission required}, '409': {description: Secret is not recoverable}}
  /api/v1/traces/{traceId}:
    parameters: [{name: traceId, in: path, required: true, schema: {type: string}}]
    get:
      summary: Read a previously completed logical traceroute
      security: [{bearerAuth: []}]
      responses: {'200': {description: Trace result, content: {application/json: {schema: {$ref: '#/components/schemas/TraceEnvelope'}}}}, '404': {description: Trace not found}}
  /api/v1/audit-logs:
    get:
      security: [{bearerAuth: []}]
      summary: List audit logs newest first
      description: Returns a filtered cursor page ordered by createdAt descending and ID descending as a deterministic tie-breaker. Text filters are exact matches; createdFrom and createdTo are inclusive. Cursors are opaque; cursors issued by older versions are accepted.
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {name: actorUserId, in: query, schema: {type: string, maxLength: 255}, description: Exact actor user ID}
        - {name: action, in: query, schema: {type: string, maxLength: 255}, description: Exact audit action}
        - {name: resourceType, in: query, schema: {type: string, maxLength: 255}, description: Exact resource type}
        - {name: resourceId, in: query, schema: {type: string, maxLength: 255}, description: Exact resource ID}
        - {name: createdFrom, in: query, schema: {type: string, format: date-time}, description: Inclusive start time}
        - {name: createdTo, in: query, schema: {type: string, format: date-time}, description: Inclusive end time}
      responses:
        '200': {description: Cursor page of audit records, content: {application/json: {schema: {$ref: '#/components/schemas/AuditLogPageEnvelope'}}}}
        '400': {description: Invalid time filter}
        '422': {description: Invalid sort or filter value}
        '403': {description: Admin permission required}
  /api/v1/downloads:
    get:
      summary: Read current release download metadata
      description: Administrator-only release information. URLs point to immutable GitHub release assets for the configured repository and never include credentials or private configuration.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Current release metadata, content: {application/json: {schema: {$ref: '#/components/schemas/DownloadInfoEnvelope'}}}}
        '401': {description: Missing or invalid management bearer Token}
        '403': {description: Administrator required}
  /api/v1/vpn-peers:
    get:
      summary: List the VPN peers visible to the caller
      description: |
        Management plane only. A regular user sees their own peers and an
        administrator sees every peer; the owner restriction is applied inside
        the paginated query, so a peer the caller may not see appears on no page
        and a page is never truncated to hide one. An unknown `status` narrows to
        nothing rather than to everything. Issuing a peer does not yet give it
        connectivity: the gateway data plane is a later phase of the approved
        rollout recorded in ADR 0002.
      security: [{bearerAuth: []}]
      parameters:
        - {$ref: '#/components/parameters/Cursor'}
        - {$ref: '#/components/parameters/Limit'}
        - {$ref: '#/components/parameters/ListSort'}
        - {$ref: '#/components/parameters/ListOrder'}
        - {name: status, in: query, schema: {type: string, enum: [active, disabled, revoked]}}
        - {name: nodeId, in: query, schema: {type: string}}
        - {name: agentId, in: query, schema: {type: string}}
        - {name: keyword, in: query, schema: {type: string, maxLength: 128}}
      responses:
        '200': {description: Cursor page, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerPageEnvelope'}}}}
        '422': {description: Invalid sort or filter value}
        '401': {description: Unauthenticated}
        '503': {description: The peer service is not assembled on this server}
    post:
      summary: Issue a VPN peer
      description: |
        Allocates one /32 from the subnet the serving node leases, generates an
        X25519 key pair and seals the private key with AES-256-GCM. The response
        is a summary and carries no key material; the private key is retrievable
        only through `config:reveal`. `agentId` must name an enabled egress agent
        the caller owns, or any agent when the caller is an administrator; a
        missing, foreign and disabled agent all answer identically so agent
        identifiers cannot be enumerated. `icmpEnabled` is refused until agents
        negotiate the capability. Replaying the same `Idempotency-Key` returns
        the stored response and cannot burn a second address.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerCreateRequest'}}}}
      responses:
        '201': {description: Issued, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerCreateEnvelope'}}}}
        '400': {description: 'Field validation failed (vpn_peer_invalid) or the configured pool is unusable (vpn_ip_pool_invalid)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '401': {description: Unauthenticated}
        '409': {description: 'vpn_node_disabled, vpn_agent_capability_missing, vpn_ip_pool_exhausted or vpn_peer_conflict', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '503': {description: 'vpn_capacity_exhausted, or credential_secret_unavailable when TUNNELMESH_TOKEN_ENCRYPTION_KEY is not configured; nothing is stored', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
  /api/v1/vpn-peers/{peerId}:
    parameters:
      - {name: peerId, in: path, required: true, schema: {type: string}}
    get:
      summary: Read one VPN peer
      description: A peer owned by somebody else answers 404 rather than 403, because 403 would confirm the identifier exists and turn this route into an enumeration oracle.
      security: [{bearerAuth: []}]
      responses:
        '200': {description: Peer summary, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerEnvelope'}}}}
        '401': {description: Unauthenticated}
        '404': {description: 'Not found or not visible (vpn_peer_not_found)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '503': {description: The peer service is not assembled on this server}
    patch:
      summary: Update the supplied VPN peer fields
      description: |
        Omitted fields keep their stored value, and at least one field is
        required. `status` accepts `active` and `disabled` only: revocation has
        its own verb, so the terminal state cannot be reached two ways where one
        of them would skip the audit entry the other writes. An expired peer may
        still be renamed and may still have its policy narrowed, because refusing
        that would leave no way to shorten the lifetime of a peer already past
        it; only a newly written `expiresAt` has to be in the future.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerPatchRequest'}}}}
      responses:
        '200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerEnvelope'}}}}
        '400': {description: 'Invalid field, an empty patch, or an attempt to set the terminal state (vpn_peer_invalid)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '401': {description: Unauthenticated}
        '404': {description: 'Not found or not visible (vpn_peer_not_found)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '409': {description: 'vpn_peer_conflict for a revoked peer, vpn_node_disabled on a node with server.vpn.enabled false', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '503': {description: The peer service is not assembled on this server}
    delete:
      summary: Revoke a VPN peer
      description: |
        Terminal. The row is kept for audit and the address is never reused, so a
        retired /32 cannot later carry somebody else's traffic. A revoked peer
        stays readable and stops being loaded by the gateway. Revoking twice is
        idempotent, because "already revoked" is not something a caller can act
        on and a retry after a lost response must not fail.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses:
        '200': {description: The revoked peer, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerEnvelope'}}}}
        '401': {description: Unauthenticated}
        '404': {description: 'Not found or not visible (vpn_peer_not_found)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '409': {description: 'vpn_node_disabled on a node with server.vpn.enabled false', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '503': {description: The peer service is not assembled on this server}
  /api/v1/vpn-peers/{peerId}/rotate:
    parameters:
      - {name: peerId, in: path, required: true, schema: {type: string}}
    post:
      summary: Rotate the peer key pair
      description: |
        Replaces the key pair and keeps the address, which is part of the user's
        own configuration; the old public key stops working immediately. The
        action is idempotent under `Idempotency-Key`, because a retry that
        rotated twice would leave the caller holding a configuration that no
        longer works with no error to explain it. Rotating does not consume
        another address and does not move the allocation counter.
      security: [{bearerAuth: []}]
      parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
      responses:
        '200': {description: The rotated peer, content: {application/json: {schema: {$ref: '#/components/schemas/VPNPeerEnvelope'}}}}
        '401': {description: Unauthenticated}
        '404': {description: 'Not found or not visible (vpn_peer_not_found)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '409': {description: 'vpn_peer_conflict for a revoked peer, vpn_node_disabled on a disabled node', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '503': {description: 'credential_secret_unavailable when TUNNELMESH_TOKEN_ENCRYPTION_KEY is not configured', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
  /api/v1/vpn-peers/{peerId}/config:reveal:
    parameters:
      - {name: peerId, in: path, required: true, schema: {type: string}}
    post:
      summary: Reveal the peer configuration, private key included
      description: |
        Audited, `no-store`, and the only response in this API that contains a
        peer private key. Requires `X-VPN-Config-Reveal-Confirm`, an
        `Idempotency-Key` and `acknowledgeRisk: true`; visibility is resolved
        before those preconditions so the route cannot be used to probe
        identifiers. The key is sealed rather than hashed, so a confirmed reveal
        may be repeated. The request is deliberately not served from the
        idempotency store: that store persists response bodies, and this one
        carries key material. Answers `vpn_node_disabled` while this node has no
        gateway identity, rather than rendering a file whose `[Peer] PublicKey`
        is blank and would fail on the user's machine.
      security: [{bearerAuth: []}]
      parameters:
        - {name: X-VPN-Config-Reveal-Confirm, in: header, required: true, schema: {type: string}}
        - {$ref: '#/components/parameters/IdempotencyKey'}
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/VPNConfigRevealRequest'}}}}
      responses:
        '200':
          description: The rendered wg-quick configuration
          headers:
            Cache-Control: {schema: {type: string, const: no-store}}
          content: {application/json: {schema: {$ref: '#/components/schemas/VPNConfigRevealEnvelope'}}}
        '400': {description: A confirmation header, the idempotency key or the acknowledgement is missing}
        '401': {description: Unauthenticated}
        '404': {description: 'Not found or not visible (vpn_peer_not_found)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '409': {description: 'vpn_node_disabled when this node has no gateway identity', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '503': {description: 'credential_secret_unavailable when TUNNELMESH_TOKEN_ENCRYPTION_KEY is not configured', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
  /api/v1/vpn-peers/{peerId}/flows:
    parameters:
      - {name: peerId, in: path, required: true, schema: {type: string}}
    get:
      summary: List the active flows of one peer
      description: |
        Answered from the memory of the gateway that serves the peer, so it is a
        snapshot of this instant and not a history: a flow leaves the list the
        moment it is closed, reaped or revoked, and nothing here is persisted.
        The projection is narrow by construction - no payload, no key material and
        no agent-side socket detail - and carries no cursor, because one peer's
        flow set is bounded by server.vpn.max_flows_per_peer and a list read in
        two requests would describe two different moments.
        Visibility is resolved before the gateway is asked, so an identifier that
        does not exist, or that belongs to somebody else, is a 404, and the route
        cannot be used to probe either fact.
      security: [{bearerAuth: []}]
      responses:
        '200':
          description: The flows this node's gateway holds for the peer. An empty list means the peer is served here and is idle.
          content: {application/json: {schema: {$ref: '#/components/schemas/VPNFlowListEnvelope'}}}
        '401': {description: Unauthenticated}
        '404': {description: 'Not found or not visible (vpn_peer_not_found)', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '501': {description: 'This node cannot observe that peer (vpn_not_implemented): it is served by another gateway node, or this server has no running gateway because it was built without the vpn tag or runs with server.vpn disabled. Not an empty list, which would claim the peer is idle.', content: {application/json: {schema: {$ref: '#/components/schemas/VPNErrorEnvelope'}}}}
        '503': {description: The peer service is not assembled on this server}
  /api/v1/vpn-nodes:
    get:
      summary: Read the VPN gateway status of this node
      description: |
        One entry: the node this process is. A fleet-wide view would need a
        cluster RPC that does not exist, and answering with the nodes this process
        happens to know would show part of a fleet as if it were the whole fleet.
        The shape stays a list so that adding the RPC later does not change a
        contract a console already reads.
        Available in both builds. A server without the vpn tag, or one with
        server.vpn disabled, reports enabled false and omits the counters, which a
        console renders as an em dash. The counters belong to the subnet lease and
        to the peer table, so they are read from those rather than derived from
        configuration: a capacity computed from a prefix is a promise the issuer
        may not be able to keep.
      security: [{bearerAuth: []}]
      responses:
        '200':
          description: This node's gateway status
          content: {application/json: {schema: {$ref: '#/components/schemas/VPNNodeListEnvelope'}}}
        '401': {description: Unauthenticated}
        '404': {description: A node sub-resource, which this route does not have}
        '405': {description: Method not allowed}
        '500': {description: 'The gateway could not read the counters it reports. The request fails rather than answer with them omitted, because an absent counter reads as unknown and a slow database is not unknown.'}
components:
  securitySchemes:
    bearerAuth: {type: http, scheme: bearer}
    clientTokenAuth:
      type: http
      scheme: bearer
      description: |
        A client service token (`client.token` in client.yaml), presented as a bearer
        token. It is a distinct scheme from `bearerAuth` even though the transport is the
        same, because the credential is resolved differently: `bearerAuth` answers to
        `api_tokens` (console logins), while this one answers to `service_tokens` with
        `type: client`. A console token is rejected here with 401 and a client token is
        rejected by every `bearerAuth` endpoint, so neither surface can be reached with
        the other's credential.
    agentTokenAuth:
      type: http
      scheme: bearer
      description: An Agent service token bound to exactly one enabled Agent.
  parameters:
    Cursor: {name: cursor, in: query, schema: {type: string}}
    Limit: {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 500}}
    ListSort: {name: sort, in: query, description: Allowlisted column name; pair with order. The default is the endpoint's business timestamp descending., schema: {type: string}}
    ListOrder: {name: order, in: query, description: Sort direction., schema: {type: string, enum: [asc, desc], default: desc}}
    ListKeyword: {name: keyword, in: query, schema: {type: string, maxLength: 128}}
    ListRoleFilter: {name: role, in: query, description: Comma-separated role values; repeated values are deduplicated., schema: {type: string, enum: [user]}}
    ListMultiTokenTypeFilter: {name: type, in: query, description: Comma-separated token types., schema: {type: string, enum: [agent, client, server_node]}}
    ListMultiStatusFilter: {name: status, in: query, description: Comma-separated lifecycle values; accepted values depend on the endpoint., schema: {type: string}}
    IncludeStale: {name: includeStale, in: query, description: Include an expired or explicitly stale snapshot, schema: {type: boolean, default: false}}
    IdempotencyKey: {name: Idempotency-Key, in: header, schema: {type: string, maxLength: 255}}
    TokenOwnerFilter: {name: owner, in: query, description: Owner user ID; admin-only when it differs from the current user, schema: {type: string}}
  schemas:
    Envelope: {type: object, required: [code, msg, data], properties: {code: {type: integer}, msg: {type: string}, data: {type: object}}}
    LoginRequest:
      type: object
      additionalProperties: false
      required: [username, password]
      description: Password login attempt. Unknown fields are rejected rather than ignored. The body is capped at 1 MiB.
      properties:
        username: {type: string, description: 'Trimmed and lower-cased for the throttle bucket, so case variants cannot multiply the brute-force budget.'}
        password: {type: string, format: password, description: 'Write-only. Cleared from memory as soon as the credential check finishes, never placed in a challenge payload, never logged and never echoed back.'}
        trustDevice: {type: boolean, description: Ask for a long-lived trusted-device token on success. Honoured only while the policy allows the bypass; ignored when device trust is disabled.}
    PasswordChangeRequest: {type: object, additionalProperties: false, required: [currentPassword, newPassword], properties: {currentPassword: {type: string, format: password}, newPassword: {type: string, format: password, minLength: 12, maxLength: 128}}}
    UserCreateRequest: {type: object, additionalProperties: false, required: [username], properties: {username: {type: string, minLength: 3, maxLength: 64, pattern: '^[A-Za-z0-9._-]+$'}}}
    UserStatusRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      description: Administrator-facing account toggles. At least one field must be present; a body that changes nothing is rejected with 400 rather than silently accepted.
      properties:
        disabled: {type: boolean, description: Logically disables or re-enables the account. A disabled account cannot log in and its bearer tokens stop validating.}
        mfaRequired: {type: boolean, description: Per-account second-factor requirement. It is a floor rather than a suggestion and keeps being enforced even when the global mfaMode is disabled or optional. Setting it on an account with no enrollment blocks that account's next login until it enrolls.}
    User:
      type: object
      additionalProperties: false
      required: [id, username, role, disabled, createdAt, updatedAt, mfaRequired, authSource]
      description: The only projection of an account row that may reach a response. The password hash and every identity secret stay out of it by construction.
      properties:
        id: {type: string}
        username: {type: string}
        role: {type: string, enum: [admin, user]}
        disabled: {type: boolean}
        deletedAt: {type: string, format: date-time, nullable: true}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
        mfaRequired: {type: boolean, description: Per-account second-factor override}
        authSource: {type: string, enum: [local, oidc, mixed], description: 'Where the primary credential comes from. local is a username/password account, oidc is provisioned by an identity provider, and mixed is a local account that also has at least one linked external identity.'}
    UserEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/User'}}
    LoginSuccess:
      type: object
      additionalProperties: false
      required: [token, user, deviceTrusted]
      description: Authenticated login outcome. A newly trusted device is delivered as an HttpOnly Set-Cookie on the same response rather than in the body, so a script-injection bug in the console cannot read the long-lived credential.
      properties:
        token: {type: string, readOnly: true, description: 'Opaque console bearer token, returned once. Only its hash is stored and there is no reveal endpoint for it.'}
        user: {$ref: '#/components/schemas/User'}
        deviceTrusted: {type: boolean, description: 'True when this request is covered by a trusted device, either one that already existed or one issued now.'}
        recoveryCodesExhausted: {type: boolean, description: Present and true only when a recovery-code login just consumed the last remaining bypass code.}
    LoginSuccessEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/LoginSuccess'}}
    MFAChallenge:
      type: object
      additionalProperties: false
      required: [mfaRequired, challengeId, methods, expiresAt]
      description: Returned with HTTP 200 instead of a session when the account still owes a second factor. The presence of `mfaRequired` is what distinguishes this shape from an authenticated login.
      properties:
        mfaRequired: {type: boolean, enum: [true]}
        challengeId: {type: string, description: 'Single-use challenge identifier. It is a bearer secret for the remainder of the login, so it is never written to the audit log.'}
        methods: {type: array, minItems: 1, items: {type: string, enum: [totp, recovery]}, description: Always contains totp. recovery is added only when the account already has an enabled second factor.}
        expiresAt: {type: string, format: date-time, description: RFC 3339 UTC deadline of the challenge window}
    MFAChallengeEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/MFAChallenge'}}
    MFAVerifyRequest:
      type: object
      additionalProperties: false
      required: [challengeId, code]
      properties:
        challengeId: {type: string, description: Value returned by the login or OIDC callback step}
        code: {type: string, format: password, description: 'Write-only. One current TOTP code or one single-use recovery code. Cleared from memory as soon as the request goes out of scope and never logged.'}
        trustDevice: {type: boolean, description: Ask for a long-lived trusted-device token on success}
    MFAEnrollRequest:
      type: object
      additionalProperties: false
      description: The whole body may be omitted by a passwordless SSO-only account, which has no password to re-prove.
      properties:
        currentPassword: {type: string, format: password, description: 'Write-only and never stored. Required for an account that has a local password, so a hijacked session cannot be made durable by enrolling an attacker authenticator. Cleared as soon as the request goes out of scope.'}
    MFACodeRequest:
      type: object
      additionalProperties: false
      required: [code]
      properties:
        code: {type: string, format: password, description: 'Write-only. One current TOTP code, or a recovery code where the operation accepts one. Cleared as soon as the request goes out of scope and never logged.'}
    MFAEnrollment:
      type: object
      additionalProperties: false
      required: [secret, otpauthUrl, recoveryCodes, expiresAt]
      description: |
        Returned exactly once, at enrollment time, with `Cache-Control: no-store`.

        There is no reveal endpoint for any of these values, unlike
        `POST /api/v1/tokens/{tokenId}/reveal`. The shared secret is sealed with AES-256-GCM before
        it reaches storage and the recovery codes are stored as one-way hashes, so neither can be
        read back by an administrator, by a database dump or by a later API call. Losing them means
        re-enrolling, or asking an administrator for `POST /api/v1/users/{userId}/mfa/reset`.
      properties:
        secret: {type: string, readOnly: true, description: 'Base32 TOTP shared secret. Treat it as a credential: it is returned once and is never re-readable.'}
        otpauthUrl: {type: string, readOnly: true, description: 'otpauth:// URI for QR scanning. It embeds the same one-time secret, so it must be handled as a secret too.'}
        recoveryCodes: {type: array, readOnly: true, items: {type: string, format: password}, description: 'Single-use bypass codes, returned once. Only one-way hashes are stored, so they are never re-readable. Regenerating replaces the whole set.'}
        expiresAt: {type: string, format: date-time, description: RFC 3339 UTC deadline for confirming this pending enrollment}
    MFAEnrollmentEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/MFAEnrollment'}}
    MFAStatus:
      type: object
      additionalProperties: false
      required: [status, remainingRecoveryCodes, policy]
      description: Self-service second-factor status. Never carries the shared secret or any code.
      properties:
        status: {type: string, enum: [none, pending, enabled], description: 'pending means an enrollment exists but was never confirmed with a live code, so it does not yet satisfy the policy.'}
        enabledAt: {type: string, format: date-time, nullable: true}
        lastUsedAt: {type: string, format: date-time, nullable: true}
        remainingRecoveryCodes: {type: integer, minimum: 0, description: A count only. The codes themselves are stored as one-way hashes and are never re-readable.}
        policy: {$ref: '#/components/schemas/EffectiveMFAPolicy'}
    EffectiveMFAPolicy:
      type: object
      additionalProperties: false
      required: [mode, required]
      properties:
        mode: {type: string, enum: [disabled, optional, required], description: The global operator setting}
        required: {type: boolean, description: 'The effective decision for this account. True when the global mode is required, when it is optional and a factor is enabled, or when the account carries the per-account mfaRequired override.'}
    MFAStatusEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/MFAStatus'}}
    AdminMFAStatus:
      type: object
      additionalProperties: false
      required: [status, remainingRecoveryCodes]
      description: "Administrator view of somebody else's second factor. Deliberately narrower than the self-service status: no timestamps and no policy, because neither is needed to decide whether a reset is warranted."
      properties:
        status: {type: string, enum: [none, pending, enabled]}
        remainingRecoveryCodes: {type: integer, minimum: 0, description: A count only; the codes are never re-readable}
    AdminMFAStatusEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/AdminMFAStatus'}}
    RecoveryCodes:
      type: object
      additionalProperties: false
      required: [recoveryCodes]
      description: "Returned exactly once with `Cache-Control: no-store`. Every previously issued code is invalidated by the same call. There is no reveal endpoint for recovery codes."
      properties:
        recoveryCodes: {type: array, readOnly: true, items: {type: string, format: password}, description: 'Replacement plaintext bypass codes. Only one-way hashes are stored, so these values can never be read back.'}
    RecoveryCodesEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/RecoveryCodes'}}
    Device:
      type: object
      additionalProperties: false
      required: [id, name, userAgent, ip, trustedAt, expiresAt, current]
      description: One trusted device. The device token and its SHA-256 digest are never part of this view, and no API can read the token back after the login that issued it.
      properties:
        id: {type: string}
        name: {type: string, maxLength: 191, description: Operator-set label. Empty until PATCH sets one.}
        userAgent: {type: string, description: User-Agent captured when the device was trusted}
        ip: {type: string, description: Client address captured when the device was trusted}
        trustedAt: {type: string, format: date-time}
        expiresAt: {type: string, format: date-time, description: 'The cookie lifetime is derived from this value, so a browser credential can never outlive the row that authorizes it.'}
        lastSeenAt: {type: string, format: date-time, nullable: true, description: 'Refreshed at most once per touch-throttle interval, so a busy console does not turn every request into a write.'}
        current: {type: boolean, description: 'True only on the self-service list, for the device that presented the trusted-device cookie on this request. Always false on the administrator list, where the caller is not the subject.'}
    DevicePage:
      type: object
      required: [items, nextCursor, hasMore]
      description: The trusted-device list is bounded by the maxTrustedDevices policy, so one page is always complete. `nextCursor` is always the empty string and `hasMore` is always false; the envelope exists so callers can treat every list uniformly.
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/Device'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    DevicePageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/DevicePage'}}
    DeviceRenameRequest:
      type: object
      additionalProperties: false
      description: A body is required, but `name` itself may be empty to clear the label.
      properties:
        name: {type: string, maxLength: 191, description: 'Trimmed, then truncated to 191 characters before storage. A longer value is shortened rather than rejected. An empty value clears the label.'}
    Identity:
      type: object
      additionalProperties: false
      required: [id, providerId, providerName, subject, username, email, displayName, linkedAt]
      description: One linked external identity. Carries no credential of any kind.
      properties:
        id: {type: string}
        providerId: {type: string}
        providerName: {type: string, description: Resolved server-side so the console does not have to join providers client-side}
        subject: {type: string, description: IdP-side subject identifier. It is not a credential and is included because unlinking the wrong row is unrecoverable without it.}
        username: {type: string, description: The local account username; not stored on the link row}
        email: {type: string}
        displayName: {type: string}
        linkedAt: {type: string, format: date-time}
        lastLoginAt: {type: string, format: date-time, nullable: true}
    IdentityPage:
      type: object
      required: [items, nextCursor, hasMore]
      description: At most one link per provider, so one page is always complete. `nextCursor` is always the empty string and `hasMore` is always false.
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/Identity'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    IdentityPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/IdentityPage'}}
    PublicOIDCProvider:
      type: object
      additionalProperties: false
      required: [id, name, displayName]
      description: The only provider fields an unauthenticated caller may see. Deliberately excludes the issuer, client id, endpoints and every toggle.
      properties:
        id: {type: string}
        name: {type: string, description: Provider slug used to build the authorize and callback URLs}
        displayName: {type: string, description: Label for the login-page button}
    PublicOIDCProviderList:
      type: object
      required: [items]
      description: A bare list with no cursor. The set of enabled and publicly listed providers is small and always complete.
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/PublicOIDCProvider'}}
    PublicOIDCProviderListEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/PublicOIDCProviderList'}}
    OIDCTicketExchangeRequest:
      type: object
      additionalProperties: false
      required: [ticket]
      properties:
        ticket: {type: string, format: password, description: 'Write-only single-use value from the callback redirect. It is a bearer secret: it is never written to the audit log, never echoed back, and is consumed before the session is issued.'}
        trustDevice: {type: boolean, description: Ask for a long-lived trusted-device token on success}
    AuthPolicyUpdateRequest:
      type: object
      additionalProperties: false
      required: [mfaMode, deviceTrustEnabled, deviceTrustTtlSeconds, allowTrustedDeviceBypass, maxTrustedDevices, sessionTokenTtlSeconds]
      description: Full replacement of the authentication policy. Every field is required because an omitted numeric would fall outside the enforced bounds and be rejected anyway.
      properties:
        mfaMode: {type: string, enum: [disabled, optional, required], description: Global second-factor enforcement. optional means an account that has enrolled must use it and one that has not may skip it.}
        deviceTrustEnabled: {type: boolean, description: 'Master switch for trusted devices. Turning it off revokes every bypass immediately on every node, without a token or session change.'}
        deviceTrustTtlSeconds: {type: integer, format: int64, minimum: 3600, maximum: 7776000, description: How long a browser stays trusted. One hour to ninety days.}
        allowTrustedDeviceBypass: {type: boolean, description: 'Lets a trusted device stand in for the second factor. Requires deviceTrustEnabled, because storing a bypass while trust is off would silently grant it the moment trust is re-enabled.'}
        maxTrustedDevices: {type: integer, minimum: 1, maximum: 100, description: Per-account cap. Issuing a device over the cap revokes the oldest active one.}
        sessionTokenTtlSeconds: {type: integer, format: int64, minimum: 0, description: Console bearer-token lifetime in seconds. 0 keeps the historical non-expiring behaviour.}
    AuthPolicy:
      type: object
      additionalProperties: false
      required: [mfaMode, deviceTrustEnabled, deviceTrustTtlSeconds, allowTrustedDeviceBypass, maxTrustedDevices, sessionTokenTtlSeconds, updatedAt]
      description: The authoritative `auth_settings` row. Process configuration only seeds it the first time it is read.
      properties:
        mfaMode: {type: string, enum: [disabled, optional, required]}
        deviceTrustEnabled: {type: boolean}
        deviceTrustTtlSeconds: {type: integer, format: int64}
        allowTrustedDeviceBypass: {type: boolean, description: 'Reported as the conjunction of the stored flag and deviceTrustEnabled, so a bypass can never be read back as active while trust is off.'}
        maxTrustedDevices: {type: integer}
        sessionTokenTtlSeconds: {type: integer, format: int64}
        updatedAt: {type: string, format: date-time, description: 'RFC 3339 UTC timestamp, or the empty string while the row has never been written.'}
    AuthPolicyEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/AuthPolicy'}}
    RoleMappingInput:
      type: object
      additionalProperties: false
      required: [claim, role]
      description: One ordered claim-to-role rule. The first match wins.
      properties:
        claim: {type: string, maxLength: 64, description: Claim name to read from the id_token or the userinfo document}
        value: {type: string, description: Value to compare against. May be empty where the rule only tests for the claim being present.}
        role: {type: string, enum: [admin, user], description: Lower-cased before storage}
    RoleMapping:
      type: object
      additionalProperties: false
      required: [claim, value, role]
      description: |
        Stored role-mapping rule as it is rendered back by a read API.

        The keys are identical to `RoleMappingInput`, so a rule read from the API can be edited
        and posted back unchanged. Every field is always present on a read: `value` is the empty
        string for a presence-only rule rather than being omitted.
      properties:
        claim: {type: string}
        value: {type: string}
        role: {type: string, enum: [admin, user]}
    OIDCProviderCreateRequest:
      type: object
      additionalProperties: false
      required: [name, issuer, clientId, scopes, redirectUri, defaultRole]
      properties:
        name: {type: string, pattern: '^[a-z0-9][a-z0-9-]{1,62}$', description: URL-safe slug. It becomes part of the published redirect URI and is immutable after creation.}
        displayName: {type: string, maxLength: 191, description: Defaults to name}
        issuer: {type: string, format: uri, description: Absolute https URL. Must equal the id_token iss claim.}
        clientId: {type: string, maxLength: 255, description: Public identifier; not a secret}
        clientSecret: {type: string, format: password, writeOnly: true, description: 'Write-only, with no length limit of its own beyond the 1 MiB request-body cap. Sealed with AES-256-GCM before it reaches storage and never rendered back by any read API, which reports hasSecret instead. There is no reveal endpoint for it. Omit entirely for a public client that authenticates with PKCE alone.'}
        scopes: {type: array, minItems: 1, items: {type: string}, description: 'Must include openid. Stored deduplicated with openid first, which keeps what an operator reads identical to what the IdP receives.'}
        redirectUri: {type: string, format: uri, description: Absolute https URL inside one of the server's configured allowed bases. This is what bounds where an assertion code can be sent even by a compromised administrator.}
        authorizationEndpoint: {type: string, format: uri, description: Optional absolute http(s) URL. Resolved from discovery when empty.}
        tokenEndpoint: {type: string, format: uri, description: Optional absolute http(s) URL. Resolved from discovery when empty.}
        userinfoEndpoint: {type: string, format: uri, description: Optional absolute http(s) URL. Resolved from discovery when empty.}
        jwksUri: {type: string, format: uri, description: Optional absolute http(s) URL. Resolved from discovery when empty.}
        idTokenAlgs: {type: array, items: {type: string, enum: [RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA]}, description: 'Accepted id_token signature algorithms. Defaults to RS256. None and the HS* family are rejected, so a token signed with the client secret can never be accepted.'}
        usernameClaim: {type: string, maxLength: 64, description: Claim that resolves to the local username. Defaults to preferred_username.}
        roleMappings: {type: array, items: {$ref: '#/components/schemas/RoleMappingInput'}, description: Ordered claim-to-role rules. The first match wins.}
        defaultRole: {type: string, enum: [admin, user], description: 'Role for a just-in-time provisioned account when no mapping matches. Required, because an empty value is neither.'}
        authoritativeRoles: {type: boolean, default: true, description: When true the IdP may change an existing account's role on login}
        autoCreateUsers: {type: boolean, default: true, description: When true an unknown subject is provisioned on first login}
        fetchUserinfo: {type: boolean, default: false, description: When true the userinfo endpoint is called in addition to verifying the id_token}
        publicListed: {type: boolean, default: true, description: When true the provider appears in the unauthenticated listing that renders the login-page buttons}
        enabled: {type: boolean, default: true}
    OIDCProviderUpdateRequest:
      type: object
      additionalProperties: false
      description: |
        Partial update. Every field is optional and an omitted field keeps the stored value rather
        than switching it off, which is why the toggles are nullable. The merged result is
        revalidated as a whole before anything is written. `PUT` accepts the same body.
      properties:
        name: {type: string, pattern: '^[a-z0-9][a-z0-9-]{1,62}$', description: Immutable. Omit it or repeat the stored value; supplying a different one is rejected with oidc_provider_invalid.}
        displayName: {type: string, maxLength: 191}
        issuer: {type: string, format: uri}
        clientId: {type: string, maxLength: 255}
        clientSecret: {type: string, format: password, writeOnly: true, description: 'Write-only. Empty or omitted keeps the stored secret. Supplying one reseals it and requires the encryption key. Never rendered back by any read API and there is no reveal endpoint for it.'}
        scopes: {type: array, minItems: 1, items: {type: string}, description: Must include openid}
        redirectUri: {type: string, format: uri}
        authorizationEndpoint: {type: string, format: uri}
        tokenEndpoint: {type: string, format: uri}
        userinfoEndpoint: {type: string, format: uri}
        jwksUri: {type: string, format: uri}
        idTokenAlgs: {type: array, items: {type: string, enum: [RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA]}}
        usernameClaim: {type: string, maxLength: 64}
        roleMappings: {type: array, items: {$ref: '#/components/schemas/RoleMappingInput'}, description: Replaces the whole ordered list rather than merging into it}
        defaultRole: {type: string, enum: [admin, user]}
        authoritativeRoles: {type: boolean}
        autoCreateUsers: {type: boolean}
        fetchUserinfo: {type: boolean}
        publicListed: {type: boolean}
        enabled: {type: boolean, description: Turning this off on the last enabled provider is refused with oidc_provider_in_use while any account has no other credential}
    OIDCProvider:
      type: object
      additionalProperties: false
      required: [id, name, displayName, issuer, clientId, scopes, redirectUri, authorizationEndpoint, tokenEndpoint, userinfoEndpoint, jwksUri, idTokenAlgs, usernameClaim, roleMappings, defaultRole, authoritativeRoles, autoCreateUsers, fetchUserinfo, publicListed, enabled, hasSecret, createdAt, updatedAt]
      description: |
        Full provider configuration as an administrator sees it.

        The client secret is not part of this view in any form: not the plaintext, not the
        ciphertext, not the nonce, not the key id and not the version. `hasSecret` is the only
        signal, and there is no reveal endpoint for an SSO client secret. A lost secret must be
        re-entered through `PATCH /api/v1/sso/providers/{providerId}`.
      properties:
        id: {type: string}
        name: {type: string, description: Immutable slug used by the authorize and callback routes}
        displayName: {type: string}
        issuer: {type: string, format: uri}
        clientId: {type: string}
        scopes: {type: array, items: {type: string}, description: 'Stored order, with openid first'}
        redirectUri: {type: string, format: uri}
        authorizationEndpoint: {type: string, format: uri, description: Empty when it is resolved from discovery at login time}
        tokenEndpoint: {type: string, format: uri, description: Empty when it is resolved from discovery at login time}
        userinfoEndpoint: {type: string, format: uri, description: Empty when it is resolved from discovery at login time}
        jwksUri: {type: string, format: uri, description: Empty when it is resolved from discovery at login time}
        idTokenAlgs: {type: array, items: {type: string}, description: 'Canonical spellings, never empty'}
        usernameClaim: {type: string}
        roleMappings: {type: array, items: {$ref: '#/components/schemas/RoleMapping'}, description: Ordered rules; the first match wins}
        defaultRole: {type: string, enum: [admin, user]}
        authoritativeRoles: {type: boolean}
        autoCreateUsers: {type: boolean}
        fetchUserinfo: {type: boolean}
        publicListed: {type: boolean}
        enabled: {type: boolean}
        hasSecret: {type: boolean, description: True when an encrypted client secret is stored. The secret itself is never returned.}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    OIDCProviderEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/OIDCProvider'}}
    OIDCProviderPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/OIDCProvider'}}
        nextCursor: {type: string, description: Opaque. Pass it back as the cursor query parameter to read the next page.}
        hasMore: {type: boolean}
    OIDCProviderPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/OIDCProviderPage'}}
    OIDCProviderTest:
      type: object
      additionalProperties: false
      required: [discoveryOk, jwksOk, algorithms, endpoints, error]
      description: |
        Connectivity report. A failure is carried inside the 200 body rather than as an HTTP error,
        because surfacing the failure is the point of the call.

        The IdP's own error text is attacker-influenced and is never echoed; only the stable codes
        below appear. No secret is returned.
      properties:
        discoveryOk: {type: boolean, description: True when the metadata document was retrieved and parsed}
        jwksOk: {type: boolean, description: True when the signing keys were fetched. Only attempted after discovery succeeds.}
        algorithms: {type: array, items: {type: string}, description: The stored allowlist in canonical spelling}
        endpoints: {type: object, additionalProperties: {type: string}, description: 'Resolved endpoints keyed by authorization, token, jwks and optionally userinfo. Empty when discovery failed.'}
        error: {type: string, enum: ['', oidc_discovery_failed, oidc_jwks_fetch_failed, oidc_relying_party_unavailable], description: Empty on success}
    OIDCProviderTestEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/OIDCProviderTest'}}
    ApiError:
      type: object
      additionalProperties: false
      required: [error]
      description: |
        The `data` payload of every error response. `code` and `msg` in the envelope carry the HTTP
        status and its reason phrase, so clients must branch on `data.error` rather than on prose.

        The enumeration below is the complete set of stable snake_case codes the identity surface
        can produce. A handful of older branches still emit human-readable prose in the same field
        (`not found`, `method not allowed`, `invalid JSON`, `admin role required`); clients should
        treat an unlisted value as opaque and fall back to the HTTP status.
      properties:
        error:
          type: string
          description: Stable machine-readable error code
          enum:
            - unauthenticated
            - invalid_credentials
            - login_throttled
            - mfa_challenge_invalid
            - mfa_attempts_exceeded
            - mfa_code_invalid
            - mfa_code_reused
            - mfa_not_enrolled
            - mfa_not_confirmed
            - mfa_already_enabled
            - mfa_required_by_policy
            - current_password_required
            - current_password_invalid
            - device_trust_disabled
            - device_not_found
            - identity_not_found
            - identity_required_for_login
            - account_not_found
            - oidc_state_invalid
            - oidc_provider_error
            - login_ticket_invalid
            - oidc_user_not_provisioned
            - oidc_provider_not_found
            - oidc_provider_disabled
            - oidc_provider_invalid
            - oidc_provider_in_use
            - oidc_discovery_failed
            - oidc_jwks_fetch_failed
            - oidc_token_exchange_failed
            - oidc_upstream_error
            - oidc_id_token_invalid
            - oidc_unsupported_algorithm
            - oidc_no_matching_key
            - oidc_claim_missing
            - oidc_role_mapping_invalid
            - auth_policy_invalid
            - username_invalid
            - last_admin_protected
            - admin_account_protected
            - forbidden
            - conflict
            - not_found
            - secret_storage_unavailable
            - identity_services_unavailable
            - idempotency_key_required
            - idempotency_key_conflict
            - idempotency_in_progress
            - account_disabled
            - internal_error
    ApiErrorEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/ApiError'}}
    CredentialCreateRequest:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: {type: string, minLength: 1, maxLength: 255}
        type: {type: string, enum: [ssh_public_key, password, proxy_basic], default: ssh_public_key}
        publicKey: {type: string, minLength: 20, maxLength: 16384, description: OpenSSH public-key line; required for ssh_public_key, must be omitted for password and proxy_basic. Private-key material is rejected.}
        username: {type: string, maxLength: 256, description: 'proxy_basic only: the Basic-auth user name. It is not a secret, is stored in publicKey and is echoed back by every read API. Colons and control characters are rejected because RFC 7617 forbids them in a user-id.'}
        enabled: {type: boolean, default: true}
        secret: {$ref: '#/components/schemas/CredentialSecretInput'}
    CredentialSecretInput:
      type: object
      additionalProperties: false
      description: Write-only authentication secret. Sealed with AES-256-GCM before it reaches storage and never rendered back by any read API. Omit or send an empty object to keep the stored secret unchanged; changing `type` clears it.
      properties:
        password: {type: string, format: password, maxLength: 4096, description: Required for password and proxy_basic credentials; rejected for ssh_public_key}
        privateKey: {type: string, format: password, maxLength: 65536, description: Optional for ssh_public_key; enables browser auto-authentication. Rejected for a password credential.}
        passphrase: {type: string, format: password, maxLength: 1024, description: Requires privateKey}
    ExtractSSHPublicKeyRequest:
      type: object
      additionalProperties: false
      required: [privateKey]
      properties:
        privateKey: {type: string, format: password, maxLength: 65536, description: Private key parsed once in server memory and never persisted}
        passphrase: {type: string, format: password, maxLength: 1024}
    ExtractSSHPublicKey:
      type: object
      additionalProperties: false
      required: [publicKey, fingerprint]
      properties:
        publicKey: {type: string}
        fingerprint: {type: string}
    ExtractSSHPublicKeyEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/ExtractSSHPublicKey'}}
    CredentialPatchRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: {type: string, minLength: 1, maxLength: 255}
        type: {type: string, enum: [ssh_public_key, password, proxy_basic]}
        publicKey: {type: string, minLength: 20, maxLength: 16384}
        username: {type: string, maxLength: 256, description: proxy_basic only; an empty value is treated as "field absent" and never clears the stored user name}
        enabled: {type: boolean}
        secret: {$ref: '#/components/schemas/CredentialSecretInput'}
    Credential:
      type: object
      additionalProperties: false
      required: [id, ownerUserId, name, type, publicKey, fingerprint, enabled, hasSecret, status, createdAt, updatedAt]
      properties:
        id: {type: string}
        ownerUserId: {type: string}
        name: {type: string}
        type: {type: string, enum: [ssh_public_key, password, proxy_basic]}
        publicKey: {type: string, description: Empty for a password credential; holds the user name for proxy_basic}
        fingerprint: {type: string, description: Empty for a password credential; hex SHA-256 of the user name for proxy_basic}
        username: {type: string, description: Present only for proxy_basic credentials; mirrors publicKey so callers do not need to know the two share a column}
        enabled: {type: boolean}
        hasSecret: {type: boolean, description: True when an encrypted authentication secret is stored. Neither the ciphertext, nonce, key id nor plaintext is ever returned.}
        status: {type: string, enum: [active, deleted]}
        deletedAt: {type: string, format: date-time, nullable: true}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    CredentialEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/Credential'}}
    CredentialPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/Credential'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    CredentialPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/CredentialPage'}}
    RemoteServerCreateRequest:
      type: object
      additionalProperties: false
      required: [name, host, port, defaultUsername, agentId]
      properties:
        name: {type: string, minLength: 1, maxLength: 255}
        host: {type: string, minLength: 1, maxLength: 255}
        port: {type: integer, minimum: 1, maximum: 65535}
        defaultUsername: {type: string, minLength: 1, maxLength: 255}
        credentialId: {type: string, description: Optional SSH public-key credential owned by the caller}
        agentId: {type: string}
        enabled: {type: boolean, default: true}
    RemoteServerPatchRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: {type: string, minLength: 1, maxLength: 255}
        host: {type: string, minLength: 1, maxLength: 255}
        port: {type: integer, minimum: 1, maximum: 65535}
        defaultUsername: {type: string, minLength: 1, maxLength: 255}
        credentialId: {type: string}
        agentId: {type: string}
        enabled: {type: boolean}
    RemoteServer:
      type: object
      additionalProperties: false
      required: [id, ownerUserId, name, host, port, defaultUsername, agentId, enabled, status, createdAt, updatedAt]
      properties:
        id: {type: string}
        ownerUserId: {type: string}
        name: {type: string}
        host: {type: string}
        port: {type: integer, minimum: 1, maximum: 65535}
        defaultUsername: {type: string}
        credentialId: {type: string, nullable: true}
        agentId: {type: string}
        agentName: {type: string, nullable: true, description: Display name joined from the enabled Agent}
        credentialName: {type: string, nullable: true, description: Display name joined from the selected SSH public-key credential}
        credentialType: {type: string, nullable: true, enum: [ssh_public_key, password], description: Type of the bound credential; null when no credential is bound}
        credentialHasSecret: {type: boolean, nullable: true, description: True when the bound credential can auto-authenticate the browser session; null when no credential is bound}
        agentOnline: {type: boolean, nullable: true, description: True when the Agent has an active lease; null when lease state is unavailable}
        enabled: {type: boolean}
        status: {type: string, enum: [enabled, disabled, deleted]}
        lastConnectedAt: {type: string, format: date-time, nullable: true}
        lastResult: {type: string}
        lastErrorClass: {type: string}
        deletedAt: {type: string, format: date-time, nullable: true}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    RemoteServerEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/RemoteServer'}}
    RemoteServerPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/RemoteServer'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    RemoteServerPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/RemoteServerPage'}}
    WebSSHSessionCreateRequest:
      type: object
      additionalProperties: false
      required: [username]
      properties:
        username: {type: string, minLength: 1, maxLength: 255}
        credentialId: {type: string, description: Optional SSH public-key credential used by the browser client}
    WebSSHTicket:
      type: object
      additionalProperties: false
      required: [sessionId, ticket, websocketPath]
      properties:
        sessionId: {type: string}
        ticket: {type: string, description: One-time ticket; never logged or returned again}
        websocketPath: {type: string, description: Binary WebSocket path excluding the ticket query}
        auth: {$ref: '#/components/schemas/WebSSHAuth'}
    WebSSHAuth:
      type: object
      additionalProperties: false
      required: [kind, credentialId]
      description: One-time decrypted authentication material for the credential owner. Held in browser memory only, never persisted by the Server, never logged, and absent from idempotent replays.
      properties:
        kind: {type: string, enum: [password, private_key], description: Which field the browser must use for libssh2 authentication}
        credentialId: {type: string}
        password: {type: string, format: password, description: Present when kind=password}
        privateKey: {type: string, format: password, description: Present when kind=private_key}
        passphrase: {type: string, format: password, description: Present when the stored private key is passphrase-protected}
    WebSSHTicketEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/WebSSHTicket'}}
    WebSSHSession:
      type: object
      additionalProperties: false
      required: [id, remoteServerId, agentId, status, createdAt, expiresAt]
      properties:
        id: {type: string}
        remoteServerId: {type: string}
        agentId: {type: string}
        status: {type: string, enum: [pending, active, closed, expired]}
        createdAt: {type: string, format: date-time}
        expiresAt: {type: string, format: date-time}
        connectedAt: {type: string, format: date-time, nullable: true}
        closedAt: {type: string, format: date-time, nullable: true}
        closeReason: {type: string}
    WebSSHSessionEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/WebSSHSession'}}
    WebSSHSessionPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/WebSSHSession'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    WebSSHSessionPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/WebSSHSessionPage'}}
    AgentRequest: {type: object, required: [name], properties: {name: {type: string}, capabilities: {type: array, items: {type: string}}, enabled: {type: boolean}}}
    AgentPatchRequest: {type: object, description: The agent fields a PATCH may carry, with no required member so an omitted field keeps its stored value., properties: {name: {type: string, minLength: 1}, capabilities: {type: array, items: {type: string}}, enabled: {type: boolean}}}
    AgentMetadataEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/AgentMetadata'}
    AgentMetadata:
      type: object
      required: [agentId, instanceId, nodeId, epoch, revision, stale, reportedAt, updatedAt, items, instances]
      properties:
        agentId: {type: string}
        instanceId: {type: string, description: Instance ID of the freshest healthy metadata row}
        nodeId: {type: string}
        epoch: {type: integer, format: int64}
        revision: {type: integer, format: int64}
        stale: {type: boolean}
        reportedAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
        items: {type: array, items: {$ref: '#/components/schemas/AgentMetadataItem'}}
        instances:
          type: array
          description: Metadata for every reporting Agent instance
          items: {$ref: '#/components/schemas/AgentMetadataInstance'}
        connections:
          type: array
          description: Live local connections; empty when the API process cannot see local state
          items: {$ref: '#/components/schemas/AgentConnection'}
    AgentMetadataInstance:
      type: object
      required: [instanceId, nodeId, epoch, revision, stale, reportedAt, updatedAt, items]
      properties:
        instanceId: {type: string}
        nodeId: {type: string}
        epoch: {type: integer, format: int64}
        revision: {type: integer, format: int64}
        stale: {type: boolean}
        reportedAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
        items: {type: array, items: {$ref: '#/components/schemas/AgentMetadataItem'}}
        connectionCount: {type: integer, minimum: 0}
    AgentConnection:
      type: object
      required: [agentId, instanceId, nodeId, connectionId, epoch, connectionEpoch, healthy, activeStreams, lastHeartbeatAt]
      properties:
        agentId: {type: string}
        instanceId: {type: string}
        nodeId: {type: string}
        connectionId: {type: string}
        epoch: {type: integer, format: int64}
        connectionEpoch: {type: integer, format: int64}
        serverNodeId: {type: string}
        healthy: {type: boolean}
        activeStreams: {type: integer, minimum: 0}
        lastHeartbeatAt: {type: string, format: date-time}
    AgentConnectionClusterEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data:
              type: object
              required: [connections]
              properties:
                connections: {type: array, items: {$ref: '#/components/schemas/ClusterAgentConnection'}}
    ClusterAgentConnection:
      type: object
      additionalProperties: false
      required: [agentId, instanceId, connectionId, connectionEpoch, serverNodeId, serverNodeEpoch, serverNodeAddress, healthy, activeStreams, healthScore, leaseExpiresAt, local]
      properties:
        agentId: {type: string}
        instanceId: {type: string}
        connectionId: {type: string}
        connectionEpoch: {type: integer, format: int64, description: Registry lease epoch used for cross-node close fencing}
        serverNodeId: {type: string}
        serverNodeEpoch: {type: integer, format: int64}
        serverNodeAddress: {type: string, description: Authenticated relay endpoint of the owning Server node}
        healthy: {type: boolean}
        activeStreams: {type: integer, minimum: 0}
        healthScore: {type: integer, format: int64, minimum: 0}
        lastHeartbeatAt: {type: string, format: date-time}
        leaseExpiresAt: {type: string, format: date-time}
        local: {type: boolean, description: True when the responding Server owns the physical WebSocket}
    AgentConnectionCloseEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/AgentConnectionCloseResult'}
    AgentConnectionCloseResult:
      type: object
      additionalProperties: false
      required: [agentId, connectionId, connectionEpoch, closed]
      properties:
        agentId: {type: string}
        connectionId: {type: string}
        connectionEpoch: {type: integer, format: int64}
        closed: {type: boolean}
    ClientPageEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/ClientPage'}
    ClientEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/Client'}
    ClientPage:
      type: object
      required: [items, hasMore, summary]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/Client'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
        summary: {$ref: '#/components/schemas/ClientListSummary'}
    ClientListSummary:
      type: object
      description: Aggregates over the whole filtered Client population, never the current cursor page, so console counters cannot disagree with the table.
      additionalProperties: false
      required: [total, online, activeConnections, activeStreams, metadataUnavailable, metadataStale]
      properties:
        total: {type: integer, minimum: 0}
        online: {type: integer, minimum: 0, description: Clients with at least one unexpired lease}
        activeConnections: {type: integer, minimum: 0, description: Unexpired physical WebSocket leases across the population}
        activeStreams: {type: integer, minimum: 0, description: Sum of activeStreams over unexpired leases in the whole filtered population}
        metadataUnavailable: {type: integer, minimum: 0, description: Clients that never accepted CLIENT_HELLO}
        metadataStale: {type: integer, minimum: 0, description: Reported metadata past its TTL, regardless of presence}
    Client:
      type: object
      additionalProperties: false
      required: [id, instanceId, ownerUserId, tokenIds, agentIds, status, metadataState, activeConnections, activeStreams, serverNodeIds, lastSeenAt, metadata, capabilities, listeners]
      properties:
        id: {type: string, description: Server-managed Client instance resource ID}
        instanceId: {type: string, description: Stable client-generated logical instance ID}
        ownerUserId: {type: string}
        tokenIds: {type: array, items: {type: string}}
        agentIds: {type: array, items: {type: string}}
        version: {type: string}
        commit: {type: string}
        platform: {type: string}
        hostname: {type: string}
        processStartAt: {type: string, format: date-time}
        status: {type: string, enum: [online, offline], description: Presence only. Derived from unexpired leases, never from metadata state.}
        metadataState: {type: string, enum: [fresh, expired, unavailable], description: fresh and expired require an accepted CLIENT_HELLO; unavailable means the Client never reported metadata and is independent of presence.}
        activeConnections: {type: integer, minimum: 0}
        activeStreams: {type: integer, minimum: 0, description: Sum of activeStreams over the unexpired leases of this instance only}
        serverNodeIds: {type: array, items: {type: string}}
        lastSeenAt: {type: string, format: date-time}
        metadata: {type: object, additionalProperties: {type: string}, description: Bounded non-secret annotations reported by the Client}
        capabilities: {type: array, items: {type: string}}
        listeners:
          type: array
          items:
            type: object
            required: [protocol, listenAddress, agentId, enabled]
            properties:
              protocol: {type: string}
              listenAddress: {type: string}
              agentId: {type: string}
              enabled: {type: boolean}
    ClientConnectionClusterEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data:
              type: object
              required: [connections, truncated]
              properties:
                connections: {type: array, items: {$ref: '#/components/schemas/ClientConnection'}}
                truncated: {type: boolean, description: True when more leases exist for the requested state than limit allowed to return.}
    ClientConnection:
      type: object
      additionalProperties: false
      required: [connectionId, clientInstanceId, tokenId, ownerUserId, serverNodeId, connectionEpoch, activeStreams, healthScore, acquiredAt, lastHeartbeatAt, expiresAt, local]
      properties:
        connectionId: {type: string}
        clientInstanceId: {type: string}
        tokenId: {type: string}
        ownerUserId: {type: string}
        serverNodeId: {type: string}
        connectionEpoch: {type: integer, format: int64}
        activeStreams: {type: integer, minimum: 0, description: Streams the owning Server node still holds open on this connection, written by its heartbeat; an expired lease keeps its final reported value}
        healthScore: {type: integer, format: int64, minimum: 0}
        acquiredAt: {type: string, format: date-time}
        lastHeartbeatAt: {type: string, format: date-time}
        expiresAt: {type: string, format: date-time}
        local: {type: boolean}
    ClientConnectionCloseEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/ClientConnectionCloseResult'}
    ClientConnectionCloseResult:
      type: object
      additionalProperties: false
      required: [clientInstanceId, connectionId, connectionEpoch, closed]
      properties:
        clientInstanceId: {type: string}
        connectionId: {type: string}
        connectionEpoch: {type: integer, format: int64}
        closed: {type: boolean}
    ClientAgent:
      type: object
      additionalProperties: false
      required: [id, name, online]
      description: One agent as a client token sees it. The closed property set is the contract; adding a field here is an information-disclosure change, not a cosmetic one.
      properties:
        id: {type: string, description: 'Agent identifier, the value `client.tunnels[].agent_id` expects'}
        name: {type: string, description: Display name shown in the picker}
        online: {type: boolean, description: True while the agent holds an unexpired connection lease}
    ClientAgentPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/ClientAgent'}}
        nextCursor: {type: string, description: 'Composite `created_at`/`id` key of the last emitted row; empty when this is the last page'}
        hasMore: {type: boolean, description: 'True when more matching agents may follow, either later in the page that was scanned or in a later one'}
    ClientAgentPageEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/ClientAgentPage'}
    AgentSelf:
      type: object
      additionalProperties: false
      required: [id, name]
      properties:
        id: {type: string}
        name: {type: string}
    UpdateLatestEnvelope:
      type: object
      properties:
        code: {type: integer}
        msg: {type: string}
        data:
          type: object
          required: [version, server_version]
          properties:
            version: {type: string, description: The release version published in server.updates.dir, example: v1.4.0}
            server_version: {type: string, description: The Server's own version; clients never upgrade past it, example: v1.4.0}
    AgentSelfEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/AgentSelf'}
    AgentMetadataItem:
      type: object
      required: [name, source, redacted]
      properties:
        name: {type: string}
        source: {type: string, enum: [file, env]}
        value: {type: string, description: Omitted for redacted sensitive fields}
        redacted: {type: boolean}
    PolicyRequest:
      type: object
      required: [targetHost, targetPort]
      properties:
        targetHost: {type: string, description: Exact IP or hostname; use * to match any host}
        targetPort: {type: integer, minimum: 0, maximum: 65535, description: Exact target port; use 0 to match any port}
        protocol: {type: string}
        allowedCIDRs: {type: array, items: {type: string}, description: Additional IP allowlist; an empty array means unrestricted}
        allowedPorts: {type: array, items: {type: integer}, description: Additional port allowlist; an empty array means unrestricted}
    PolicyEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/Policy'}
    PolicyPageEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/PolicyPage'}
    Policy:
      type: object
      required: [id, agentId, protocol, targetHost, targetPort, allowedCIDRs, allowedPorts, createdAt, updatedAt]
      properties:
        id: {type: string}
        agentId: {type: string}
        protocol: {type: string}
        targetHost: {type: string}
        targetPort: {type: integer, minimum: 0, maximum: 65535}
        allowedCIDRs: {type: array, items: {type: string}}
        allowedPorts: {type: array, items: {type: integer}}
        deletedAt: {type: string, format: date-time, nullable: true, description: Non-null means the policy is logically deleted and does not authorize traffic}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    PolicyPage:
      type: object
      required: [items]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/Policy'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    TunnelRequest:
      type: object
      required: [agentId]
      properties:
        agentId: {type: string}
        protocol: {type: string, enum: [http, websocket, http-proxy], description: 'Public application protocol. http-proxy creates a whole-host managed proxy entry instead of a reverse-proxy route.'}
        domain: {type: string, description: 'Exact domain or single-level tm-* wildcard. An http-proxy route must use tp-<name>.<suffix> with a lowercase name of at most 32 characters.'}
        pathPrefix: {type: string, default: /, description: Forced to / for http-proxy routes, where it carries no routing meaning}
        targetHost: {type: string, description: 'IP or hostname that the Agent dials. Required unless protocol is http-proxy, which must omit it: the server stores the wildcard sentinel * because the real target comes from each proxied request.'}
        targetPort: {type: integer, minimum: 1, maximum: 65535, description: Required unless protocol is http-proxy, which must omit it; the server stores 0.}
        publicPort: {type: integer}
        status: {type: string}
        hostHeader: {type: string, description: Host sent to the upstream service; defaults to targetHost}
        targetScheme: {type: string, enum: [http, https], default: http, description: Upstream transport scheme used by the Agent}
        tlsServerName: {type: string, description: TLS SNI and certificate name; requires targetScheme https and defaults to targetHost}
        config: {type: object, description: 'Backward-compatible route configuration; new route fields are also persisted here. Must be omitted for http-proxy routes, which use the dedicated policy fields below.'}
        authMode: {type: string, enum: [none, basic], default: none, description: 'http-proxy only: authentication the proxy entry enforces on every proxied request'}
        credentialId: {type: string, description: 'http-proxy only: required when authMode is basic and must reference an enabled proxy_basic credential the caller may read'}
        sourceCIDRs: {type: array, items: {type: string}, description: 'http-proxy only: client IP allowlist evaluated on the real peer address. Bare IPs are normalized to /32 or /128, duplicates are dropped and the supplied order is kept. An empty list denies every client.'}
        targetCIDRs: {type: array, items: {type: string}, description: 'http-proxy only: target network allowlist. Empty allows any address the dangerous-address filter permits.'}
        targetPorts: {type: array, items: {type: integer, minimum: 1, maximum: 65535}, description: 'http-proxy only: target port allowlist, deduped and sorted. Empty allows any port. It applies to hostname targets as well as IP literals.'}
        allowPrivateTargets: {type: boolean, default: true, description: 'http-proxy only: whether loopback, private, link-local and unique-local targets may be dialled. Cloud metadata addresses are refused either way.'}
        maxConcurrentTunnels: {type: integer, minimum: 0, description: 'http-proxy only: per-route concurrent tunnel cap; 0 keeps the server-wide default'}
        description: {type: string, maxLength: 256, description: 'http-proxy only: operator note rendered by the admin UI'}
    TunnelUpdateRequest:
      type: object
      additionalProperties: false
      description: Omitted fields keep their stored value. `protocol` cannot be switched to or from `http-proxy`; create a new route instead.
      properties:
        agentId: {type: string, description: Omitted preserves the current Agent}
        protocol: {type: string, enum: [http, websocket, http-proxy], description: Omitted preserves the current protocol; switching to or from http-proxy is refused with 400}
        domain: {type: string, description: Exact domain or single-level tm-* wildcard}
        pathPrefix: {type: string, default: /}
        targetHost: {type: string, description: 'Fixed to the * sentinel on an http-proxy route; sending any other value is refused with 400'}
        targetPort: {type: integer, minimum: 0, maximum: 65535, description: 'Fixed to 0 on an http-proxy route; sending any other value is refused with 400'}
        hostHeader: {type: string, description: Omitted preserves the current Host header; empty uses targetHost}
        targetScheme: {type: string, enum: [http, https], description: Omitted preserves the current upstream scheme; empty defaults to http}
        tlsServerName: {type: string, description: Omitted preserves the current TLS name; empty uses targetHost}
        publicPort: {type: integer, minimum: 0, maximum: 65535}
        status: {type: string, enum: [active, disabled]}
        config: {type: object, description: Must be omitted on an http-proxy route together with hostHeader, targetScheme and tlsServerName}
        authMode: {type: string, enum: [none, basic], description: 'http-proxy only; switching to basic re-validates the stored credentialId when none is supplied'}
        credentialId: {type: string, description: 'http-proxy only; must reference an enabled proxy_basic credential'}
        sourceCIDRs: {type: array, items: {type: string}, description: 'http-proxy only; an explicit empty list denies every client, which is a real change and not an omission'}
        targetCIDRs: {type: array, items: {type: string}, description: 'http-proxy only; replaces the stored allowlist'}
        targetPorts: {type: array, items: {type: integer, minimum: 1, maximum: 65535}, description: 'http-proxy only; replaces the stored allowlist'}
        allowPrivateTargets: {type: boolean, description: 'http-proxy only'}
        maxConcurrentTunnels: {type: integer, minimum: 0, description: 'http-proxy only; 0 restores the server-wide default'}
        description: {type: string, maxLength: 256, description: 'http-proxy only'}
    AuditLog:
      type: object
      additionalProperties: false
      required: [id, action, resourceType, createdAt]
      properties:
        id: {type: string}
        actorUserId: {type: string}
        actorUsername:
          type: string
          description: >-
            Display name resolved for actorUserId, so a list reads as a person rather
            than an identifier. Empty for a system event or an account that no longer
            exists; the identifier remains the authority.
        action: {type: string}
        resourceType: {type: string}
        resourceId: {type: string}
        details:
          type: object
          additionalProperties: true
          description: >-
            Action-specific non-secret facts. Update events record the current value
            and, only for a field that actually changed, the previous value under the
            same name suffixed with Before - for example name and nameBefore. Events
            written while serving a management request also carry traceId, the W3C
            trace id of that request, which joins the row to the structured logs.
            Secrets, passwords, private keys, token material, Authorization headers
            and session bytes are never written here.
        createdAt: {type: string, format: date-time}
    DashboardSummaryEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties:
            data:
              type: object
              required: [agentsTotal, agentsOnline, activeTunnels, managedRoutes, validServiceTokens, recentEvents]
              properties:
                agentsTotal: {type: integer}
                agentsOnline: {type: integer}
                activeTunnels: {type: integer}
                managedRoutes: {type: integer}
                validServiceTokens: {type: integer}
                recentEvents: {type: array, maxItems: 10, items: {$ref: '#/components/schemas/AuditLog'}}
    AuditLogPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties:
            data:
              type: object
              required: [items]
              properties:
                items: {type: array, items: {$ref: '#/components/schemas/AuditLog'}}
                nextCursor: {type: string}
                hasMore: {type: boolean}
    ServerNode:
      type: object
      additionalProperties: false
      required: [id, name, address, epoch, enabled, status, activeConnections, activeStreams, healthScore, createdAt, updatedAt]
      properties:
        id: {type: string, maxLength: 255}
        name: {type: string, minLength: 1, maxLength: 255}
        address: {type: string}
        epoch: {type: integer, format: int64, minimum: 0}
        enabled: {type: boolean}
        deletedAt: {type: string, format: date-time, nullable: true}
        lastSeenAt: {type: string, format: date-time, nullable: true}
        expiresAt: {type: string, format: date-time, nullable: true}
        status: {type: string, enum: [online, offline, disabled, deleted]}
        activeConnections: {type: integer, format: int64, minimum: 0}
        activeStreams: {type: integer, format: int64, minimum: 0}
        healthScore: {type: integer, format: int64, minimum: 0}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    ServerNodeUpdateRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: {type: string, minLength: 1, maxLength: 255}
        enabled: {type: boolean}
    ServerNodeEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/ServerNode'}}
    ServerNodePage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/ServerNode'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    ServerNodePageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/ServerNodePage'}}
    TokenScope:
      type: object
      additionalProperties: false
      properties:
        agentIds: {type: array, maxItems: 128, uniqueItems: true, items: {type: string, maxLength: 255}, description: Explicit client-token Agent allowlist; an empty or omitted array allows every Agent authorized to the token owner}
        serverNodeIds: {type: array, maxItems: 128, uniqueItems: true, items: {type: string, maxLength: 255}, description: Explicit server_node allowlist; an empty or omitted array allows every enabled, non-deleted Server node}
        protocols: {type: array, maxItems: 4, uniqueItems: true, items: {type: string, enum: [tcp, udp, http, ws, websocket]}}
        targetCIDRs: {type: array, maxItems: 128, uniqueItems: true, items: {type: string, description: IPv4 or IPv6 CIDR}}
        targetPorts: {type: array, maxItems: 1024, uniqueItems: true, items: {type: integer, minimum: 1, maximum: 65535}}
    TokenScopeUpdate:
      type: object
      additionalProperties: false
      properties:
        agentIds: {type: array, maxItems: 128, uniqueItems: true, items: {type: string, maxLength: 255}, description: Client-token Agent allowlist; empty allows every Agent owned by the Token owner, omitted preserves the current value}
        serverNodeIds: {type: array, maxItems: 128, uniqueItems: true, items: {type: string, maxLength: 255}, description: server_node allowlist; empty creates a fleet Token, omitted preserves the current value}
        protocols: {type: array, maxItems: 4, uniqueItems: true, items: {type: string, enum: [tcp, udp, http, ws, websocket]}, description: Empty array allows every protocol; omitted preserves the current value}
        targetCIDRs: {type: array, maxItems: 128, uniqueItems: true, items: {type: string, description: IPv4 or IPv6 CIDR}, description: Empty array allows every target address; omitted preserves the current value}
        targetPorts: {type: array, maxItems: 1024, uniqueItems: true, items: {type: integer, minimum: 1, maximum: 65535}, description: Empty array allows every port; omitted preserves the current value}
    TokenCreateRequest:
      type: object
      additionalProperties: false
      required: [type]
      properties:
        type: {type: string, enum: [agent, client, server_node]}
        ownerUserId: {type: string, description: Defaults to the authenticated management user; only admins may name another owner}
        agentId: {type: string, description: Required only for agent Tokens}
        nodeId: {type: string, deprecated: true, description: Legacy single-node binding; new server_node Tokens use scope.serverNodeIds}
        scope:
          allOf: [{$ref: '#/components/schemas/TokenScope'}]
          description: Optional narrowing scope; omission is equivalent to an empty scope
        expiresAt: {type: string, format: date-time, description: Optional future expiration time}
    TokenUpdateRequest:
      type: object
      additionalProperties: false
      properties:
        expiresAt:
          type: string
          format: date-time
          nullable: true
          description: Future expiration time, or null for a non-expiring Token; omitted preserves the current value
        scope:
          allOf: [{$ref: '#/components/schemas/TokenScopeUpdate'}]
          description: Optional authorization-scope update
    TokenMetadata:
      type: object
      additionalProperties: false
      required: [id, type, ownerUserId, prefix, scope, status, createdAt, updatedAt, replayed]
      properties:
        id: {type: string}
        type: {type: string, enum: [agent, client, server_node]}
        ownerUserId: {type: string}
        agentId: {type: string}
        nodeId: {type: string}
        prefix: {type: string, description: Short non-secret display prefix}
        scope: {$ref: '#/components/schemas/TokenScope'}
        status: {type: string, enum: [active, revoked, expired, unavailable], description: Derived from timestamps and current owner/bound-resource state}
        expiresAt: {type: string, format: date-time}
        revokedAt: {type: string, format: date-time}
        lastUsedAt: {type: string, format: date-time}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
        replayed: {type: boolean, description: True only for an idempotent create/rotate replay}
    TokenMutation:
      allOf:
        - {$ref: '#/components/schemas/TokenMetadata'}
        - type: object
          properties:
            secret: {type: string, readOnly: true, description: One-time plaintext credential; present only on the first successful create or rotate response and never on replay}
    TokenMetadataEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties:
            data: {$ref: '#/components/schemas/TokenMetadata'}
    TokenMutationEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties:
            data: {$ref: '#/components/schemas/TokenMutation'}
    TokenPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/TokenMetadata'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    TokenPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties:
            data: {$ref: '#/components/schemas/TokenPage'}
    TokenReveal:
      type: object
      required: [tokenId, type, secret, revealedAt, oneTime]
      properties:
        tokenId: {type: string}
        type: {type: string}
        secret: {type: string, readOnly: true}
        expiresAt: {type: string, format: date-time}
        revealedAt: {type: string, format: date-time}
        oneTime: {type: boolean}
    TokenRevealEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/TokenReveal'}}
    TraceRequest:
      type: object
      properties:
        timeoutMs: {type: integer, minimum: 100, maximum: 30000}
        maxHops: {type: integer, minimum: 1, maximum: 16}
        verbose: {type: boolean}
        includeSensitive: {type: boolean}
        includeSecrets: {type: boolean}
    TraceEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {type: object}}
    DiagnoseRequest:
      type: object
      required: [kind, host, port]
      properties:
        kind: {type: string, enum: [tcp, http, udp]}
        host: {type: string, maxLength: 255}
        port: {type: integer, minimum: 1, maximum: 65535}
        timeoutMs: {type: integer, minimum: 10, maximum: 30000}
    DownloadInfoEnvelope:
      allOf:
        - $ref: '#/components/schemas/Envelope'
        - type: object
          properties:
            data: {$ref: '#/components/schemas/DownloadInfo'}
    DownloadInfo:
      type: object
      additionalProperties: false
      required: [version, commit, buildTime, repository, releaseUrl, checksumUrl, manifestUrl, schemaVersion, assets]
      properties:
        version: {type: string}
        commit: {type: string}
        buildTime: {type: string}
        repository: {type: string, description: GitHub owner/name repository}
        releaseUrl: {type: string, format: uri}
        checksumUrl: {type: string, format: uri}
        manifestUrl: {type: string, format: uri}
        schemaVersion: {type: integer, minimum: 1}
        assets: {type: array, minItems: 1, items: {$ref: '#/components/schemas/DownloadAsset'}}
    DownloadAsset:
      type: object
      additionalProperties: false
      required: [platform, archive, url]
      properties:
        platform: {type: string, enum: [linux-amd64, linux-arm64, darwin-amd64, darwin-arm64, windows-amd64, windows-arm64]}
        archive: {type: string}
        url: {type: string, format: uri}
    VPNErrorCode:
      type: string
      description: |
        Stable vpn error code, carried in `data.error` of every mapped failure
        while `msg` carries the human detail. The set is the public contract of
        the embedded VPN gateway design spec §12.1 and internal/vpn/errors.go; a
        code is never renamed without a deprecation window, because consoles,
        metric labels and troubleshooting tables key off these strings.
        `credential_secret_unavailable` is shared with the credential and
        identity surfaces on purpose: one root cause, one code.
      enum: [vpn_peer_invalid, vpn_ip_pool_invalid, vpn_peer_not_found, vpn_peer_conflict, vpn_agent_capability_missing, vpn_ip_pool_exhausted, vpn_node_disabled, vpn_capacity_exhausted, vpn_not_implemented, credential_secret_unavailable]
    VPNError:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error: {$ref: '#/components/schemas/VPNErrorCode'}
    VPNErrorEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/VPNError'}}
    VPNPeer:
      type: object
      additionalProperties: false
      required: [id, ownerUserId, name, publicKey, vpnIp, nodeId, agentId, allowedIps, allowedPorts, allowPrivateTargets, icmpEnabled, maxConcurrentFlows, packetRateLimit, status, createdAt, updatedAt]
      description: |
        One peer as the management plane reports it. The sealed private-key
        columns are absent from this projection by construction rather than
        merely omitted from the JSON: ciphertext, nonce, key id and version never
        reach a response, a browser cache, a proxy log or a console.
      properties:
        id: {type: string}
        ownerUserId: {type: string}
        name: {type: string, maxLength: 255}
        description: {type: string, maxLength: 255}
        publicKey: {type: string, description: Base64 X25519 public key, always 44 characters}
        vpnIp: {type: string, description: The single /32 this peer holds on the tunnel. It survives a key rotation and is never reused after revocation.}
        nodeId: {type: string, description: The gateway node that issued and serves this peer}
        agentId: {type: string, description: The egress agent traffic leaves through}
        allowedIps: {type: array, items: {type: string}, description: 'Egress destinations as IPv4 CIDRs, in canonical sorted form. Empty denies every destination, the semantics WireGuard itself uses for an absent AllowedIPs.'}
        allowedPorts: {type: array, items: {type: integer, minimum: 1, maximum: 65535}, description: 'Empty permits every port, which is the opposite of an empty allowedIps.'}
        allowPrivateTargets: {type: boolean, description: Whether the peer may reach RFC1918, loopback and link-local targets. Cloud metadata, multicast and non-routable addresses stay denied either way.}
        icmpEnabled: {type: boolean, description: 'Whether this peer asked for echo. It is a ceiling rather than a promise: the egress agent also has to have negotiated stream_icmp_echo.v1, and the node has to run server.vpn.icmp_enabled.'}
        maxConcurrentFlows: {type: integer, minimum: 0, description: Zero means unlimited}
        packetRateLimit: {type: integer, minimum: 0, description: Packets per second; zero means unlimited}
        expiresAt: {type: string, format: date-time, nullable: true}
        status: {type: string, enum: [active, disabled, revoked], description: 'revoked is terminal. The row is kept for audit and the peer stops being loaded by the gateway.'}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    VPNPeerEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/VPNPeer'}}
    VPNPeerPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/VPNPeer'}}
        nextCursor: {type: string}
        hasMore: {type: boolean}
    VPNPeerPageEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/VPNPeerPage'}}
    VPNPeerCreate:
      allOf:
        - {$ref: '#/components/schemas/VPNPeer'}
        - type: object
          required: [configRevealPath]
          properties:
            configRevealPath: {type: string, description: 'Where the configuration carrying the private key is fetched. A fact about the resource, not a promise of availability: the node still has to hold a gateway identity.'}
    VPNPeerCreateEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/VPNPeerCreate'}}
    VPNPeerCreateRequest:
      type: object
      additionalProperties: false
      required: [name, agentId]
      description: Unknown fields are rejected rather than ignored, and the body is capped at 1 MiB.
      properties:
        name: {type: string, minLength: 1, maxLength: 255, description: Control characters are rejected, so a name cannot inject a second line into a log entry or a rendered configuration.}
        description: {type: string, maxLength: 255}
        agentId: {type: string, minLength: 1, description: An enabled egress agent owned by the caller. An administrator may name any agent.}
        allowedIps: {type: array, items: {type: string}, description: 'IPv4 CIDRs. An element containing a comma is rejected rather than split, so one entry can never silently become two rules.'}
        allowedPorts: {type: array, items: {type: integer, minimum: 1, maximum: 65535}, description: Omitted or empty permits every port.}
        allowPrivateTargets: {type: boolean, default: false}
        icmpEnabled: {type: boolean, default: false, description: 'Requires server.vpn.icmp_enabled on the node and an egress agent that is connected and has negotiated stream_icmp_echo.v1; otherwise refused with vpn_agent_capability_missing. A cluster node that cannot see the agent connection still issues the peer and records icmpCapability: unverified in the audit entry.'}
        maxConcurrentFlows: {type: integer, minimum: 0, default: 0}
        packetRateLimit: {type: integer, minimum: 0, default: 0}
        expiresAt: {type: string, format: date-time, nullable: true, description: Must be in the future}
    VPNPeerPatchRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      description: A partial update. Absent fields keep their stored value; `allowedIps` and `allowedPorts` are set by presence, because an empty array is a value with a meaning of its own.
      properties:
        name: {type: string, minLength: 1, maxLength: 255}
        description: {type: string, maxLength: 255}
        agentId: {type: string, minLength: 1, description: Re-authorised exactly like a create, so a patch cannot move a peer onto somebody else's egress agent.}
        allowedIps: {type: array, items: {type: string}}
        allowedPorts: {type: array, items: {type: integer, minimum: 1, maximum: 65535}}
        allowPrivateTargets: {type: boolean}
        icmpEnabled: {type: boolean, description: 'Setting true is re-checked against the egress agent capability and refused with vpn_agent_capability_missing when it is missing'}
        maxConcurrentFlows: {type: integer, minimum: 0}
        packetRateLimit: {type: integer, minimum: 0}
        expiresAt: {type: string, format: date-time, nullable: true, description: 'null clears the expiry. An instant must be in the future, which is the one clock rule that does not apply to the rest of a patch.'}
        status: {type: string, enum: [active, disabled], description: 'revoked is refused here; revocation has its own verb and its own audit action.'}
    VPNConfigRevealRequest:
      type: object
      additionalProperties: false
      required: [acknowledgeRisk]
      properties:
        acknowledgeRisk: {type: boolean, enum: [true], description: 'Must be true. Unknown fields are rejected, so a misspelled acknowledgement cannot pass for a confirmed one.'}
    VPNConfigReveal:
      type: object
      additionalProperties: false
      required: [id, name, vpnIp, format, config]
      properties:
        id: {type: string}
        name: {type: string}
        vpnIp: {type: string}
        format: {type: string, enum: [wg-quick]}
        config: {type: string, description: 'The complete configuration file, private key included. Served with Cache-Control: no-store, never written to the idempotency store, and never placed in a log line, a metric label or an audit detail.'}
    VPNConfigRevealEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/VPNConfigReveal'}}
    VPNFlow:
      type: object
      additionalProperties: false
      required: [id, protocol, target, port, startedAt, bytesSent, bytesReceived]
      description: |
        One active flow of one peer, as the gateway holds it in memory. The
        identifier is unique inside one process lifetime and is not a database id:
        flows are never persisted, so an identifier that survived a restart would
        point at nothing.
      properties:
        id: {type: string}
        protocol: {type: string, enum: [tcp, udp, icmp-echo]}
        target: {type: string, description: The internal address the flow goes to}
        port: {type: integer, minimum: 0, maximum: 65535, description: 'Zero for icmp-echo, which has no port'}
        startedAt: {type: string, format: date-time}
        bytesSent: {type: integer, minimum: 0, description: Tunnel payload the peer sent towards the internal service}
        bytesReceived: {type: integer, minimum: 0, description: Tunnel payload the internal service sent back}
    VPNFlowList:
      type: object
      required: [items]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/VPNFlow'}}
    VPNFlowListEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/VPNFlowList'}}
    VPNNodeStatus:
      type: object
      additionalProperties: false
      required: [nodeId, enabled, icmpCapable]
      description: |
        One gateway node. The counters are absent rather than zero when the node
        cannot know them - no gateway in this build, or no subnet leased yet -
        because zero allocated reads as "the pool is empty" while an omitted
        counter reads as "unknown", which is the fact.
      properties:
        nodeId: {type: string}
        enabled: {type: boolean, description: Whether server.vpn is enabled on that node}
        listen: {type: string, description: 'The configured UDP endpoint. Absent while the section is disabled, because nothing is bound.'}
        endpointHost: {type: string, description: The host written into the peer configurations this node issues}
        subnet: {type: string, description: 'The subnet this node leased. Absent until the first peer is issued, which is when a subnet is claimed.'}
        allocated: {type: integer, minimum: 0, description: Addresses handed out of that subnet}
        capacity: {type: integer, minimum: 0, description: 'Addresses the subnet can hold: every address in the prefix except the network address, the broadcast address and the one the node reserves for its own tunnel interface'}
        peers: {type: integer, minimum: 0, description: 'Peers this node issued that are not revoked. A revoked row keeps its address forever, so it is not part of a count an operator reads as live.'}
        icmpCapable: {type: boolean, description: 'server.vpn.icmp_enabled on a gateway that is actually serving. A switch alone cannot promise an echo reply.'}
    VPNNodeList:
      type: object
      required: [items]
      properties:
        items: {type: array, items: {$ref: '#/components/schemas/VPNNodeStatus'}}
    VPNNodeListEnvelope:
      allOf:
        - {$ref: '#/components/schemas/Envelope'}
        - type: object
          properties: {data: {$ref: '#/components/schemas/VPNNodeList'}}
