openapi: 3.1.0
info:
  title: Property Resilience External API
  version: 1.0.0
  description: >-
    Activation-gated risk intelligence API. Sandbox responses are self-authored synthetic
    fixtures and cannot resolve production resources. LIVE remains disabled until the owner
    accepts the API quota policy and production activation gate.
  contact:
    email: kontakt@propertyresilience.pl
servers:
  - url: https://propertyresilience.pl/api/v1
tags:
  - name: Sandbox
  - name: Usage
  - name: Credential management
  - name: Webhook management
paths:
  /external/risk/sandbox/{fixtureId}:
    get:
      operationId: getSyntheticRiskFixture
      tags: [Sandbox]
      summary: Read an explicitly synthetic risk fixture
      security: [{ externalApiKey: [] }]
      x-required-scope: risk:read
      x-property-resilience-environment: sandbox
      parameters:
        - name: fixtureId
          in: path
          required: true
          schema: { type: string, const: synthetic-warsaw-climate-v1 }
      responses:
        '200':
          description: Synthetic fixture only.
          headers:
            x-api-sandbox: { schema: { type: string, const: 'true' } }
            x-api-version: { schema: { type: string, const: 1.0.0 } }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SandboxRiskFixture' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /external/sandbox/echo:
    post:
      operationId: createSyntheticIdempotencyEcho
      tags: [Sandbox]
      summary: Exercise mutation idempotency without production data
      security: [{ externalApiKey: [] }]
      x-required-scope: portfolio:write
      x-property-resilience-environment: sandbox
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [clientReference]
              properties:
                clientReference: { type: string, pattern: '^[A-Za-z0-9._:-]{1,80}$' }
      responses:
        '201':
          description: Synthetic operation accepted or its original result replayed.
          headers:
            Idempotent-Replayed: { schema: { type: string, enum: ['true', 'false'] } }
            x-api-sandbox: { schema: { type: string, const: 'true' } }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [sandbox, clientReference, accepted]
                properties:
                  sandbox: { type: boolean, const: true }
                  clientReference: { type: string }
                  accepted: { type: boolean, const: true }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/ValidationError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /external/usage:
    get:
      operationId: getExternalApiUsage
      tags: [Usage]
      summary: Read the caller account API quota projection
      security: [{ externalApiKey: [] }]
      x-required-scope: usage:read
      responses:
        '200':
          description: Server-authoritative allowance and usage projection.
          headers:
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiUsage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /api-credentials:
    get:
      operationId: listApiCredentials
      tags: [Credential management]
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountId' }]
      responses:
        '200':
          description: Credential metadata; raw secrets are never returned.
          content:
            application/json:
              schema:
                type: object
                required: [environment, items]
                properties:
                  environment: { $ref: '#/components/schemas/ApiEnvironment' }
                  items: { type: array, items: { $ref: '#/components/schemas/ApiCredential' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/Unavailable' }
    post:
      operationId: createApiCredential
      tags: [Credential management]
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateApiCredential' }
      responses:
        '201':
          description: Credential created; `secret` is shown exactly once.
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [credential, secret, secretShownOnce]
                    properties:
                      credential: { $ref: '#/components/schemas/ApiCredential' }
                      secret: { type: string, writeOnly: true, pattern: '^pr_(sandbox|live)_[A-Za-z0-9_-]{12}\.[A-Za-z0-9_-]{43}$' }
                      secretShownOnce: { type: boolean, const: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /api-credentials/{credentialId}:
    delete:
      operationId: revokeApiCredential
      tags: [Credential management]
      security: [{ sessionCookie: [] }]
      parameters:
        - $ref: '#/components/parameters/CredentialId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AccountSelection' }
      responses:
        '204': { description: Credential revoked immediately. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /api-credentials/{credentialId}/rotate:
    post:
      operationId: rotateApiCredential
      tags: [Credential management]
      security: [{ sessionCookie: [] }]
      parameters:
        - $ref: '#/components/parameters/CredentialId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/AccountSelection'
                - type: object
                  properties:
                    overlapMinutes: { type: integer, minimum: 1, maximum: 1440, default: 60 }
      responses:
        '201':
          description: Replacement secret shown once; old key remains valid only for the bounded overlap.
          content:
            application/json:
              schema:
                type: object
                required: [credential, secret, secretShownOnce]
                properties:
                  credential: { $ref: '#/components/schemas/ApiCredential' }
                  secret: { type: string, writeOnly: true }
                  secretShownOnce: { type: boolean, const: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
  /api-usage:
    get:
      operationId: getManagedApiUsage
      tags: [Usage]
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountId' }]
      responses:
        '200':
          description: Account API access and request entitlement decisions.
          content:
            application/json:
              schema:
                type: object
                required: [environment, access, requests]
                properties:
                  environment: { $ref: '#/components/schemas/ApiEnvironment' }
                  access: { $ref: '#/components/schemas/EntitlementDecision' }
                  requests: { $ref: '#/components/schemas/EntitlementDecision' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /api-webhook-endpoints:
    get:
      operationId: listApiWebhookEndpoints
      tags: [Webhook management]
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountId' }]
      responses:
        '200':
          description: Endpoint metadata without signing secrets.
          content:
            application/json:
              schema:
                type: object
                required: [environment, items]
                properties:
                  environment: { $ref: '#/components/schemas/ApiEnvironment' }
                  items: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createApiWebhookEndpoint
      tags: [Webhook management]
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [url, eventTypes]
              properties:
                accountId: { type: [string, 'null'], format: uuid }
                url:
                  type: string
                  format: uri
                  maxLength: 2048
                  description: HTTPS destination on the default port 443; credentials and fragments are forbidden.
                eventTypes:
                  type: array
                  minItems: 1
                  maxItems: 3
                  uniqueItems: true
                  items: { $ref: '#/components/schemas/WebhookEventType' }
      responses:
        '201':
          description: HTTPS endpoint created after public-address validation; signing secret shown once.
          content:
            application/json:
              schema:
                type: object
                required: [endpoint, signingSecret, signingSecretShownOnce]
                properties:
                  endpoint: { $ref: '#/components/schemas/WebhookEndpoint' }
                  signingSecret: { type: string, writeOnly: true }
                  signingSecretShownOnce: { type: boolean, const: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
  /api-webhook-endpoints/{endpointId}:
    delete:
      operationId: revokeApiWebhookEndpoint
      tags: [Webhook management]
      security: [{ sessionCookie: [] }]
      parameters: [{ $ref: '#/components/parameters/EndpointId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AccountSelection' }
      responses:
        '204': { description: Endpoint revoked. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
components:
  securitySchemes:
    externalApiKey:
      type: http
      scheme: bearer
      bearerFormat: pr_sandbox_... or pr_live_...
      description: Raw secret is shown once; rotate immediately if disclosed.
    sessionCookie:
      type: apiKey
      in: cookie
      name: pr_session
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 16, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }
    AccountId:
      name: accountId
      in: query
      required: false
      schema: { type: string, format: uuid }
    CredentialId:
      name: credentialId
      in: path
      required: true
      schema: { type: string, format: uuid }
    EndpointId:
      name: endpointId
      in: path
      required: true
      schema: { type: string, format: uuid }
  headers:
    RateLimitLimit:
      description: Effective shared request limit for the current fixed window.
      schema: { type: integer, minimum: 1 }
    RateLimitRemaining:
      description: Requests remaining in the binding shared window.
      schema: { type: integer, minimum: 0 }
    RateLimitReset:
      description: Unix timestamp when the binding shared window resets.
      schema: { type: integer, minimum: 0 }
  schemas:
    ApiEnvironment: { type: string, enum: [sandbox, live] }
    ApiScope:
      type: string
      enum: [risk:read, portfolio:read, portfolio:write, export:read, usage:read, webhook:manage]
    WebhookEventType:
      type: string
      enum: [risk.analysis.completed.v1, portfolio.screening.completed.v1, export.ready.v1]
    AccountSelection:
      type: object
      additionalProperties: false
      properties:
        accountId: { type: [string, 'null'], format: uuid }
    CreateApiCredential:
      type: object
      additionalProperties: false
      required: [name, scopes]
      properties:
        accountId: { type: [string, 'null'], format: uuid }
        name: { type: string, minLength: 1, maxLength: 100 }
        scopes: { type: array, minItems: 1, maxItems: 6, uniqueItems: true, items: { $ref: '#/components/schemas/ApiScope' } }
        expiresAt: { type: [string, 'null'], format: date-time }
    ApiCredential:
      type: object
      additionalProperties: false
      required: [id, accountId, environment, name, prefix, scopes, status, expiresAt, overlapUntil, lastUsedAt, createdAt]
      properties:
        id: { type: string, format: uuid }
        accountId: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/ApiEnvironment' }
        name: { type: string }
        prefix: { type: string, pattern: '^[A-Za-z0-9_-]{12}$' }
        scopes: { type: array, items: { $ref: '#/components/schemas/ApiScope' } }
        status: { type: string, enum: [active, rotating, revoked] }
        expiresAt: { type: [string, 'null'], format: date-time }
        overlapUntil: { type: [string, 'null'], format: date-time }
        lastUsedAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
    WebhookEndpoint:
      type: object
      additionalProperties: false
      required: [id, accountId, environment, url, hostname, eventTypes, status, createdAt]
      properties:
        id: { type: string, format: uuid }
        accountId: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/ApiEnvironment' }
        url: { type: string, format: uri }
        hostname: { type: string }
        eventTypes: { type: array, items: { $ref: '#/components/schemas/WebhookEventType' } }
        status: { type: string, enum: [active, disabled, revoked] }
        createdAt: { type: string, format: date-time }
    EntitlementDecision:
      type: object
      additionalProperties: true
      required: [feature, allowed, consumed, reserved, remaining, policyVersion, reasonCode]
      properties:
        feature: { type: string, enum: [api.access, api.request] }
        allowed: { type: boolean }
        consumed: { type: integer, minimum: 0 }
        reserved: { type: integer, minimum: 0 }
        remaining: { type: [integer, 'null'], minimum: 0 }
        policyVersion: { type: string }
        reasonCode: { type: string }
    ApiUsage:
      type: object
      additionalProperties: false
      required: [accountId, environment, requests]
      properties:
        accountId: { type: string, format: uuid }
        environment: { $ref: '#/components/schemas/ApiEnvironment' }
        requests: { $ref: '#/components/schemas/EntitlementDecision' }
    SandboxRiskFixture:
      type: object
      additionalProperties: true
      required: [sandbox, fixtureId, contractVersion, modelVersion, synthetic, disclaimer, subject, decision, risks]
      properties:
        sandbox: { type: boolean, const: true }
        fixtureId: { type: string, const: synthetic-warsaw-climate-v1 }
        contractVersion: { type: string, const: 1.0.0 }
        modelVersion: { type: string, const: sandbox-risk-model-1.0.0 }
        synthetic: { type: boolean, const: true }
        disclaimer: { type: string }
        subject: { type: object }
        decision: { type: object }
        risks: { type: array, items: { type: object } }
    Problem:
      type: object
      required: [type, title, status, code, requestId]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        code: { type: string }
        requestId: { type: string }
  responses:
    Unauthorized:
      description: Missing, malformed, expired, revoked, or invalid credential.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    Forbidden:
      description: Scope, entitlement, organization role, or quota authorization failed before work.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    NotFound:
      description: Resource missing or not owned.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    Conflict:
      description: Credential or endpoint state conflict.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    IdempotencyConflict:
      description: Same key with changed request, or original request still processing.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    ValidationError:
      description: Invalid bounded request.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    PayloadTooLarge:
      description: Request exceeds the documented body limit.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    RateLimited:
      description: Shared account or credential rate limit, or plan quota reached.
      headers:
        Retry-After: { schema: { type: integer, minimum: 1 } }
        RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    Unavailable:
      description: External API is disabled or not safely configured.
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
