openapi: 3.0.3

info:
  title: SentryAgent.ai — Credential Management Service
  version: 1.0.0
  description: |
    The Credential Management Service provides secure generation, listing,
    rotation, and revocation of OAuth 2.0 client credentials for registered
    AI agents.

    Each agent can hold multiple credentials simultaneously to support
    zero-downtime rotation. A credential consists of a `client_id` (= `agentId`)
    and a `client_secret`.

    **Security model**:
    - `client_secret` is returned **once only** — at creation or rotation time.
    - Secrets are stored as a bcrypt hash; plain-text is never persisted.
    - An agent may only manage its own credentials unless the caller holds an
      admin-scoped token.
    - Rotating a credential immediately revokes the previous `client_secret`.

    **Auth**: Bearer token (JWT) required on all endpoints.

servers:
  - url: http://localhost:3000/api/v1
    description: Local development server
  - url: https://api.sentryagent.ai/v1
    description: Production server

tags:
  - name: Credential Management
    description: Generate, list, rotate, and revoke agent credentials

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT access token obtained via `POST /token`.
        Include as: `Authorization: Bearer <token>`

  schemas:
    CredentialStatus:
      type: string
      description: |
        Lifecycle status of a credential.
        - `active`: Credential is valid and can be used to obtain tokens.
        - `revoked`: Credential has been explicitly revoked and is permanently invalid.
      enum:
        - active
        - revoked
      example: active

    Credential:
      type: object
      description: |
        A credential record for an AI agent. The `clientSecret` is **never**
        returned in this schema — it is only returned once in `CredentialWithSecret`
        at the moment of creation or rotation.
      required:
        - credentialId
        - clientId
        - status
        - createdAt
      properties:
        credentialId:
          type: string
          format: uuid
          description: Immutable, system-assigned unique identifier for this credential.
          readOnly: true
          example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
        clientId:
          type: string
          format: uuid
          description: >
            The `agentId` this credential belongs to. Equal to the `agentId`
            path parameter.
          readOnly: true
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
        status:
          $ref: '#/components/schemas/CredentialStatus'
        createdAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when the credential was created.
          readOnly: true
          example: "2026-03-28T09:00:00.000Z"
        expiresAt:
          type: string
          format: date-time
          nullable: true
          description: |
            ISO 8601 timestamp when the credential expires.
            `null` indicates the credential does not expire (valid until revoked).
          example: "2027-03-28T09:00:00.000Z"
        revokedAt:
          type: string
          format: date-time
          nullable: true
          description: >
            ISO 8601 timestamp when the credential was revoked.
            `null` if the credential has not been revoked.
          readOnly: true
          example: null

    CredentialWithSecret:
      allOf:
        - $ref: '#/components/schemas/Credential'
        - type: object
          description: |
            Extended credential record returned **only** at creation or rotation time.
            The `clientSecret` is shown once and never retrievable again.
            Store it securely immediately.
          required:
            - clientSecret
          properties:
            clientSecret:
              type: string
              description: |
                The plain-text client secret. **Shown once only** — store securely immediately.
                This value is not persisted in plain text on the server.
              format: password
              example: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"

    GenerateCredentialRequest:
      type: object
      description: |
        Optional request body for generating new credentials.
        If `expiresAt` is omitted, the credential does not expire.
      properties:
        expiresAt:
          type: string
          format: date-time
          description: |
            Optional ISO 8601 expiry timestamp for the credential.
            Must be a future date. If omitted, the credential has no expiry.
          example: "2027-03-28T09:00:00.000Z"

    PaginatedCredentialsResponse:
      type: object
      description: Paginated list of credentials for an agent.
      required:
        - data
        - total
        - page
        - limit
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Credential'
        total:
          type: integer
          description: Total number of credentials for this agent.
          example: 3
        page:
          type: integer
          description: Current page number (1-based).
          example: 1
        limit:
          type: integer
          description: Number of items per page.
          example: 20

    ErrorResponse:
      type: object
      description: Standard error response envelope.
      required:
        - code
        - message
      properties:
        code:
          type: string
          description: Machine-readable error code.
          example: "CREDENTIAL_NOT_FOUND"
        message:
          type: string
          description: Human-readable description of the error.
          example: "Credential with the specified ID was not found."
        details:
          type: object
          description: Optional structured details about the error.
          additionalProperties: true
          example: {}

  responses:
    Unauthorized:
      description: Missing or invalid Bearer token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            code: "UNAUTHORIZED"
            message: "A valid Bearer token is required to access this resource."

    Forbidden:
      description: Valid token but insufficient permissions.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            code: "FORBIDDEN"
            message: "You do not have permission to manage credentials for this agent."

    AgentNotFound:
      description: The specified agent does not exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            code: "AGENT_NOT_FOUND"
            message: "Agent with the specified ID was not found."

    CredentialNotFound:
      description: The specified credential does not exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            code: "CREDENTIAL_NOT_FOUND"
            message: "Credential with the specified ID was not found."

    TooManyRequests:
      description: Rate limit exceeded.
      headers:
        X-RateLimit-Limit:
          schema:
            type: integer
          example: 100
        X-RateLimit-Remaining:
          schema:
            type: integer
          example: 0
        X-RateLimit-Reset:
          schema:
            type: integer
          example: 1743155400
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            code: "RATE_LIMIT_EXCEEDED"
            message: "Too many requests. Please retry after the rate limit window resets."

    InternalServerError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            code: "INTERNAL_SERVER_ERROR"
            message: "An unexpected error occurred. Please try again later."

