openapi: 3.1.0
info:
  title: wDOI Registry API
  version: 1.0.0
  description: |
    REST API for minting and managing wDOI identifiers, API keys, and webhooks.
    All write endpoints support idempotency and return consistent error shapes.
servers:
  - url: https://api.sandbox.wdoi.org
    description: Sandbox
  - url: https://api.wdoi.org
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Health
  - name: Auth / API Keys
  - name: Webhooks
  - name: Identifiers
  - name: Search
  - name: Public
x-rateLimits:
  burstPerSecond: 20
  windowTenMinutes: 600
  daily: 50000
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
  parameters:
    Prefix: { in: path, name: prefix, required: true, schema: { type: string } }
    Suffix: { in: path, name: suffix, required: true, schema: { type: string } }
    OrgIdQ: { in: query, name: organizationId, schema: { type: string } }
    PubIdQ: { in: query, name: publisherId, schema: { type: string } }
    ColIdQ: { in: query, name: collectionId, schema: { type: string } }
  headers:
    X-Request-Id: { schema: { type: string }, description: Correlation/Idempotency id echoed back }
    X-RateLimit-Limit: { schema: { type: integer } }
    X-RateLimit-Remaining: { schema: { type: integer } }
    X-RateLimit-Reset: { schema: { type: integer }, description: Unix epoch seconds when limit resets }
    Idempotency-Status: { schema: { type: string, enum: [replayed] } }
  responses:
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Authenticated but lacks permissions/scopes
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: Input failed validation
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }
    RateLimited:
      description: Too many requests
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds to wait }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    # ---------- Common ----------
    ResourceType:
      type: string
      enum: [ARTICLE, ISSUE, DATASET, PREPRINT, OTHER]
    State:
      type: string
      enum: [ACTIVE, DEPRECATED, TOMBSTONED]
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum: [BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, RATE_LIMITED, VALIDATION_FAILED, INTERNAL]
        message: { type: string }
        requestId: { type: string }
    ValidationError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            details:
              type: array
              items:
                type: object
                properties:
                  path: { type: string, example: 'metadataJson.titles[0].value' }
                  msg: { type: string }
    # ---------- Auth / Keys ----------
    Scope:
      type: string
      enum:
        - identifiers:mint
        - identifiers:update
        - identifiers:read
        - search:read
        - webhooks:manage
        - keys:manage
    KeyScopeTarget:
      type: object
      properties:
        organizationId: { type: string, nullable: true }
        publisherId: { type: string, nullable: true }
        collectionId: { type: string, nullable: true }
    ApiKeyCreateReq:
      type: object
      required: [name, scopes]
      properties:
        name: { type: string, maxLength: 120 }
        scopes:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/Scope' }
        scopeTarget: { $ref: '#/components/schemas/KeyScopeTarget' }
        expiresAt: { type: string, format: date-time, nullable: true }
    ApiKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        lastFour: { type: string }
        scopes: { type: array, items: { $ref: '#/components/schemas/Scope' } }
        scopeTarget: { $ref: '#/components/schemas/KeyScopeTarget' }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time, nullable: true }
        disabledAt: { type: string, format: date-time, nullable: true }
    ApiKeySecret:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          properties:
            token:
              type: string
              description: The cleartext token (returned once on creation)
    # ---------- Webhooks ----------
    WebhookEventType:
      type: string
      enum:
        - identifier.minted
        - identifier.updated
        - identifier.tombstoned
        - relation.added
        - search.reindexed
    WebhookEndpointCreateReq:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEventType' }, minItems: 1 }
        description: { type: string, maxLength: 200, nullable: true }
        secret: { type: string, nullable: true, description: 'If omitted, server generates one' }
        active: { type: boolean, default: true }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEventType' } }
        active: { type: boolean }
        description: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        lastDeliveryAt: { type: string, format: date-time, nullable: true }
    WebhookSecretReveal:
      type: object
      properties:
        secret: { type: string, description: 'Cleartext (shown only on create/rotate)' }
    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        endpointId: { type: string }
        event: { $ref: '#/components/schemas/WebhookEventType' }
        status: { type: string, enum: [PENDING, DELIVERED, FAILED] }
        attempt: { type: integer }
        maxAttempts: { type: integer }
        deliveredAt: { type: string, format: date-time, nullable: true }
        responseCode: { type: integer, nullable: true }
        payload: { type: object, additionalProperties: true }
        signature: { type: string }
        errorMsg: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    # ---------- Identifiers ----------
    Identifier:
      type: object
      properties:
        id: { type: string }
        wdoi: { type: string, description: 'wdoi:10.xxxx/suffix' }
        prefix: { type: string }
        suffix: { type: string }
        targetUrl: { type: string, format: uri }
        resourceType: { $ref: '#/components/schemas/ResourceType' }
        state: { $ref: '#/components/schemas/State' }
        metadataJson: { type: object, additionalProperties: true }
        organization: { type: object, nullable: true, additionalProperties: true }
        publisher: { type: object, nullable: true, additionalProperties: true }
        collection: { type: object, nullable: true, additionalProperties: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    IdentifierPublic:
      type: object
      properties:
        wdoi: { type: string }
        prefix: { type: string }
        suffix: { type: string }
        targetUrl: { type: string, format: uri }
        resourceType: { $ref: '#/components/schemas/ResourceType' }
        state: { $ref: '#/components/schemas/State' }
        title: { type: string, nullable: true }
        metadata: { type: object, additionalProperties: true }
        collection: { type: object, nullable: true, additionalProperties: true }
        related:
          type: array
          items:
            type: object
            properties:
              kind: { type: string, enum: [IS_PREPRINT_OF, IS_SUPPLEMENT_TO, CITES, IS_VERSION_OF, IS_PART_OF, HAS_PART] }
              direction: { type: string, enum: [in, out] }
              wdoi: { type: string }
              title: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    MintRequest:
      type: object
      required: [prefix, suffixPolicy, targetUrl, metadataJson]
      properties:
        prefix: { type: string, example: '10.wdoi' }
        suffixPolicy: { type: string, enum: [AUTO, MANUAL] }
        suffix: { type: string, description: Required if MANUAL }
        targetUrl: { type: string, format: uri }
        resourceType: { $ref: '#/components/schemas/ResourceType' }
        collectionId: { type: string, nullable: true }
        metadataJson: { type: object, additionalProperties: true }
        idempotencyKey: { type: string }
        publisherId: { type: string, nullable: true }
    UpdateRequest:
      type: object
      properties:
        targetUrl: { type: string, format: uri }
        state: { $ref: '#/components/schemas/State' }
        resourceType: { $ref: '#/components/schemas/ResourceType' }
        metadataJson: { type: object, additionalProperties: true }
    TombstoneRequest:
      type: object
      properties:
        reason: { type: string, maxLength: 2000 }
    AddRelationRequest:
      type: object
      required: [toWdoi, kind]
      properties:
        toWdoi: { type: string }
        kind:
          type: string
          enum: [IS_PREPRINT_OF, IS_SUPPLEMENT_TO, CITES, IS_VERSION_OF, IS_PART_OF, HAS_PART]
    SearchHit:
      type: object
      properties:
        id: { type: string }
        score: { type: number }
        wdoi: { type: string }
        prefix: { type: string }
        suffix: { type: string }
        title: { type: string, nullable: true }
        targetUrl: { type: string }
        state: { $ref: '#/components/schemas/State' }
        resourceType: { $ref: '#/components/schemas/ResourceType' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        metadataJson: { type: object, additionalProperties: true }

paths:
  # ---------- Health ----------
  /v1/health:
    get:
      tags: [Health]
      summary: Liveness/readiness info
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
              type: object
              properties:
                status: { type: string, example: 'ok' }
                opensearch: { type: string, example: 'enabled' }
                db: { type: string, example: 'ok' }
                version: { type: string }
  # ---------- Auth / API Keys ----------
  /v1/auth/keys:
    post:
      tags: [Auth / API Keys]
      summary: Create API key
      description: Returns the cleartext token once
      x-scopes: [keys:manage]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApiKeyCreateReq' }
      responses:
        '200': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/ApiKeySecret' } } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    get:
      tags: [Auth / API Keys]
      summary: List API keys
      x-scopes: [keys:manage]
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/ApiKey' } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/auth/keys/{keyId}:
    get:
      tags: [Auth / API Keys]
      summary: Get a key (metadata)
      x-scopes: [keys:manage]
      parameters: [{ in: path, name: keyId, required: true, schema: { type: string } }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiKey' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Auth / API Keys]
      summary: Disable/revoke a key
      x-scopes: [keys:manage]
      parameters: [{ in: path, name: keyId, required: true, schema: { type: string } }]
      responses:
        '204': { description: Revoked }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------- Webhooks ----------
  /v1/webhooks/endpoints:
    post:
      tags: [Webhooks]
      summary: Create webhook endpoint
      x-scopes: [webhooks:manage]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointCreateReq' }
      responses:
        '200':
          description: Created
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/WebhookEndpoint'
                - type: object
                properties:
                secret:
                type: string
                description: 'Cleartext secret (only on create)'
        '400': { $ref: '#/components/responses/ValidationError' }
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      x-scopes: [webhooks:manage]
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } } } } }

  /v1/webhooks/endpoints/{endpointId}:
    get:
      tags: [Webhooks]
      summary: Get endpoint
      x-scopes: [webhooks:manage]
      parameters: [{ in: path, name: endpointId, required: true, schema: { type: string } }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookEndpoint' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Webhooks]
      summary: Update endpoint (url, events, active, description)
      x-scopes: [webhooks:manage]
      parameters: [{ in: path, name: endpointId, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
            type: object
            properties:
              url: { type: string, format: uri }
              events: { type: array, items: { $ref: '#/components/schemas/WebhookEventType' } }
              active: { type: boolean }
              description: { type: string, maxLength: 200 }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookEndpoint' } } } }
    delete:
      tags: [Webhooks]
      summary: Delete endpoint
      x-scopes: [webhooks:manage]
      parameters: [{ in: path, name: endpointId, required: true, schema: { type: string } }]
      responses:
        '204': { description: Deleted }
  /v1/webhooks/endpoints/{endpointId}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate and reveal new secret
      x-scopes: [webhooks:manage]
      parameters: [{ in: path, name: endpointId, required: true, schema: { type: string } }]
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookSecretReveal' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/webhooks/deliveries:
    get:
      tags: [Webhooks]
      summary: List recent deliveries (paged)
      x-scopes: [webhooks:manage]
      parameters:
        - in: query
          name: endpointId
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
        - in: query
          name: beforeId
          schema: { type: string }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/WebhookDelivery' } } } } }
  /v1/webhooks/deliveries/{deliveryId}/retry:
    post:
      tags: [Webhooks]
      summary: Retry a failed delivery now
      x-scopes: [webhooks:manage]
      parameters: [{ in: path, name: deliveryId, required: true, schema: { type: string } }]
      responses:
        '202': { description: Retry queued }

  # ---------- Identifiers ----------
  /v1/identifiers:
    get:
      tags: [Identifiers]
      summary: List identifiers (filtered)
      parameters:
        - in: query
          name: prefix
          schema: { type: string }
        - $ref: '#/components/parameters/OrgIdQ'
        - $ref: '#/components/parameters/ColIdQ'
        - in: query
          name: state
          schema: { $ref: '#/components/schemas/State' }
        - in: query
          name: includeTombstoned
          schema: { type: boolean }
        - in: query
          name: resourceType
          schema: { $ref: '#/components/schemas/ResourceType' }
        - in: query
          name: q
          schema: { type: string }
      responses:
        '200':
          description: OK
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Identifier'

    post:
      tags: [Identifiers]
      summary: Mint a new identifier
      description: Requires scope identifiers:mint and DEPOSITOR/JM role (or RA)
      x-scopes: [identifiers:mint]
      requestBody:
      responses:
        '200':
          description: Minted (idempotent)
          headers:
            Idempotency-Status: { $ref: '#/components/headers/Idempotency-Status' }
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Identifier'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }


  /v1/identifiers/by-org/{orgId}:
    get:
      tags: [Identifiers]
      summary: List by organization
      parameters:
        - in: path
          name: orgId
          required: true
          schema: { type: string }
        - in: query
          name: q
          schema: { type: string }
        - in: query
          name: state
          schema: { $ref: '#/components/schemas/State' }
        - in: query
          name: prefix
          schema: { type: string }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Identifier' } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/identifiers/by-publisher/{publisherId}:
    get:
      tags: [Identifiers]
      summary: List by publisher
      parameters:
        - in: path
          name: publisherId
          required: true
          schema: { type: string }
        - in: query
          name: q
          schema: { type: string }
        - in: query
          name: state
          schema: { $ref: '#/components/schemas/State' }
        - in: query
          name: prefix
          schema: { type: string }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Identifier' } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/identifiers/by-collection/{collectionId}:
    get:
      tags: [Identifiers]
      summary: List by collection
      parameters:
        - in: path
          name: collectionId
          required: true
          schema: { type: string }
        - in: query
          name: q
          schema: { type: string }
        - in: query
          name: state
          schema: { $ref: '#/components/schemas/State' }
        - in: query
          name: prefix
          schema: { type: string }
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Identifier' } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/identifiers/{prefix}/{suffix}:
    get:
      tags: [Identifiers]
      summary: Read identifier (private shape)
      parameters:
        - $ref: '#/components/parameters/Prefix'
        - $ref: '#/components/parameters/Suffix'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Identifier'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      tags: [Identifiers]
      summary: Update (metadata/URL/state)
      x-scopes: [identifiers:update]
      parameters:
        - $ref: '#/components/parameters/Prefix'
        - $ref: '#/components/parameters/Suffix'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateRequest'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Identifier'
        '400':
          $ref: '#/components/responses/ValidationError'

    delete:
      tags: [Identifiers]
      summary: Tombstone
      x-scopes: [identifiers:update]
      parameters:
        - $ref: '#/components/parameters/Prefix'
        - $ref: '#/components/parameters/Suffix'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TombstoneRequest'
      responses:
        '200':
          description: Tombstoned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Identifier'

  /v1/identifiers/{prefix}/{suffix}/relations:
    post:
      tags: [Identifiers]
      summary: Add relation edge
      x-scopes: [identifiers:update]
      parameters:
        - $ref: '#/components/parameters/Prefix'
        - $ref: '#/components/parameters/Suffix'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddRelationRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  relationId: { type: string }

  /v1/identifiers/public/{prefix}/{suffix}:
    get:
      tags: [Public]
      summary: Public, sanitized payload
      security: []
      parameters:
        - $ref: '#/components/parameters/Prefix'
        - $ref: '#/components/parameters/Suffix'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IdentifierPublic'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/identifiers/search:
    get:
      tags: [Search]
      summary: Full-text search (OpenSearch)
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string }
        - in: query
          name: size
          schema: { type: integer, default: 10, maximum: 100 }
        - in: query
          name: state
          schema: { $ref: '#/components/schemas/State' }
        - in: query
          name: prefix
          schema: { type: string }
        - in: query
          name: includeTombstoned
          schema: { type: boolean }
        - in: query
          name: resourceType
          schema: { $ref: '#/components/schemas/ResourceType' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SearchHit'
        '400':
          $ref: '#/components/responses/ValidationError'

  /v1/identifiers/validate:
    post:
      tags: [Identifiers]
      summary: Validate a would-be deposit (no write)
      x-scopes: [identifiers:mint]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MintRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  warnings:
                    type: array
                    items: { type: string }
        '400':
          $ref: '#/components/responses/ValidationError'

  /v1/identifiers/admin/reindex-all:
    post:
      tags: [Identifiers]
      summary: Reindex all to OpenSearch
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                properties:
                  ok: { type: boolean }
                  indexed: { type: integer }
