openapi: 3.1.0
info:
  title: AgentPulse Control-Plane API
  version: 1.0.0
  description: >-
    Tenant-safe control-plane API for the local-first AgentPulse agent and
    read-only customer fleet dashboard.
  contact:
    name: AgentPulse Support
    email: support@agentpulse.ca
  license:
    name: Apache-2.0
servers:
  - url: https://api.agentpulse.ca
    description: Reserved production contract target; not deployed
  - url: https://staging-api.agentpulse.ca
    description: Staging health target; Phase 3A operations remain contract-only and undeployed
  - url: http://localhost:8787
    description: Local Worker

tags:
  - name: health
  - name: enrollment
  - name: agents
  - name: fleet
  - name: billing
  - name: onboarding
  - name: session
  - name: account

paths:
  /health:
    get:
      tags: [health]
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Worker is alive
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'

  /v1/enrollment-tokens:
    post:
      tags: [enrollment]
      operationId: createEnrollmentToken
      security:
        - AccountBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EnrollmentTokenRequest'
      responses:
        '201':
          description: Short-lived token created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnrollmentTokenResponse'
        '401': { description: Invalid account credential }
        '402': { description: Subscription inactive }
        '422': { description: Invalid token TTL }

  /v1/agents/enroll:
    post:
      tags: [enrollment, agents]
      operationId: enrollAgent
      security:
        - EnrollmentBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentEnrollmentRequest'
      responses:
        '201':
          description: Agent credential issued once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentEnrollmentResponse'
        '401': { description: Invalid or expired enrollment token }
        '402': { description: Subscription inactive }
        '409': { description: 'Token consumed, plan limit reached, or duplicate agent' }

  /v1/agents/heartbeat:
    post:
      tags: [agents]
      operationId: submitHeartbeat
      security:
        - AgentBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HeartbeatRequest'
      responses:
        '202':
          description: New heartbeat accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HeartbeatResponse'
        '200':
          description: Duplicate heartbeat acknowledged idempotently
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HeartbeatResponse'
        '401': { description: Invalid agent credential }
        '402': { description: Subscription inactive }
        '413': { description: Body too large }
        '422': { description: Invalid heartbeat }

  /v1/agents/policy:
    get:
      tags: [agents]
      operationId: getAgentPolicy
      security:
        - AgentBearer: []
      responses:
        '200':
          description: Policy narrowed to the agent local ceiling
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PolicyResponse'
        '401': { description: Invalid agent credential }
        '402': { description: Subscription inactive }

  /v1/fleet:
    get:
      tags: [fleet]
      operationId: getFleet
      security:
        - AccountBearer: []
      responses:
        '200':
          description: Authenticated tenant fleet and recent incidents
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FleetResponse'
        '401': { description: Invalid account credential }
        '402': { description: Subscription inactive }

  /v1/stripe/webhook:
    post:
      tags: [billing]
      operationId: receiveStripeWebhook
      security: []
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema: { type: string, minLength: 1 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200': { description: Event accepted or already processed }
        '400': { description: Invalid Stripe signature or payload }

  /v1/billing/checkout:
    post:
      tags: [billing]
      operationId: createBillingCheckout
      x-implementation-status: contract-only
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckoutRequest'
      responses:
        '201':
          description: >-
            Stripe Checkout Session created for an allowlisted Price ID. The
            success/cancel URLs and claim nonce are server-derived and never
            accepted from the client.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutResponse'
        '422': { description: Invalid or unrecognized plan }
        '503': { description: Plan is not mapped to a Stripe Price ID }

  /v1/onboarding/claim:
    post:
      tags: [onboarding]
      operationId: claimOnboarding
      x-implementation-status: contract-only
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OnboardingClaimRequest'
      responses:
        '200':
          description: >-
            Checkout Session verified server-side against canonical Stripe
            state; exactly one successful claim is allowed per checkout
            session. Issues an HttpOnly, Secure, SameSite session cookie —
            the browser never receives the raw account credential.
          headers:
            Set-Cookie:
              description: >-
                HttpOnly; Secure (outside local development); SameSite=Lax
                or stricter; narrow path/domain; short expiry.
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingClaimResponse'
        '401': { description: Claim nonce is invalid or unknown }
        '409': { description: 'Checkout not yet completed, already claimed, or expired' }

  /v1/session:
    delete:
      tags: [session]
      operationId: deleteSession
      x-implementation-status: contract-only
      security:
        - BrowserSession: []
      parameters:
        - $ref: '#/components/parameters/CsrfToken'
      responses:
        '204': { description: Session revoked }
        '401': { description: 'Missing, invalid, or expired session' }
        '403': { description: CSRF token missing or invalid }

  /v1/account:
    get:
      tags: [account]
      operationId: getAccount
      x-implementation-status: contract-only
      security:
        - BrowserSession: []
      responses:
        '200':
          description: Authenticated tenant's account and normalized entitlement state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountSummary'
        '401': { description: 'Missing, invalid, or expired session' }

  /v1/billing/portal:
    post:
      tags: [billing]
      operationId: createBillingPortalSession
      x-implementation-status: contract-only
      security:
        - BrowserSession: []
      parameters:
        - $ref: '#/components/parameters/CsrfToken'
      responses:
        '200':
          description: Stripe Customer Portal session for the authenticated tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingPortalResponse'
        '401': { description: 'Missing, invalid, or expired session' }
        '403': { description: CSRF token missing or invalid }
        '503': { description: Stripe portal session could not be created }

components:
  securitySchemes:
    AccountBearer:
      type: http
      scheme: bearer
      description: Hashed account credential; browser sessions are a later release blocker.
    AgentBearer:
      type: http
      scheme: bearer
      description: Hashed per-agent credential issued once during enrollment.
    EnrollmentBearer:
      type: http
      scheme: bearer
      description: Short-lived one-time enrollment token.
    BrowserSession:
      type: apiKey
      in: cookie
      name: ap_session
      description: >-
        HttpOnly, Secure (outside local development), SameSite=Lax or
        stricter session cookie issued once by a verified claim. Holds only
        an opaque token whose SHA-256 hash is persisted server-side; the raw
        value is never readable by JavaScript or stored in browser storage.

  parameters:
    CsrfToken:
      name: X-CSRF-Token
      in: header
      required: true
      description: >-
        Server-backed synchronizer CSRF token returned in the claim response
        body; only its hash is persisted, and the browser cookie carries only
        the opaque session token.
      schema: { type: string, minLength: 16 }

  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }

    HealthResponse:
      type: object
      required: [ok, service, version, environment]
      properties:
        ok: { type: boolean, const: true }
        service: { type: string, const: agentpulse-control-plane }
        version: { type: string }
        environment: { type: string }

    EnrollmentTokenRequest:
      type: object
      required: [ttl_seconds]
      additionalProperties: false
      properties:
        ttl_seconds: { type: integer, minimum: 60, maximum: 900 }

    EnrollmentTokenResponse:
      type: object
      required: [enrollment_token, expires_at]
      properties:
        enrollment_token: { type: string, minLength: 16 }
        expires_at: { type: integer }

    AgentEnrollmentRequest:
      type: object
      required: [agent_key, hostname, local_policy_ceiling]
      additionalProperties: false
      properties:
        agent_key: { type: string, minLength: 1, maxLength: 128, pattern: '^\S(?:.*\S)?$' }
        hostname: { type: string, minLength: 1, maxLength: 255, pattern: '^\S(?:.*\S)?$' }
        local_policy_ceiling:
          type: string
          enum: [off, alert, ask, auto]

    AgentEnrollmentResponse:
      type: object
      required: [agent_id, agent_credential, agent_key]
      properties:
        agent_id: { type: string, format: uuid }
        agent_credential: { type: string, minLength: 16 }
        agent_key: { type: string }

    HeartbeatIncident:
      type: object
      required: [kind, status, detail]
      additionalProperties: false
      properties:
        fingerprint: { type: string, maxLength: 255 }
        kind: { type: string, maxLength: 128 }
        status: { type: string, enum: [open, resolved, escalated, succeeded, pending] }
        severity: { type: string, enum: [info, warning, critical] }
        detail: { type: string, maxLength: 2048 }

    HeartbeatSummary:
      type: object
      additionalProperties: true
      properties:
        observations: { type: integer, minimum: 0 }
        breaches: { type: integer, minimum: 0 }
        actions: { type: integer, minimum: 0 }
        queued: { type: integer, minimum: 0 }
        alerts: { type: integer, minimum: 0 }
        anomalies: { type: integer, minimum: 0 }
        escalations: { type: integer, minimum: 0 }
        blocked: { type: integer, minimum: 0 }
        errors:
          type: array
          items: { type: string }

    HeartbeatRequest:
      type: object
      required: [idempotency_key, observed_at, summary, incidents]
      additionalProperties: false
      properties:
        idempotency_key: { type: string, minLength: 1, maxLength: 128, pattern: '^\S(?:.*\S)?$' }
        observed_at: { type: number }
        summary: { $ref: '#/components/schemas/HeartbeatSummary' }
        incidents:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/HeartbeatIncident' }

    HeartbeatResponse:
      type: object
      required: [ok, duplicate]
      properties:
        ok: { type: boolean, const: true }
        duplicate: { type: boolean }

    FleetIncident:
      type: object
      required: [id, fingerprint, kind, status, severity, detail, opened_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        fingerprint: { type: string }
        kind: { type: string }
        status: { type: string, enum: [open, resolved, escalated] }
        severity: { type: string, enum: [info, warning, critical] }
        detail: { type: string }
        opened_at: { type: integer }
        updated_at: { type: integer }

    FleetAgent:
      type: object
      required: [agent_key, hostname, enrolled_at, local_policy_ceiling, incidents]
      properties:
        agent_key: { type: string }
        hostname: { type: string }
        enrolled_at: { type: integer }
        last_seen_at: { type: [integer, 'null'] }
        local_policy_ceiling: { type: string, enum: [off, alert, ask, auto] }
        incidents:
          type: array
          items: { $ref: '#/components/schemas/FleetIncident' }

    FleetResponse:
      type: object
      required: [agents]
      properties:
        agents:
          type: array
          items: { $ref: '#/components/schemas/FleetAgent' }

    PolicyResponse:
      type: object
      required: [version, policy]
      properties:
        version: { type: integer, minimum: 0 }
        policy: { type: object, additionalProperties: true }

    Plan:
      type: string
      enum: [starter, pro, business]

    EntitlementStatus:
      type: string
      description: >-
        Normalized entitlement derived server-side from Stripe subscription
        status plus the confirmed grace-period policy; never the raw Stripe
        status string.
      enum: [active, grace, blocked]

    CheckoutRequest:
      type: object
      required: [plan]
      additionalProperties: false
      properties:
        plan: { $ref: '#/components/schemas/Plan' }

    CheckoutResponse:
      type: object
      required: [checkout_url, expires_at]
      properties:
        checkout_url: { type: string, format: uri }
        expires_at: { type: integer }

    OnboardingClaimRequest:
      type: object
      required: [claim_nonce]
      additionalProperties: false
      properties:
        claim_nonce: { type: string, minLength: 16, maxLength: 255 }

    AccountSummary:
      type: object
      required: [tenant_id, email, plan, entitlement_status, agent_limit]
      properties:
        tenant_id: { type: string, format: uuid }
        email: { type: string, format: email }
        plan: { $ref: '#/components/schemas/Plan' }
        entitlement_status: { $ref: '#/components/schemas/EntitlementStatus' }
        agent_limit: { type: integer, minimum: 1 }
        current_period_end: { type: [integer, 'null'] }
        grace_period_ends_at: { type: [integer, 'null'] }

    OnboardingClaimResponse:
      type: object
      required: [csrf_token, account]
      properties:
        csrf_token: { type: string, minLength: 16 }
        account: { $ref: '#/components/schemas/AccountSummary' }

    BillingPortalResponse:
      type: object
      required: [portal_url]
      properties:
        portal_url: { type: string, format: uri }
