openapi: 3.0.3
info:
  title: ViewNova Engineering API
  version: 0.1.0
  description: ViewNova control-plane API. Browser, CLI, and automation clients use this contract.
servers:
  - url: /
paths:
  /health:
    get:
      summary: Liveness
      responses:
        "200":
          description: Process is up
  /ready:
    get:
      summary: Readiness
      description: >
        Database readiness. When VION_TLS_CERT is set and the PEM parses, the
        JSON includes tlsCertNotAfter (RFC3339). A missing or unreadable
        certificate omits the field and does not fail readiness. No ACME and
        no certificate reload loop.
      responses:
        "200":
          description: Dependencies are ready
          content:
            application/json:
              schema:
                type: object
                properties:
                  tlsCertNotAfter:
                    type: string
                    format: date-time
                    description: Present when the configured TLS certificate parses
        "503":
          description: A dependency is down
  /api/v1/system/client-config:
    get:
      summary: Browser client bootstrap
      description: runtimeBase, setupRequired, configHash (no auth).
      responses:
        "200":
          description: Client config JSON
  /api/v1/setup/state:
    get:
      summary: First-run setup state
      description: >
        setupRequired when principals empty and setup not marked complete.
        Detail fields require header x-vion-setup-token when setup is active.
      responses:
        "200":
          description: Setup state
  /api/v1/setup/schema:
    get:
      summary: Setting schema for the wizard
      parameters:
        - in: header
          name: x-vion-setup-token
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Schema JSON
        "401":
          description: Missing or invalid setup token
        "410":
          description: Setup already complete
  /api/v1/setup/validate:
    post:
      summary: Validate wizard values for a step
      parameters:
        - in: header
          name: x-vion-setup-token
          required: true
          schema:
            type: string
      responses:
        "200":
          description: ok + errors[]
        "410":
          description: Setup already complete
  /api/v1/setup/test/{kind}:
    post:
      summary: Probe database, runtime, certificate, webhook, or oidc
      parameters:
        - in: path
          name: kind
          required: true
          schema:
            type: string
            enum: [database, runtime, certificate, webhook, oidc]
        - in: header
          name: x-vion-setup-token
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Probe result
        "410":
          description: Setup already complete
  /api/v1/setup/complete:
    post:
      summary: Write vion.toml, create first admin, finish setup
      parameters:
        - in: header
          name: x-vion-setup-token
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Applied; may schedule supervised exit 75
        "410":
          description: Setup already complete
  /api/v1/system/settings:
    get:
      summary: List resolved server settings (admin)
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Settings + schema
        "403":
          description: Not admin
    put:
      summary: Write settings to vion.toml (admin)
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Written
        "400":
          description: Validation failed or env-locked key
  /api/v1/system/apply:
    post:
      summary: Apply pending restart (admin)
      security:
        - bearerAuth: []
      responses:
        "200":
          description: restart scheduled or manual
  /api/v1/auth/login:
    post:
      summary: Create a local session
      description: >
        Body: username, password; optional tenantId or tenantSlug (ADR 0013).
        Omit tenant fields to use the default tenant (backward compatible).
      responses:
        "200":
          description: Bearer token
        "401":
          description: Invalid credentials
  /api/v1/auth/options:
    get:
      summary: Login methods configured on this server
      description: >
        Unauthenticated. Returns { oidc, breakGlass, endSession, multiTenant } so Studio can show
        Sign in with OIDC only when OIDC is configured, and a quiet Emergency
        access link only when break-glass is configured. endSession is true only
        when VION_OIDC_END_SESSION_ENDPOINT is non-empty; the endpoint URL is
        not returned. multiTenant mirrors VION_MULTI_TENANT. Does not return
        secrets, usernames, or the issuer URL.
      responses:
        "200":
          description: Capability flags
  /api/v1/tenants:
    get:
      summary: List tenants (admin)
      description: >
        Own tenant when VION_MULTI_TENANT is unset; all tenants when enabled.
      responses:
        "200":
          description: Tenant list
        "403":
          description: Not admin
    post:
      summary: Create tenant (platform gate)
      description: >
        Requires VION_MULTI_TENANT=1 and tenant admin. Body name + slug and an
        optional admin { username, password, displayName } that creates the
        tenant's first admin principal in the same transaction (response
        adminPrincipalId). When unset, returns 403 with code tenant.not_enabled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, slug]
              properties:
                name:
                  type: string
                slug:
                  type: string
                admin:
                  type: object
                  required: [username, password]
                  properties:
                    username:
                      type: string
                    password:
                      type: string
                    displayName:
                      type: string
      responses:
        "201":
          description: Created
        "400":
          description: Invalid name, slug, username, or password policy
        "403":
          description: Forbidden or tenant.not_enabled
        "409":
          description: Slug already used
  /api/v1/tenants/{tenantId}:
    get:
      summary: Get tenant (admin)
      parameters:
        - in: path
          name: tenantId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Tenant
        "403":
          description: Cross-tenant without multi-tenant flag
        "404":
          description: Unknown tenant
  /api/v1/auth/break-glass:
    post:
      summary: Emergency admin session via TOTP
      description: >
        Requires VION_BREAK_GLASS_TOTP_SECRET. Mints a one-hour admin session and
        writes auth.break_glass.* audit events. Optional VION_BREAK_GLASS_USERNAME
        selects the admin principal; otherwise the oldest admin is used.
      responses:
        "200":
          description: Bearer token with breakGlass true and expiresAt (created+1h)
        "401":
          description: Invalid TOTP code
        "503":
          description: Break-glass is not configured or no admin principal exists
  /api/v1/auth/oidc/start:
    get:
      summary: Start OIDC Authorization Code login
      description: >
        Requires VION_OIDC_ISSUER, VION_OIDC_CLIENT_ID, VION_OIDC_CLIENT_SECRET,
        and VION_OIDC_REDIRECT_URI. Redirects (302) to the IdP authorize URL with
        state and nonce stored server-side. When unset, returns 503.
      responses:
        "302":
          description: Redirect to identity provider
        "503":
          description: OIDC is not configured
  /api/v1/auth/oidc/callback:
    get:
      summary: Complete OIDC Authorization Code login
      description: >
        Exchanges the authorization code, validates id_token (iss/aud/exp/nonce
        and JWKS signature), links preferred_username or email to an existing
        principal, or creates a viewer when VION_OIDC_AUTO_PROVISION=true.
        Default response is the same opaque session JSON as local login
        ({ token, principal }). When VION_OIDC_UI_REDIRECT is set, responds with
        302 to that URL with fragment #token=… for Studio Web (JSON clients
        leave the variable unset).
      parameters:
        - in: query
          name: code
          schema:
            type: string
        - in: query
          name: state
          schema:
            type: string
      responses:
        "200":
          description: Bearer token (JSON) when VION_OIDC_UI_REDIRECT is unset
        "302":
          description: Redirect to VION_OIDC_UI_REDIRECT with #token= fragment
        "400":
          description: Missing or invalid callback parameters
        "401":
          description: Token exchange or id_token validation failed
        "403":
          description: No matching principal and auto-provision disabled
        "503":
          description: OIDC is not configured
  /api/v1/auth/oidc/logout:
    get:
      summary: Redirect to the OIDC end-session endpoint
      description: >
        Unauthenticated. When VION_OIDC_END_SESSION_ENDPOINT is non-empty,
        responds 302 with Location set to that endpoint exactly and no JSON body.
        When the variable is unset or blank, responds 404 JSON and the body does
        not contain an endpoint URL. Does not change endSession on
        /api/v1/auth/options, which stays a boolean.
      responses:
        "302":
          description: Redirect to the configured end-session endpoint
          headers:
            Location:
              schema:
                type: string
              description: VION_OIDC_END_SESSION_ENDPOINT with no added query
        "404":
          description: End-session endpoint is not configured
  /api/v1/auth/me:
    get:
      summary: Current principal
      description: >
        Own view: id, username, displayName, role (effective; an API token's
        roleMax may lower it), disabled, createdAt, email, mobile, locale,
        timeZone, lastLoginAt, passwordChangedAt, accountExpiresAt,
        avatarUpdatedAt. Also returns tenant { id, name, slug, isDefault } for
        the principal's tenant.
      responses:
        "200":
          description: "{ principal, tenant }"
    patch:
      summary: Update your own contact fields
      description: >
        Accepts only email, mobile (E.164), locale (BCP 47), and timeZone
        (IANA). An empty string clears a field. Sending displayName, role,
        disabled, accountExpiresAt, or unlock is a 400. Audit records the
        field names, not the values.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactPatch"
      responses:
        "200":
          description: Updated own view
        "400":
          description: Empty patch, invalid value, or an admin-only field
  /api/v1/audit:
    get:
      summary: List recent audit events
      description: >
        Admin-only read of the append-only hash-chain table `audit_events`.
        Optional `type` matches `action` exactly. `from`/`to` are RFC3339
        bounds on `ts`. `limit` defaults to 100 and is capped at 500.
        Newest `seq` first. Does not write an audit row.
      parameters:
        - in: query
          name: type
          schema:
            type: string
          description: Exact audit action (for example auth.login.succeeded)
        - in: query
          name: from
          schema:
            type: string
            format: date-time
        - in: query
          name: to
          schema:
            type: string
            format: date-time
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Recent events
          content:
            application/json:
              schema:
                type: object
                required: [events]
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      required:
                        [seq, eventId, ts, action, resourceType, result, previousHash, eventHash]
                      properties:
                        seq:
                          type: integer
                        eventId:
                          type: string
                          format: uuid
                        ts:
                          type: string
                          format: date-time
                        actorId:
                          type: string
                          format: uuid
                          nullable: true
                        action:
                          type: string
                        resourceType:
                          type: string
                        resourceId:
                          type: string
                          format: uuid
                          nullable: true
                        result:
                          type: string
                          enum: [success, failure, denied]
                        correlationId:
                          type: string
                          nullable: true
                        detail:
                          type: object
                        previousHash:
                          type: string
                        eventHash:
                          type: string
        "400":
          description: Invalid type window or limit
        "401":
          description: Unauthenticated
        "403":
          description: Not an admin
  /api/v1/sessions:
    get:
      summary: List sessions
      description: >
        Admin sees all tenant sessions; other roles see only their own.
        Each row includes sessionId, principalId, principalUsername, createdAt,
        expiresAt, optional revokedAt, and current (true when the bearer token matches).
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema:
                type: object
                required: [sessions]
                properties:
                  sessions:
                    type: array
                    items:
                      type: object
                      required:
                        [sessionId, principalId, principalUsername, createdAt, expiresAt, current]
                      properties:
                        sessionId:
                          type: string
                          format: uuid
                        principalId:
                          type: string
                          format: uuid
                        principalUsername:
                          type: string
                        createdAt:
                          type: string
                          format: date-time
                        expiresAt:
                          type: string
                          format: date-time
                        revokedAt:
                          type: string
                          format: date-time
                          nullable: true
                        current:
                          type: boolean
        "401":
          description: Unauthenticated
  /api/v1/sessions/{sessionId}:
    delete:
      summary: Revoke a session
      description: >
        Sets revoked_at. Admin may revoke any session; other roles only their own.
        Writes session.revoked audit on first revoke. authenticate rejects revoked tokens.
      parameters:
        - name: sessionId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Session revoked (or already revoked)
          content:
            application/json:
              schema:
                type: object
                required: [sessionId, revokedAt]
                properties:
                  sessionId:
                    type: string
                    format: uuid
                  revokedAt:
                    type: string
                    format: date-time
        "401":
          description: Unauthenticated
        "403":
          description: Not allowed to revoke this session
        "404":
          description: Session not found
  /api/v1/sessions/revoke-others:
    post:
      summary: Revoke all other sessions for the current principal
      description: >
        Sets revoked_at on every active session belonging to the authenticated principal
        except the current bearer session. Personal API tokens are not affected.
        Writes session.revoked audit (with revokeOthers) for each newly revoked session.
        authenticate rejects revoked session tokens; the current session remains valid.
      responses:
        "200":
          description: Other sessions revoked (revokedCount may be zero)
          content:
            application/json:
              schema:
                type: object
                required: [revokedCount, keptSessionId, revokedAt]
                properties:
                  revokedCount:
                    type: integer
                    minimum: 0
                  keptSessionId:
                    type: string
                    format: uuid
                  revokedAt:
                    type: string
                    format: date-time
        "401":
          description: Unauthenticated
  /api/v1/tokens:
    get:
      summary: List personal API tokens
      description: >
        Admin sees all tenant API tokens; other roles see only their own.
        Secrets are never returned. Each row includes tokenId, name,
        principalUsername, createdAt, optional expiresAt/lastUsedAt/revokedAt,
        and optional roleMax (viewer|engineer) when the token was clamped.
      responses:
        "200":
          description: Token list
          content:
            application/json:
              schema:
                type: object
                required: [tokens]
                properties:
                  tokens:
                    type: array
                    items:
                      type: object
                      required:
                        [tokenId, name, principalUsername, createdAt]
                      properties:
                        tokenId:
                          type: string
                          format: uuid
                        name:
                          type: string
                        principalUsername:
                          type: string
                        createdAt:
                          type: string
                          format: date-time
                        expiresAt:
                          type: string
                          format: date-time
                          nullable: true
                        lastUsedAt:
                          type: string
                          format: date-time
                          nullable: true
                        revokedAt:
                          type: string
                          format: date-time
                          nullable: true
                        roleMax:
                          type: string
                          enum: [viewer, engineer]
                          nullable: true
                          description: >
                            When set, Bearer authenticate uses this role
                            (never above the principal) for authorize.
        "401":
          description: Unauthenticated
    post:
      summary: Create a personal API token
      description: >
        Creates a named token for the authenticated principal. The plaintext
        token (prefix vion_at_) is returned once; only a SHA-256 hash is stored.
        Optional roleMax or scopes (viewer|engineer) clamps the effective role
        used on authenticate; never elevates above the principal; admin is not
        a valid clamp. When expiresAt is omitted, VION_API_TOKEN_TTL_HOURS
        (else VION_API_TOKEN_TTL_DAYS) sets the default expiry; both unset
        means no expiry. An explicit expiresAt overrides the env TTL. Writes
        token.created audit. Bearer authenticate accepts the token like an
        opaque session token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 128
                expiresAt:
                  type: string
                  format: date-time
                  nullable: true
                  description: >
                    Optional future expiry. Omit to use VION_API_TOKEN_TTL_HOURS
                    or VION_API_TOKEN_TTL_DAYS; both unset keeps the token
                    from expiring.
                roleMax:
                  type: string
                  enum: [viewer, engineer]
                  description: Optional role clamp (alias of scopes)
                scopes:
                  type: string
                  enum: [viewer, engineer]
                  description: Optional role clamp alias for roleMax
      responses:
        "201":
          description: Token created (plaintext shown once)
          content:
            application/json:
              schema:
                type: object
                required: [tokenId, name, token, createdAt, principalUsername]
                properties:
                  tokenId:
                    type: string
                    format: uuid
                  name:
                    type: string
                  token:
                    type: string
                    description: Plaintext secret; shown only in this response
                  createdAt:
                    type: string
                    format: date-time
                  expiresAt:
                    type: string
                    format: date-time
                    nullable: true
                  lastUsedAt:
                    type: string
                    format: date-time
                    nullable: true
                  principalUsername:
                    type: string
                  roleMax:
                    type: string
                    enum: [viewer, engineer]
                    nullable: true
        "400":
          description: Invalid name, expiresAt, roleMax, or scopes
        "401":
          description: Unauthenticated
        "403":
          description: roleMax/scopes would elevate above the principal
  /api/v1/tokens/{tokenId}:
    delete:
      summary: Revoke a personal API token
      description: >
        Sets revoked_at. Admin may revoke any token; other roles only their own.
        Writes token.revoked audit on first revoke. authenticate rejects revoked tokens.
      parameters:
        - name: tokenId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Token revoked (or already revoked)
          content:
            application/json:
              schema:
                type: object
                required: [tokenId, revokedAt]
                properties:
                  tokenId:
                    type: string
                    format: uuid
                  revokedAt:
                    type: string
                    format: date-time
        "401":
          description: Unauthenticated
        "403":
          description: Not allowed to revoke this token
        "404":
          description: Token not found
  /api/v1/tokens/{tokenId}/rotate:
    post:
      summary: Rotate a personal API token secret
      description: >
        Replaces the stored hash with a new secret for an active (non-revoked,
        non-expired) token. tokenId, name, and expiresAt are unchanged; lastUsedAt
        is cleared. The new plaintext (prefix vion_at_) is returned once; the old
        secret stops authenticating. Admin may rotate any tenant token; other roles
        only their own. Writes token.rotated audit.
      parameters:
        - name: tokenId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Secret rotated (plaintext shown once)
          content:
            application/json:
              schema:
                type: object
                required: [tokenId, name, token, createdAt, principalUsername]
                properties:
                  tokenId:
                    type: string
                    format: uuid
                  name:
                    type: string
                  token:
                    type: string
                    description: New plaintext secret; shown only in this response
                  createdAt:
                    type: string
                    format: date-time
                  expiresAt:
                    type: string
                    format: date-time
                    nullable: true
                  lastUsedAt:
                    type: string
                    format: date-time
                    nullable: true
                  principalUsername:
                    type: string
                  roleMax:
                    type: string
                    enum: [viewer, engineer]
                    nullable: true
                    description: Unchanged on rotate
        "400":
          description: Token is revoked or expired
        "401":
          description: Unauthenticated
        "403":
          description: Not allowed to rotate this token
        "404":
          description: Token not found
  /api/v1/license:
    get:
      summary: License entitlement and grace status
      description: >
        Returns status valid|grace|expired from VION_LICENSE_EXPIRES_AT and
        optional VION_LICENSE_GRACE_DAYS (default 30). When unset, status is
        valid.         Optional VION_LICENSE_MAX_TAGS caps draft tags; create/publish
        over the limit returns 403 license_limit. Response includes maxTags
        (null when unset) and used (current upsert tag count). Optional
        VION_LICENSE_MAX_RUNTIMES (alias VION_LICENSE_MAX_NODES) caps
        activated deployment targets; activate over the limit returns 403
        license_limit. Response includes maxRuntimes (null when unset) and
        usedRuntimes (distinct activated targets). Optional
        VION_LICENSE_MAX_SCREENS caps draft screens the same way tags are
        capped; create and publish over the limit return 403 license_limit.
        Response includes maxScreens (null when unset) and usedScreens
        (current upsert screen count). Auth required.
        Grace and expired block publish, create project, and create principal
        with 403 license_grace or license_expired.
      responses:
        "200":
          description: License snapshot
          content:
            application/json:
              schema:
                type: object
                required: [status, expiresAt, graceEndsAt, productName, maxTags, used, maxRuntimes, usedRuntimes, maxScreens, usedScreens]
                properties:
                  status:
                    type: string
                    enum: [valid, grace, expired]
                  expiresAt:
                    type: string
                    format: date-time
                    nullable: true
                  graceEndsAt:
                    type: string
                    format: date-time
                    nullable: true
                  productName:
                    type: string
                  maxTags:
                    type: integer
                    minimum: 0
                    nullable: true
                  used:
                    type: integer
                    minimum: 0
                  maxRuntimes:
                    type: integer
                    minimum: 0
                    nullable: true
                  usedRuntimes:
                    type: integer
                    minimum: 0
                  maxScreens:
                    type: integer
                    minimum: 0
                    nullable: true
                  usedScreens:
                    type: integer
                    minimum: 0
        "401":
          description: Unauthenticated
  /api/v1/ha/status:
    get:
      summary: HA / witness node status
      description: >
        Returns the local node (VION_NODE_ID, VION_NODE_ROLE) and best-effort
        status for peers listed in VION_HA_PEERS (id=url or id:role=url).
        Peers are probed on /ready then /health with a short timeout; lagMs and
        lastSeen are best-effort. revisionHash is taken from a peer's
        /api/v1/status contentHash when available. witnessConfigured is true when
        the local role or any peer role is WITNESS. Auth required.
        Local role may reflect an in-memory override from POST /api/v1/ha/role.
        When VION_HA_LAG_WARN_MS is set, a node with lagMs greater than that
        threshold includes lagWarn true. Unset threshold omits lagWarn. This
        does not change role and does not perform automatic failover.
      responses:
        "200":
          description: HA cluster snapshot
          content:
            application/json:
              schema:
                type: object
                required: [nodes, witnessConfigured]
                properties:
                  nodes:
                    type: array
                    items:
                      type: object
                      required: [id, role, lastSeen]
                      properties:
                        id:
                          type: string
                        role:
                          type: string
                          enum: [ACTIVE, STANDBY, WITNESS]
                        revisionHash:
                          type: string
                        lagMs:
                          type: integer
                          format: int64
                        lagWarn:
                          type: boolean
                          description: True when lagMs exceeds VION_HA_LAG_WARN_MS
                        lastSeen:
                          type: string
                          format: date-time
                  witnessConfigured:
                    type: boolean
        "401":
          description: Unauthenticated
  /api/v1/ha/role:
    post:
      summary: Manually set local HA node role
      description: >
        Admin only. Sets the local node role (active|standby|witness) in memory for
        GET /api/v1/ha/status reporting. Does not mutate process environment
        VION_NODE_ROLE; a process restart restores the env role. Writes audit
        action ha.role_changed. Does not perform automatic failover, peer
        coordination, or remote role changes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [active, standby, witness]
      responses:
        "200":
          description: Local role overridden
          content:
            application/json:
              schema:
                type: object
                required: [nodeId, previousRole, role, witnessConfigured]
                properties:
                  nodeId:
                    type: string
                  previousRole:
                    type: string
                    enum: [ACTIVE, STANDBY, WITNESS]
                  role:
                    type: string
                    enum: [ACTIVE, STANDBY, WITNESS]
                  witnessConfigured:
                    type: boolean
                  note:
                    type: string
        "400":
          description: Invalid role
        "401":
          description: Unauthenticated
        "403":
          description: Non-admin role
  /api/v1/certificates:
    get:
      summary: Certificate inventory
      description: >
        Lists PEM certificates from optional VION_CERT_INVENTORY_DIR and, when set,
        VION_TLS_CERT. Admin or engineer. usage is inferred (tls-http from env role,
        or filename heuristics). expiringSoon is true when notAfter is within
        VION_CERT_EXPIRING_DAYS (default 30). The list includes expiringWithinDays.
      responses:
        "200":
          description: Certificate list
          content:
            application/json:
              schema:
                type: object
                required: [certificates, expiringWithinDays]
                properties:
                  expiringWithinDays:
                    type: integer
                    minimum: 0
                    description: Window used for expiringSoon (VION_CERT_EXPIRING_DAYS or 30)
                  certificates:
                    type: array
                    items:
                      $ref: "#/components/schemas/CertificateEntry"
        "401":
          description: Unauthenticated
        "403":
          description: Viewer or other non-admin/engineer role
    post:
      summary: Upload a PEM certificate into the inventory directory
      description: >
        Admin only. Writes `{name}.pem` under VION_CERT_INVENTORY_DIR after validating
        a parseable X.509 certificate in `pem`. Name is sanitized (ASCII letters, digits,
        `-`, `_`); path traversal is rejected. Returns 503 when the inventory directory
        is not configured.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, pem]
              properties:
                name:
                  type: string
                  description: Inventory basename without extension
                pem:
                  type: string
                  description: PEM-encoded X.509 certificate
      responses:
        "201":
          description: Certificate stored
          content:
            application/json:
              schema:
                type: object
                required: [certificate]
                properties:
                  certificate:
                    $ref: "#/components/schemas/CertificateEntry"
        "400":
          description: Invalid name or unparseable PEM
        "401":
          description: Unauthenticated
        "403":
          description: Non-admin role
        "503":
          description: VION_CERT_INVENTORY_DIR is not set
  /api/v1/certificates/{name}:
    delete:
      summary: Delete a PEM certificate from the inventory directory
      description: >
        Admin only. Removes `{name}.pem` under VION_CERT_INVENTORY_DIR.
        Name sanitization matches upload. Returns 503 when the inventory directory
        is not configured.
      parameters:
        - in: path
          name: name
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Deleted
        "400":
          description: Invalid name
        "401":
          description: Unauthenticated
        "403":
          description: Non-admin role
        "404":
          description: Certificate file not found
        "503":
          description: VION_CERT_INVENTORY_DIR is not set
  /api/v1/projects/{projectId}/backup:
    post:
      summary: Export a sealed project revision backup
      description: >
        Returns a vion-backup/1 archive for the latest published revision, or
        an optional revisionId. Viewer and above. Does not include credentials.
        When VION_BACKUP_PASSWORD is set, the backup object is a
        vion-backup-encrypted/1 AES-256-GCM envelope instead of plaintext JSON.
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Sealed backup archive
        "400":
          description: Project has no revision
  /api/v1/backups:
    get:
      summary: List backup files on disk
      description: >
        Engineer or admin. Lists file names in VION_BACKUP_DIR as
        { files: [{ name, bytes, modifiedAt }] }. Names only — no file body
        and no in-place restore. Unset directory returns { files: [] }.
        Viewer returns 403.
      responses:
        "200":
          description: Backup file names
          content:
            application/json:
              schema:
                type: object
                required: [files]
                properties:
                  files:
                    type: array
                    items:
                      type: object
                      required: [name, bytes, modifiedAt]
                      properties:
                        name:
                          type: string
                        bytes:
                          type: integer
                          format: int64
                          minimum: 0
                        modifiedAt:
                          type: string
                          format: date-time
        "401":
          description: Unauthenticated
        "403":
          description: Viewer or other non-engineer/admin role
  /api/v1/backups/preflight:
    post:
      summary: Dry-run restore of a backup archive
      description: >
        Validates format and archiveHash, reports name conflicts. Does not write.
        Engineer or admin. Body is the backup object or { "backup": ... }.
        Encrypted envelopes are decrypted with VION_BACKUP_PASSWORD.
      responses:
        "200":
          description: Preflight report
        "400":
          description: Invalid or tampered archive
  /api/v1/backups/restore:
    post:
      summary: Restore a backup archive
      description: >
        Verifies archiveHash. Engineer or admin; a viewer is forbidden.
        When inPlace is false or omitted, creates a new project and workspace,
        upserts archive resources, and publishes with message "restored from backup"
        when validation allows (otherwise leaves a draft). If the archive project
        name already exists, supply a unique projectName override or the request
        returns 409.
        When inPlace is true, projectId is required. The existing project row is
        kept and its default workspace resources are replaced from the validated
        archive inside one transaction. No second project is created. A missing
        projectId is 400. An unknown projectId is 404. In-place restore does not
        publish (published is false).
        Body: { "backup": ..., "projectName"?: "...", "inPlace"?: false, "projectId"?: "..." }.
        Encrypted envelopes decrypt with VION_BACKUP_PASSWORD before verify.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [backup]
              properties:
                backup:
                  type: object
                  description: Sealed vion-backup/1 archive
                projectName:
                  type: string
                  description: Unique name for the new project when inPlace is false and the archive name conflicts
                inPlace:
                  type: boolean
                  description: When true, replace the default workspace of projectId instead of creating a project
                projectId:
                  type: string
                  format: uuid
                  description: Required when inPlace is true
      responses:
        "200":
          description: Existing project's default workspace resources were replaced
        "201":
          description: New project created from backup
        "400":
          description: Invalid or tampered archive, or inPlace without projectId
        "403":
          description: Viewer or other role that cannot restore
        "404":
          description: inPlace projectId was not found
        "409":
          description: Target project name already exists (new-project restore)
  /api/v1/principals:
    get:
      summary: List principals in the tenant
      description: >
        Admin only. Admin view: the own-view fields plus lastLoginIp,
        failedLoginCount, and lockedUntil (present only while the lock holds).
      responses:
        "200":
          description: Principal list
        "403":
          description: Forbidden
    post:
      summary: Create a principal
      responses:
        "201":
          description: Created
        "403":
          description: Forbidden
  /api/v1/principals/{principalId}:
    get:
      summary: Get a principal
      description: Admin only.
      parameters:
        - in: path
          name: principalId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Principal
        "403":
          description: Forbidden
        "404":
          description: Not found
    patch:
      summary: Update profile, contact, expiry, status, or lock
      description: >
        Admin only. At least one field required. Cannot disable or demote the last
        enabled, unexpired admin, disable/demote yourself, or set an expiry on
        yourself. Disabling revokes active sessions and API tokens.
        accountExpiresAt must be in the future; null clears it. unlock true
        clears the failed-login counter and any lock.
      parameters:
        - in: path
          name: principalId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                displayName: { type: string }
                role: { type: string, enum: [admin, engineer, viewer, operator] }
                disabled: { type: boolean }
                email: { type: string }
                mobile: { type: string }
                locale: { type: string }
                timeZone: { type: string }
                accountExpiresAt: { type: string, format: date-time, nullable: true }
                unlock: { type: boolean }
      responses:
        "200":
          description: Updated principal
        "400":
          description: Invalid patch or last-admin / self guard
        "403":
          description: Forbidden
        "404":
          description: Not found
  /api/v1/principals/{principalId}/password:
    post:
      summary: Reset a principal password
      description: >
        Admin only. Body `{ "password" }` (12–128 characters). Sets
        passwordChangedAt and clears any login lock.
      parameters:
        - in: path
          name: principalId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Password updated
        "400":
          description: Invalid password
        "403":
          description: Forbidden
        "404":
          description: Not found
  /api/v1/principals/{principalId}/avatar:
    parameters:
      - in: path
        name: principalId
        required: true
        schema:
          type: string
          format: uuid
    get:
      summary: Fetch a principal's avatar
      description: Any signed-in principal in the same tenant.
      responses:
        "200":
          description: Image bytes
          content:
            image/png: {}
            image/jpeg: {}
            image/webp: {}
        "404":
          description: No avatar
    put:
      summary: Set an avatar
      description: >
        Self or admin. Raw body, at most 256 KB. The type is detected from the
        bytes; only PNG, JPEG and WebP are accepted.
      requestBody:
        required: true
        content:
          application/octet-stream: {}
      responses:
        "200":
          description: Updated principal (avatarUpdatedAt changes)
        "400":
          description: Empty, too large, or not PNG / JPEG / WebP
        "403":
          description: Not yourself and not an admin
        "404":
          description: Not found
    delete:
      summary: Remove an avatar
      description: Self or admin.
      responses:
        "200":
          description: Updated principal
        "403":
          description: Not yourself and not an admin
        "404":
          description: No avatar
  /api/v1/principals/{principalId}/folder-grants:
    get:
      summary: List folder ACL grants for a principal
      description: >
        Self or admin. Optional single-tenant folder ACL
        (principal_folder_grants). Folders with any grant row are
        restricted; admin bypasses.
      parameters:
        - in: path
          name: principalId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Grants list
        "403":
          description: Forbidden
    put:
      summary: Replace folder ACL grants for a principal
      description: >
        Admin only. Body `{ "grants": [ { "folderId", "access": "read"|"write" } ] }`.
        Empty list clears that principal's grants.
      parameters:
        - in: path
          name: principalId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Updated grants
        "403":
          description: Forbidden
  /api/v1/projects:
    get:
      summary: List projects
      responses:
        "200":
          description: Projects
    post:
      summary: Create a project and its default workspace
      responses:
        "201":
          description: Created
  /api/v1/projects/{projectId}:
    get:
      summary: Get a project
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Project
  /api/v1/projects/{projectId}/workspaces:
    post:
      summary: Create a workspace
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "201":
          description: Created
  /api/v1/workspaces/{workspaceId}:
    get:
      summary: Get a workspace and its materialized resources
      parameters:
        - in: path
          name: workspaceId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Workspace
  /api/v1/workspaces/{workspaceId}/export/tags.csv:
    get:
      summary: Export draft tags as CSV
      description: >
        Bulk engineering export. Columns:
        id,name,dataType,unit,folderId,source,historian.
        Source is a compact protocol summary. historian is true when
        TagBehavior.historian is onChange and false when none. Auth is workspace Read
        (Admin, Engineer, Viewer).
      parameters:
        - in: path
          name: workspaceId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: text/csv attachment (tags.csv)
          content:
            text/csv:
              schema:
                type: string
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Workspace not found
  /api/v1/workspaces/{workspaceId}/import/tags.csv:
    post:
      summary: Import draft tags from CSV
      description: >
        Bulk engineering import with the same columns as export:
        id,name,dataType,unit,folderId,source,historian.
        The historian column is optional; a legacy header without it is accepted.
        true/onChange sets historian onChange; false/none clears it. When the
        column is absent, update keeps the existing mode.
        Empty or missing id creates a tag; a known workspace tag id updates
        name/dataType/unit/folderId (description and other behavior/source are preserved).
        The source column is accepted and ignored (export summary is lossy).
        Unknown ids return 400. Tags absent from the CSV are not deleted.
        Updates use the locked draft's current resourceVersion as expectedVersion.
        Auth is workspace Write (Admin, Engineer).
      parameters:
        - in: path
          name: workspaceId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          text/csv:
            schema:
              type: string
          text/plain:
            schema:
              type: string
      responses:
        "200":
          description: Import summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  created:
                    type: integer
                  updated:
                    type: integer
                  errors:
                    type: array
                    items:
                      type: object
                      properties:
                        line:
                          type: integer
                        message:
                          type: string
        "400":
          description: Invalid CSV or unknown tag id (details.errors)
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Workspace not found
        "409":
          description: Workspace not editable or version conflict
  /api/v1/workspaces/{workspaceId}/resources/{resourceId}:
    put:
      summary: Create or update a draft resource
      parameters:
        - in: path
          name: workspaceId
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: resourceId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Updated resource
        "409":
          description: Optimistic concurrency conflict
    delete:
      summary: Delete a draft resource (copy-on-write delete delta)
      description: >
        Writes a workspace_resources row with change_kind=delete. Requires
        expectedVersion to match the materialized resource. Tags absent after
        delete are removed from the draft view; publish materializes the tombstone.
      parameters:
        - in: path
          name: workspaceId
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: resourceId
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: expectedVersion
          required: true
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        "204":
          description: Deleted
        "404":
          description: Resource or workspace not found
        "409":
          description: Workspace not editable or version conflict
  /api/v1/workspaces/{workspaceId}/validate:
    post:
      summary: Validate a workspace
      parameters:
        - in: path
          name: workspaceId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Validation report
  /api/v1/workspaces/{workspaceId}/publish:
    post:
      summary: Publish an immutable revision and compile a snapshot
      parameters:
        - in: path
          name: workspaceId
          required: true
          schema:
            type: string
            format: uuid
        - in: header
          name: Idempotency-Key
          schema:
            type: string
      responses:
        "201":
          description: Revision and artifact
        "422":
          description: Blocking validation errors
  /api/v1/projects/{projectId}/revisions:
    get:
      summary: List revisions
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Revisions
  /api/v1/projects/{projectId}/revisions/{revisionId}/rollback:
    post:
      summary: Publish a historical revision as a new head (append-only copy)
      description: >
        Creates a new revision whose resources match the chosen historical revision.
        Does not update or delete existing revision rows. Requires engineer or admin.
        Query dryRun=1 returns the summary that would be published (200) and does
        not write a revision, artifact, workspace update, or success audit.
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
        - in: path
          name: revisionId
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: dryRun
          required: false
          schema:
            type: string
            enum: ["1", "true", "yes"]
          description: When 1, preview only; nothing is persisted
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                message:
                  type: string
      responses:
        "200":
          description: Dry-run summary (dryRun true); no revision written
        "201":
          description: New revision and compiled artifact
        "404":
          description: Unknown revision or revision not in project
        "422":
          description: Blocking validation errors on copied content
  /api/v1/revisions/{revisionId}:
    get:
      summary: Get a revision
      parameters:
        - in: path
          name: revisionId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Revision
  /api/v1/revisions/{revisionId}/compile:
    post:
      summary: Compile another runtime snapshot from an immutable revision
      parameters:
        - in: path
          name: revisionId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "201":
          description: Artifact
  /api/v1/deployment-artifacts/{artifactId}:
    get:
      summary: Get a deployment artifact and snapshot
      parameters:
        - in: path
          name: artifactId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Artifact
  /api/v1/deployment-artifacts/{artifactId}/activate:
    post:
      summary: Stage and activate a snapshot on runtime-core
      description: >
        StageSnapshot then ActivateSnapshot on the runtime bound to the
        artifact's deployment target (ADR 0016). An unbound target of the
        default tenant uses VION_RUNTIME_GRPC_URL; an unbound target of any
        other tenant returns 409 runtime.unbound. Stage metadata carries the
        tenant id; a runtime bound to another tenant refuses it (400
        tenant.mismatch). Response includes targetId and runtimeId.
        When VION_LICENSE_MAX_RUNTIMES (or VION_LICENSE_MAX_NODES) is set,
        activating a new deployment target beyond the cap returns 403
        license_limit. Re-activating an already counted target checks used
        versus max.
      parameters:
        - in: path
          name: artifactId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Runtime status
        "400":
          description: Snapshot rejected, including tenant.mismatch
        "403":
          description: License runtime cap (license_limit)
        "409":
          description: Target has no runtime (runtime.unbound)
  /api/v1/deployment-artifacts/{artifactId}/activate/dry-run:
    get:
      summary: Validate snapshot, content hash, and runtime reachability without activating
      description: >
        Loads the stored snapshot, verifies contentHash, and calls runtime Health
        and GetStatus on the same runtime activate would use. A runtime bound to
        another tenant returns 400 tenant.mismatch; an unbound non-default target
        returns 409 runtime.unbound. Does not call StageSnapshot or
        ActivateSnapshot.
      parameters:
        - in: path
          name: artifactId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Dry-run result (activated is always false)
        "400":
          description: Snapshot corrupt, hash mismatch, or tenant.mismatch
        "403":
          description: License runtime cap (license_limit)
        "409":
          description: Target has no runtime (runtime.unbound)
        "503":
          description: Runtime unreachable or not configured
    post:
      summary: Same as GET dry-run
      parameters:
        - in: path
          name: artifactId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Dry-run result (activated is always false)
  /api/v1/stream:
    get:
      summary: Realtime tag snapshot socket
      description: >
        Sends a welcome frame, then tag snapshots relayed from runtime-core.
        With projectId, the runtime bound to the project's default target is
        used. Without it, the default tenant uses VION_RUNTIME_HTTP_URL and any
        other tenant uses its only registered runtime. The runtime is resolved
        before the upgrade.
      parameters:
        - in: query
          name: projectId
          schema:
            type: string
            format: uuid
      responses:
        "101":
          description: Switching protocols
        "409":
          description: No runtime for this tenant or project (runtime.unbound)
  /api/v1/runtimes:
    get:
      summary: List the caller tenant's runtimes (admin)
      description: >
        Each item has id, tenantId, name, grpcUrl, httpUrl, boundTargets,
        createdAt, updatedAt. Only the caller's tenant is listed.
      responses:
        "200":
          description: Runtime list
        "403":
          description: Not admin
    post:
      summary: Register a runtime for the caller tenant (admin)
      description: >
        name is unique within the tenant. grpcUrl must be loopback unless
        VION_RUNTIME_GRPC_ALLOW_REMOTE is set (400 runtime.remote_disallowed).
        httpUrl is the runtime's HTTP base used for the tag stream. Audit
        runtime.created.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, grpcUrl, httpUrl]
              properties:
                name:
                  type: string
                grpcUrl:
                  type: string
                httpUrl:
                  type: string
      responses:
        "201":
          description: Created runtime
        "400":
          description: Invalid name or URL (runtime.bad_url, runtime.remote_disallowed)
        "403":
          description: Not admin
        "409":
          description: Name already used in this tenant
  /api/v1/runtimes/{runtimeId}:
    parameters:
      - in: path
        name: runtimeId
        required: true
        schema:
          type: string
          format: uuid
    get:
      summary: Get a runtime (admin)
      responses:
        "200":
          description: Runtime
        "404":
          description: Unknown or another tenant's runtime
    patch:
      summary: Update name, grpcUrl, or httpUrl (admin)
      description: Same validation as create. Audit runtime.updated.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                grpcUrl:
                  type: string
                httpUrl:
                  type: string
      responses:
        "200":
          description: Updated runtime
        "404":
          description: Unknown or another tenant's runtime
    delete:
      summary: Delete a runtime (admin)
      description: Refused while any deployment target is bound. Audit runtime.deleted.
      responses:
        "204":
          description: Deleted
        "404":
          description: Unknown or another tenant's runtime
        "409":
          description: Bound targets remain (runtime.in_use)
  /api/v1/deployment-targets/{targetId}/runtime:
    put:
      summary: Bind or unbind a deployment target's runtime (admin)
      description: >
        runtimeId must belong to the target's tenant. null unbinds. Audit
        target.runtime_bound with runtimeId and previousRuntimeId.
      parameters:
        - in: path
          name: targetId
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [runtimeId]
              properties:
                runtimeId:
                  type: string
                  format: uuid
                  nullable: true
      responses:
        "200":
          description: "{ targetId, projectId, runtimeId }"
        "403":
          description: Not admin
        "404":
          description: Unknown target or runtime, or another tenant's
  /api/v1/projects/{projectId}/targets:
    get:
      summary: List a project's deployment targets
      description: Items have id, name, environment, runtimeId, isDefault, createdAt.
      parameters:
        - in: path
          name: projectId
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Target list
        "404":
          description: Unknown project
  /api/v1/runtime/endpoint:
    get:
      summary: Runtime HTTP address for a project or the caller's tenant
      description: >
        With projectId, resolves the project's default target the same way
        activate does. Without it, the default tenant gets the server's
        VION_RUNTIME_HTTP_URL (runtimeId null) and any other tenant its only
        registered runtime. Returns { projectId, targetId, tenantId, runtimeId,
        httpUrl }; projectId and targetId are null without a project. The gRPC
        address is not returned.
      parameters:
        - in: query
          name: projectId
          required: false
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Endpoint
        "404":
          description: Unknown project
        "409":
          description: No runtime bound, or several without projectId (runtime.unbound)
  /api/v1/tags/{id}/history:
    get:
      summary: Tag history samples (runtime-core)
      description: >
        Served by runtime-core (not control-plane). Returns samples in [from, to)
        ascending by sourceTs. Without aggregate, the newest `limit` raw samples.
        With downsample=avg or aggregate=avg, bucketed numeric averages over the
        range (bucketMs optional; default ceil((to-from)/limit) ms, floored by
        that auto width so at most ~limit buckets). Empty samples when no
        historian provider and spool is empty. No command token.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: from
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound (RFC3339). Default Unix epoch.
        - in: query
          name: to
          schema:
            type: string
            format: date-time
          description: Exclusive upper bound (RFC3339). Default a few seconds ahead of now.
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 50
        - in: query
          name: downsample
          schema:
            type: string
            enum: [avg]
          description: Alias of aggregate; bucketed average when set to avg.
        - in: query
          name: aggregate
          schema:
            type: string
            enum: [avg]
          description: Bucketed average when set to avg.
        - in: query
          name: bucketMs
          schema:
            type: integer
            minimum: 1
          description: Bucket width in milliseconds (optional with avg).
      responses:
        "200":
          description: "{ samples: [...] }"
        "400":
          description: history.bad_range or history.bad_aggregate
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
  schemas:
    CertificateEntry:
      type: object
      required: [id, name, path, notBefore, notAfter, subject, expiringSoon]
      properties:
        id:
          type: string
        name:
          type: string
        path:
          type: string
        notBefore:
          type: string
          format: date-time
        notAfter:
          type: string
          format: date-time
        subject:
          type: string
        usage:
          type: string
          description: Inferred role such as tls-http, opcua, inventory
        expiringSoon:
          type: boolean
    ContactPatch:
      type: object
      description: Empty string clears a field.
      properties:
        email:
          type: string
        mobile:
          type: string
          description: E.164, e.g. +905321112233
        locale:
          type: string
          description: BCP 47, e.g. tr or en-GB
        timeZone:
          type: string
          description: IANA zone, e.g. Europe/Istanbul
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            correlationId:
              type: string
            details:
              type: object
security:
  - bearerAuth: []
