openapi: 3.1.0
info:
  title: EasyAuths Request Authentication API
  version: 0.2.0
  description: Prototype API for context-bound MFA and action approval. Production identity, device proof, and tenant credential work is tracked in the repository roadmap.
servers:
  - url: http://localhost:3000/api/v1
security:
  - bearerAuth: []
paths:
  /transactions:
    get:
      operationId: listTransactions
      summary: List transactions for the credential application
      x-required-scope: transactions:read
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: cursor
          schema: { type: string, maxLength: 512 }
        - in: query
          name: state
          schema: { $ref: '#/components/schemas/TransactionState' }
        - in: query
          name: actionType
          schema: { $ref: '#/components/schemas/ActionType' }
      responses:
        '200': { $ref: '#/components/responses/TransactionPage' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/Error' }
        '500': { $ref: '#/components/responses/Error' }
    post:
      operationId: createTransaction
      summary: Create an authenticated request
      x-required-scope: transactions:create
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - in: header
          name: Idempotency-Key
          required: true
          schema: { type: string, minLength: 8, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateTransaction' }
      responses:
        '200': { $ref: '#/components/responses/TransactionReplayed' }
        '201': { $ref: '#/components/responses/TransactionCreated' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '415': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /transactions/{transactionId}:
    get:
      operationId: getTransaction
      summary: Retrieve authoritative transaction status
      x-required-scope: transactions:read
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - { $ref: '#/components/parameters/TransactionId' }
      responses:
        '200': { $ref: '#/components/responses/Transaction' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/Error' }
        '500': { $ref: '#/components/responses/Error' }
  /transactions/{transactionId}/approve:
    post:
      operationId: approveTransactionDemo
      summary: Record an approval in the local prototype
      description: Available only to the explicitly seeded demo credential. Production applications cannot provision the decision scope.
      x-required-scope: transactions:decide
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - { $ref: '#/components/parameters/TransactionId' }
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/Decision' } } }
      responses:
        '200': { $ref: '#/components/responses/Transaction' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '415': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /transactions/{transactionId}/deny:
    post:
      operationId: denyTransactionDemo
      summary: Deny a request
      description: Available only to the explicitly seeded demo credential. Production applications cannot provision the decision scope.
      x-required-scope: transactions:decide
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - { $ref: '#/components/parameters/TransactionId' }
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/Decision' } } }
      responses:
        '200': { $ref: '#/components/responses/Transaction' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '415': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /transactions/{transactionId}/consume:
    post:
      operationId: consumeTransaction
      summary: Atomically consume an approved transaction
      x-required-scope: transactions:consume
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - { $ref: '#/components/parameters/TransactionId' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsumeTransaction' }
      responses:
        '200': { $ref: '#/components/responses/Transaction' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '413': { $ref: '#/components/responses/Error' }
        '415': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '500': { $ref: '#/components/responses/Error' }
  /transactions/{transactionId}/cancel:
    post:
      operationId: cancelTransaction
      summary: Cancel a pending transaction for the credential application
      x-required-scope: transactions:cancel
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - { $ref: '#/components/parameters/TransactionId' }
      responses:
        '200': { $ref: '#/components/responses/Transaction' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /verify/{code}:
    get:
      operationId: getTransactionByVerificationCode
      summary: Look up a request by its human code
      x-required-scope: transactions:read
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
        - in: path
          name: code
          required: true
          schema: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Transaction' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Error' }
  /audit:
    get:
      operationId: listAuditEvents
      summary: List tenant audit events
      x-required-scope: audit:read
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
      responses:
        '200': { $ref: '#/components/responses/AuditList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/Error' }
  /jwks:
    get:
      operationId: getJwks
      security: []
      summary: Receipt verification keys
      parameters:
        - { $ref: '#/components/parameters/RequestId' }
      responses:
        '200': { $ref: '#/components/responses/Jwks' }
        '500': { $ref: '#/components/responses/Error' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Send exactly one credential transport, normally `Authorization: Bearer <credential>`.
        `X-Api-Key: <credential>` is retained as an equivalent compatibility transport;
        sending both, repeating either, or using malformed Bearer syntax returns the same generic
        unauthorized response. Structured credentials bind their canonical tenant hint and
        `live`/`test` marker to the authenticated tenant/application environment. The local demo
        credential additionally carries a prototype-only decision scope and works only in explicit demo mode.
      x-alternative-api-key-header: X-Api-Key
      x-exclusive-transports: true
  parameters:
    RequestId:
      in: header
      name: X-Request-Id
      required: false
      description: Optional caller correlation ID. Unsafe values are ignored and replaced. This value is not an authentication or idempotency token.
      schema: { type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$' }
    TransactionId:
      in: path
      name: transactionId
      required: true
      schema: { type: string }
  headers:
    RequestId:
      description: Correlation ID selected for this request; matches error.requestId on errors.
      required: true
      schema: { type: string }
    ApiVersion:
      description: Major API contract version used to serve the response.
      required: true
      schema: { type: string, enum: ['1'] }
    RetryAfter:
      description: Seconds before the caller should retry.
      required: true
      schema: { type: integer, minimum: 1 }
    Authenticate:
      description: Bearer authentication challenge.
      required: true
      schema: { type: string }
    IdempotentReplayed:
      description: Present and true when a stored idempotent response was replayed.
      required: true
      schema: { type: string, enum: ['true'] }
    Location:
      description: Relative URL of the created transaction.
      required: true
      schema: { type: string }
    ContentTypeOptions:
      description: Prevents clients from MIME-sniffing the JSON response.
      required: true
      schema: { type: string, enum: [nosniff] }
    NoStore:
      description: Prevents storage of tenant-scoped or error responses.
      required: true
      schema: { type: string, enum: [no-store] }
    JwksCacheControl:
      description: Public cache policy for receipt verification keys.
      required: true
      schema: { type: string, enum: ['public, max-age=300, stale-while-revalidate=3600'] }
  responses:
    Error:
      description: Safe versioned error. The response never includes secrets, stack traces, SQL, or rejected input values.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing or invalid API credential, including insufficient scope without credential-oracle detail.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
        WWW-Authenticate: { $ref: '#/components/headers/Authenticate' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Request rate limit exceeded.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Transaction:
      description: Current transaction state.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Transaction' }
    TransactionCreated:
      description: Transaction created.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
        Location: { $ref: '#/components/headers/Location' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Transaction' }
    TransactionReplayed:
      description: Stored transaction response replayed for the same idempotency key and normalized request.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
        Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Transaction' }
    TransactionPage:
      description: Application-bound transaction page.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/TransactionPage' }
    AuditList:
      description: Tenant-scoped audit events.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/NoStore' }
      content:
        application/json:
          schema:
            type: object
            required: [data]
            properties:
              data: { type: array, items: { $ref: '#/components/schemas/AuditEvent' } }
    Jwks:
      description: JSON Web Key Set containing the active signing key and verify-only keys retained for receipt validation during rotation.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        X-EasyAuths-API-Version: { $ref: '#/components/headers/ApiVersion' }
        X-Content-Type-Options: { $ref: '#/components/headers/ContentTypeOptions' }
        Cache-Control: { $ref: '#/components/headers/JwksCacheControl' }
      content:
        application/json:
          schema:
            type: object
            required: [keys]
            properties:
              keys: { type: array, items: { $ref: '#/components/schemas/PublicJwk' } }
  schemas:
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: object
          additionalProperties: false
          required: [code, message, requestId]
          properties:
            code: { $ref: '#/components/schemas/ErrorCode' }
            message: { type: string }
            requestId: { type: string }
            details:
              type: array
              items:
                type: object
                additionalProperties: false
                required: [code, path, message]
                properties:
                  code: { type: string }
                  path:
                    type: array
                    items:
                      oneOf:
                        - { type: string }
                        - { type: integer }
                  message: { type: string }
    ErrorCode:
      type: string
      description: Stable machine-readable code. New codes may be added compatibly; clients must handle unknown values.
      enum:
        - UNAUTHORIZED
        - RATE_LIMITED
        - INTERNAL_ERROR
        - INVALID_JSON
        - PAYLOAD_TOO_LARGE
        - UNSUPPORTED_MEDIA_TYPE
        - VALIDATION_ERROR
        - IDEMPOTENCY_KEY_REQUIRED
        - IDEMPOTENCY_KEY_REUSED
        - INVALID_CURSOR
        - NOT_FOUND
        - REQUESTER_NOT_FOUND
        - RECIPIENT_NOT_FOUND
        - RECIPIENT_NOT_ACTIVE
        - APPLICATION_NOT_FOUND
        - WRONG_AUDIENCE
        - TERMINAL_STATE
        - APPROVER_NOT_ACTIVE
        - FORBIDDEN
        - CROSS_TENANT_ACTOR
        - SEPARATION_OF_DUTIES
        - WRONG_RECIPIENT
        - DUPLICATE_DECISION
        - NOT_APPROVED
        - DIGEST_MISMATCH
        - ALREADY_CONSUMED
    TransactionState:
      type: string
      enum: [PENDING, PARTIALLY_APPROVED, APPROVED, DENIED, EXPIRED, CANCELED, SUPERSEDED, CONSUMED]
    ActionType:
      type: string
      enum: [MESSAGE_AUTH, CRITICAL_ACTION, MONEY_MOVEMENT, APP_APPROVAL, LOGIN_MFA, OTHER]
    CreateTransaction:
      type: object
      required: [requesterId, recipientLabel, actionType, title, protectedResource, purpose, interactionMode]
      properties:
        requesterId: { type: string, minLength: 1 }
        recipientId: { type: string, minLength: 1 }
        recipientLabel: { type: string, minLength: 2, maxLength: 160 }
        applicationId: { type: string, minLength: 1 }
        actionType: { $ref: '#/components/schemas/ActionType' }
        title: { type: string, minLength: 3, maxLength: 120 }
        protectedResource: { type: string, minLength: 2, maxLength: 160 }
        purpose: { type: string, minLength: 3, maxLength: 240 }
        note: { type: string, maxLength: 500 }
        amount: { type: number, exclusiveMinimum: 0, maximum: 1000000000 }
        currency: { type: string, minLength: 3, maxLength: 3 }
        externalDigest: { type: string, pattern: '^[a-fA-F0-9]{64}$' }
        interactionMode: { $ref: '#/components/schemas/InteractionMode' }
        requiredApprovals: { type: integer, minimum: 1, maximum: 10, default: 1 }
        requireSeparation: { type: boolean, default: true }
        expiresInMinutes: { type: integer, minimum: 1, maximum: 15, default: 5 }
    Decision:
      type: object
      required: [userId, interactionMode]
      properties:
        userId: { type: string, minLength: 1 }
        comment: { type: string, maxLength: 300 }
        interactionMode: { $ref: '#/components/schemas/InteractionMode' }
        userVerified: { type: boolean, default: true, description: Prototype claim accepted by the local adapter; production will require independently verified authenticator evidence. }
    ConsumeTransaction:
      type: object
      required: [applicationId]
      properties:
        applicationId: { type: string, minLength: 1 }
        expectedDigest: { type: string, pattern: '^[a-fA-F0-9]{64}$' }
    InteractionMode:
      type: string
      enum: [OUT_OF_BAND, SAME_APP_IOS, SAME_APP_ANDROID, SAME_APP_WEB]
    AssuranceLevel:
      type: string
      enum: [CONTACT_VERIFIED, ORG_VOUCHED]
    Approval:
      type: object
      required: [id, transactionId, userId, userName, decision, interactionMode, userVerified, proofType, signature, createdAt]
      properties:
        id: { type: string }
        transactionId: { type: string }
        userId: { type: string }
        userName: { type: string }
        decision: { type: string, enum: [APPROVED, DENIED] }
        comment: { type: string }
        interactionMode: { $ref: '#/components/schemas/InteractionMode' }
        userVerified: { type: boolean }
        proofType: { type: string, enum: [LOCAL_ADAPTER, WEBAUTHN] }
        credentialId: { type: string }
        signature: { type: string }
        createdAt: { type: string, format: date-time }
    Transaction:
      type: object
      required: [id, code, organizationId, organizationName, requesterId, requesterName, requesterEmail, requesterAssurance, recipientLabel, actionType, title, protectedResource, purpose, interactionMode, requiredApprovals, requireSeparation, state, createdAt, expiresAt, approvals]
      properties:
        id: { type: string }
        code: { type: string }
        organizationId: { type: string }
        organizationName: { type: string }
        requesterId: { type: string }
        requesterName: { type: string }
        requesterEmail: { type: string, format: email }
        requesterAssurance: { $ref: '#/components/schemas/AssuranceLevel' }
        recipientId: { type: string }
        recipientLabel: { type: string }
        applicationId: { type: string }
        applicationName: { type: string }
        actionType: { $ref: '#/components/schemas/ActionType' }
        title: { type: string }
        protectedResource: { type: string }
        purpose: { type: string }
        note: { type: string }
        amount: { type: number }
        currency: { type: string }
        externalDigest: { type: string, pattern: '^[a-fA-F0-9]{64}$' }
        interactionMode: { $ref: '#/components/schemas/InteractionMode' }
        requiredApprovals: { type: integer }
        requireSeparation: { type: boolean }
        state: { $ref: '#/components/schemas/TransactionState' }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        approvedAt: { type: string, format: date-time }
        consumedAt: { type: string, format: date-time }
        receipt: { type: string, description: Prototype ES256 compact signed receipt present after final approval }
        approvals:
          type: array
          items: { $ref: '#/components/schemas/Approval' }
    TransactionPage:
      type: object
      required: [data, page]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Transaction' }
        page:
          type: object
          required: [hasMore]
          properties:
            hasMore: { type: boolean }
            nextCursor: { type: string }
    AuditEvent:
      type: object
      required: [id, organizationId, actorName, action, targetType, targetId, summary, previousHash, eventHash, createdAt]
      properties:
        id: { type: string }
        organizationId: { type: string }
        actorId: { type: string }
        actorName: { type: string }
        action: { type: string }
        targetType: { type: string }
        targetId: { type: string }
        summary: { type: string }
        previousHash: { type: string, pattern: '^(GENESIS|[a-f0-9]{64})$' }
        eventHash: { type: string, pattern: '^[a-f0-9]{64}$' }
        createdAt: { type: string, format: date-time }
    PublicJwk:
      type: object
      required: [kty, crv, x, y, alg, use, kid]
      properties:
        kty: { type: string, enum: [EC] }
        crv: { type: string, enum: [P-256] }
        x: { type: string }
        y: { type: string }
        alg: { type: string, enum: [ES256] }
        use: { type: string, enum: [sig] }
        kid: { type: string }