security:
  - BearerAuth: []

paths:
  /agents/{agentId}/credentials:
    parameters:
      - name: agentId
        in: path
        description: The unique UUID identifier of the agent.
        required: true
        schema:
          type: string
          format: uuid
        example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

    post:
      operationId: generateCredential
      tags:
        - Credential Management
      summary: Generate new credentials for an agent
      description: |
        Generates a new `client_id` + `client_secret` credential pair for the
        specified agent.

        **Important**: The `clientSecret` is returned **once only** in this response.
        It is not stored in plain text on the server and cannot be retrieved later.
        Store it securely immediately (e.g. in a secrets manager).

        An agent may hold multiple active credentials simultaneously. This supports
        zero-downtime rotation: generate a new credential, update all consumers,
        then revoke the old one.

        **Restrictions**:
        - The agent must be in `active` status.
        - An agent may manage its own credentials via a self-issued token.
        - Managing another agent's credentials requires an admin-scoped token.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateCredentialRequest'
            example:
              expiresAt: "2027-03-28T09:00:00.000Z"
      responses:
        '201':
          description: |
            Credential generated successfully.
            **Save the `clientSecret` immediately — it will not be shown again.**
          headers:
            X-RateLimit-Limit:
              schema:
                type: integer
              example: 100
            X-RateLimit-Remaining:
              schema:
                type: integer
              example: 99
            X-RateLimit-Reset:
              schema:
                type: integer
              example: 1743155400
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CredentialWithSecret'
              example:
                credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
                clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                clientSecret: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
                status: "active"
                createdAt: "2026-03-28T09:00:00.000Z"
                expiresAt: "2027-03-28T09:00:00.000Z"
                revokedAt: null
        '400':
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                code: "VALIDATION_ERROR"
                message: "Request validation failed."
                details:
                  field: "expiresAt"
                  reason: "expiresAt must be a future date-time."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Insufficient permissions or agent is not active.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                forbidden:
                  summary: Insufficient permissions
                  value:
                    code: "FORBIDDEN"
                    message: "You do not have permission to manage credentials for this agent."
                agentNotActive:
                  summary: Agent not in active status
                  value:
                    code: "AGENT_NOT_ACTIVE"
                    message: "Credentials can only be generated for active agents."
                    details:
                      agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                      status: "suspended"
        '404':
          $ref: '#/components/responses/AgentNotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'

    get:
      operationId: listCredentials
      tags:
        - Credential Management
      summary: List credentials for an agent
      description: |
        Returns a paginated list of all credentials (active and revoked) for the
        specified agent. The `clientSecret` is **never** returned in list responses.

        Results are ordered by `createdAt` descending (most recent first).
      parameters:
        - name: page
          in: query
          description: Page number (1-based). Defaults to `1`.
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
        - name: limit
          in: query
          description: Number of results per page. Defaults to `20`, maximum `100`.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          example: 20
        - name: status
          in: query
          description: Filter credentials by status.
          required: false
          schema:
            $ref: '#/components/schemas/CredentialStatus'
      responses:
        '200':
          description: Credential list returned successfully.
          headers:
            X-RateLimit-Limit:
              schema:
                type: integer
              example: 100
            X-RateLimit-Remaining:
              schema:
                type: integer
              example: 98
            X-RateLimit-Reset:
              schema:
                type: integer
              example: 1743155400
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedCredentialsResponse'
              example:
                data:
                  - credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
                    clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                    status: "active"
                    createdAt: "2026-03-28T09:00:00.000Z"
                    expiresAt: "2027-03-28T09:00:00.000Z"
                    revokedAt: null
                  - credentialId: "d8e7f6a5-b4c3-2109-edcb-a98765432109"
                    clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                    status: "revoked"
                    createdAt: "2026-01-15T08:00:00.000Z"
                    expiresAt: null
                    revokedAt: "2026-03-28T08:59:00.000Z"
                total: 2
                page: 1
                limit: 20
        '400':
          description: Invalid query parameters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                code: "VALIDATION_ERROR"
                message: "Invalid query parameter value."
                details:
                  field: "status"
                  reason: "Must be 'active' or 'revoked'."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/AgentNotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /agents/{agentId}/credentials/{credentialId}/rotate:
    parameters:
      - name: agentId
        in: path
        description: The unique UUID identifier of the agent.
        required: true
        schema:
          type: string
          format: uuid
        example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
      - name: credentialId
        in: path
        description: The unique UUID identifier of the credential to rotate.
        required: true
        schema:
          type: string
          format: uuid
        example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"

    post:
      operationId: rotateCredential
      tags:
        - Credential Management
      summary: Rotate a credential
      description: |
        Rotates an existing credential by:
        1. Immediately revoking the current `client_secret`.
        2. Generating and returning a new `client_secret` for the same `credentialId`.

        The `credentialId` remains the same after rotation; only the secret changes.
        The new `clientSecret` is returned **once only** and must be stored securely.

        **Use case**: Periodic secret rotation or emergency rotation after
        credential compromise.

        Only `active` credentials can be rotated. Attempting to rotate a `revoked`
        credential returns `409 Conflict`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateCredentialRequest'
            example:
              expiresAt: "2028-03-28T09:00:00.000Z"
      responses:
        '200':
          description: |
            Credential rotated successfully.
            **Save the new `clientSecret` immediately — it will not be shown again.**
            The previous secret is permanently invalidated.
          headers:
            X-RateLimit-Limit:
              schema:
                type: integer
              example: 100
            X-RateLimit-Remaining:
              schema:
                type: integer
              example: 97
            X-RateLimit-Reset:
              schema:
                type: integer
              example: 1743155400
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CredentialWithSecret'
              example:
                credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
                clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                clientSecret: "sk_live_9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d"
                status: "active"
                createdAt: "2026-03-28T09:00:00.000Z"
                expiresAt: "2028-03-28T09:00:00.000Z"
                revokedAt: null
        '400':
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                code: "VALIDATION_ERROR"
                message: "Request validation failed."
                details:
                  field: "expiresAt"
                  reason: "expiresAt must be a future date-time."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Agent or credential not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                agentNotFound:
                  summary: Agent not found
                  value:
                    code: "AGENT_NOT_FOUND"
                    message: "Agent with the specified ID was not found."
                credentialNotFound:
                  summary: Credential not found
                  value:
                    code: "CREDENTIAL_NOT_FOUND"
                    message: "Credential with the specified ID was not found."
        '409':
          description: Credential is already revoked and cannot be rotated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                code: "CREDENTIAL_ALREADY_REVOKED"
                message: "Revoked credentials cannot be rotated. Generate a new credential instead."
                details:
                  credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
                  revokedAt: "2026-03-20T10:00:00.000Z"
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /agents/{agentId}/credentials/{credentialId}:
    parameters:
      - name: agentId
        in: path
        description: The unique UUID identifier of the agent.
        required: true
        schema:
          type: string
          format: uuid
        example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
      - name: credentialId
        in: path
        description: The unique UUID identifier of the credential to revoke.
        required: true
        schema:
          type: string
          format: uuid
        example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"

    delete:
      operationId: revokeCredential
      tags:
        - Credential Management
      summary: Revoke a credential
      description: |
        Permanently revokes a credential, immediately preventing it from being
        used to obtain new tokens.

        **Effects of revocation**:
        - The credential's status is set to `revoked`.
        - Any tokens issued using this credential remain valid until they expire
          naturally (token revocation is handled separately via `POST /token/revoke`).
        - The credential record is retained for audit purposes.
        - This operation is **irreversible** — a revoked credential cannot be re-activated.

        Revoking an already-revoked credential returns `409 Conflict`.
      responses:
        '204':
          description: Credential revoked successfully. No response body.
          headers:
            X-RateLimit-Limit:
              schema:
                type: integer
              example: 100
            X-RateLimit-Remaining:
              schema:
                type: integer
              example: 96
            X-RateLimit-Reset:
              schema:
                type: integer
              example: 1743155400
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Agent or credential not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                agentNotFound:
                  summary: Agent not found
                  value:
                    code: "AGENT_NOT_FOUND"
                    message: "Agent with the specified ID was not found."
                credentialNotFound:
                  summary: Credential not found
                  value:
                    code: "CREDENTIAL_NOT_FOUND"
                    message: "Credential with the specified ID was not found."
        '409':
          description: Credential is already revoked.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                code: "CREDENTIAL_ALREADY_REVOKED"
                message: "This credential has already been revoked."
                details:
                  credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
                  revokedAt: "2026-03-20T10:00:00.000Z"
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
