openapi: 3.0.3
info:
  title: Elderella Partner API
  description: >-
    The public, versioned `/v1` surface for contracted partners (pharmacies,
    clinics, labs). A vetted partner authenticates with the OAuth2
    client-credentials grant, requests a connection to an elder (approved in-app
    by a primary caregiver), then sends files that flow into Elderella's
    extraction pipeline.


    Design references: `docs/PARTNER_API_STRATEGY.md`, epic ELD-5109 §5–§10.


    No-oracle contract (§5/§8): the API never reveals whether an elder address
    resolves to a real elder. A connection is always created `pending`; the send
    endpoint returns an identical `403 no_active_connection` for an unresolvable
    address, an unconsented elder, and a revoked connection; and cross-partner
    id lookups return an identical `404`. These indistinguishability properties
    are contract guarantees, pinned by tests.


    Rate limits (§9): `/v1/*` is limited per authenticated partner; `POST
    /oauth/token` is limited per client IP as a client_secret brute-force
    backstop. A tripped limit returns `429` in the standard error envelope with
    a `Retry-After` header. Limits are enforced per server instance.


    Test mode (§11): a partner may be issued a **test** credential alongside its
    live one. A token minted from a test credential can connect and send ONLY to
    the reserved sandbox address `sandbox.test.elder@care.elderella.com`; a real
    address is unresolvable to it (and the sandbox is unresolvable to a live
    token). Test connections **auto-approve after ~30 seconds** with no human, so
    the full request → approval → send loop can be rehearsed end-to-end. Test
    files return a receipt and progress `received` → `processed` like real ones,
    but never run extraction and never touch real data. Test credentials are
    issued out-of-band by Elderella; no request field selects test mode — the
    credential does.
  version: "1.0.0"

servers:
  - url: https://api.elderella.com
    description: Production partner API host (Cloudflare routes /v1/* here)

tags:
  - name: OAuth
    description: Partner authentication (client-credentials grant)
  - name: Connections
    description: Request, poll, and sever a partner↔elder connection
  - name: Files
    description: Send a file for a consented elder and poll its coarse status

paths:
  /oauth/token:
    post:
      tags: [OAuth]
      summary: Exchange client credentials for a partner bearer token
      operationId: issueToken
      description: >-
        OAuth2 client-credentials grant (RFC 6749 §4.4). Returns a short-lived,
        scoped partner bearer. Every credential failure — unknown `client_id`,
        bad secret, revoked credential, suspended partner — returns the identical
        `invalid_client` error (no client_id enumeration oracle, in body and, via
        a dummy-hash compare, timing). Rate limited per IP.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/TokenRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
      responses:
        '200':
          description: A partner bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
        '400':
          description: >-
            `invalid_request` (missing fields), `unsupported_grant_type`,
            `invalid_client` (any credential failure), or `invalid_scope`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/connections:
    post:
      tags: [Connections]
      summary: Request a connection to an elder
      operationId: createConnection
      description: >-
        Create a pending connection keyed on `(partner, address)`. Always returns
        `202 { id, status: "pending" }` for any valid-shape address — resolved or
        not. A repeat request for the same address returns the same `con_…` id.
        A malformed address is a `400` (leaks nothing about existence).
      security:
        - partnerBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConnectionCreateRequest'
      responses:
        '202':
          description: The pending connection.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectionResource'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/connections/{id}:
    parameters:
      - $ref: '#/components/parameters/ConnectionId'
    get:
      tags: [Connections]
      summary: Poll a connection's status
      operationId: getConnection
      description: >-
        Returns the connection's external status. An unknown id and one owned by
        another partner return an identical `404` (no cross-partner oracle).
      security:
        - partnerBearer: []
      responses:
        '200':
          description: The connection.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectionResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [Connections]
      summary: Sever a connection (partner-initiated)
      operationId: deleteConnection
      description: >-
        Revoke the connection → `revoked`. Idempotent: revoking an already-
        terminal connection still reports `revoked`.
      security:
        - partnerBearer: []
      responses:
        '200':
          description: The revoked connection.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectionResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/files:
    post:
      tags: [Files]
      summary: Send a file for a consented elder
      operationId: sendFile
      description: >-
        Multipart upload of any material about an elder (document, photo, audio,
        or text). Validates type (`415`) and size (`413`, 25 MB) before storing,
        then accepts it for asynchronous extraction and returns `202 { id,
        status: "received" }`. An `Idempotency-Key` retry with the same bytes
        returns the same receipt; the same key with different bytes is a `409`.
        An unresolvable address, an unconsented elder, and a revoked connection
        ALL return the identical `403 no_active_connection`.
      security:
        - partnerBearer: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: Opaque client key; a 24h store dedupes retries.
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/FileSendRequest'
      responses:
        '202':
          description: The file receipt.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileResource'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            `insufficient_scope` (token lacks `files:write`) or
            `no_active_connection` (the single no-oracle send error).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The `Idempotency-Key` was reused with a different payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: The file exceeds the 25 MB limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: The file's MIME type is not in the v1 accepted set.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/files/{id}:
    parameters:
      - $ref: '#/components/parameters/FileId'
    get:
      tags: [Files]
      summary: Poll a file's coarse status
      operationId: getFile
      description: >-
        Returns the coarse status of the partner's own submission. An unknown or
        not-owned id returns an identical `404`.
      security:
        - partnerBearer: []
      responses:
        '200':
          description: The file.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileResource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    partnerBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        A partner bearer from `POST /oauth/token`. Carries the partner audience
        and granted scopes (`connections:write`, `files:write`).

  parameters:
    ConnectionId:
      name: id
      in: path
      required: true
      description: Opaque connection id, `con_…`.
      schema:
        type: string
    FileId:
      name: id
      in: path
      required: true
      description: Opaque file id, `file_…`.
      schema:
        type: string

  responses:
    InvalidRequest:
      description: The request was malformed (bad/missing field, bad address shape).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing, malformed, invalid, or revoked partner bearer.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InsufficientScope:
      description: A valid token that lacks the scope this route requires.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: >-
        The id does not name a resource this partner can see (identical whether
        unknown or owned by another partner).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: The caller exceeded this endpoint's rate limit.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    TokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      properties:
        grant_type:
          type: string
          enum: [client_credentials]
        client_id:
          type: string
        client_secret:
          type: string
        scope:
          type: string
          description: >-
            Space-separated subset of the partner's allowed scopes. Omit to be
            granted all Ring 1 scopes.
    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, scope]
      properties:
        access_token:
          type: string
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Token lifetime in seconds.
        scope:
          type: string
          description: Space-separated granted scopes.
    OAuthError:
      type: object
      description: RFC 6749 §5.2 error shape (distinct from the /v1 error envelope).
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_request, unsupported_grant_type, invalid_client, invalid_scope]
        error_description:
          type: string
    ConnectionCreateRequest:
      type: object
      additionalProperties: false
      required: [elder]
      properties:
        elder:
          type: string
          maxLength: 255
          description: >-
            The elder's Elderella address, e.g.
            `happy.sun.meadow@care.elderella.com`.
    ConnectionResource:
      type: object
      additionalProperties: false
      required: [id, status]
      properties:
        id:
          type: string
          description: Opaque connection id, `con_…`.
        status:
          type: string
          enum: [pending, approved, expired, revoked]
          description: >-
            External connection status. `declined` is never surfaced — a decline
            reads as `pending`.
    FileSendRequest:
      type: object
      required: [file, elder]
      properties:
        file:
          type: string
          format: binary
          description: The file bytes (≤ 25 MB; see the accepted MIME set in §5).
        elder:
          type: string
          description: The elder's Elderella address.
        purpose:
          type: string
          description: Free-form hint; accepted as any string, never validated.
    FileResource:
      type: object
      additionalProperties: false
      required: [id, status]
      properties:
        id:
          type: string
          description: Opaque file id, `file_…`.
        status:
          type: string
          enum: [received, processed, failed]
          description: Coarse status of this submission.
    Error:
      type: object
      description: The standard /v1 error envelope (design §5).
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              description: Coarse grouping, e.g. `invalid_request_error`, `authentication_error`, `rate_limit_error`.
            code:
              type: string
              description: >-
                Stable machine-readable code (versioned contract) — e.g.
                `not_found`, `insufficient_scope`, `no_active_connection`,
                `unsupported_media_type`, `file_too_large`, `idempotency_key_reuse`,
                `rate_limited`.
            message:
              type: string
              description: Human-readable text; may change without a version bump.
            param:
              type: string
              description: The offending field, when applicable.
    WebhookEvent:
      type: object
      additionalProperties: false
      description: >-
        The signed JSON body POSTed to a partner's registered webhook endpoint
        (design §8, §11). Delivered with an `Elderella-Signature: t=<unix_ts>,v1=<hmac>`
        header — HMAC-SHA256 over `"{t}.{raw_body}"` with the partner's signing
        secret. Only two event types are ever emitted; there is deliberately no
        `connection.declined` and no `connection.expired` event.
      required: [id, type, created, data]
      properties:
        id:
          type: string
          description: Opaque event id, `evt_…` — the partner's idempotency key.
        type:
          type: string
          enum: [connection.approved, connection.revoked]
        created:
          type: integer
          description: Event creation time, unix seconds.
        data:
          $ref: '#/components/schemas/WebhookConnectionData'
    WebhookConnectionData:
      type: object
      additionalProperties: false
      required: [connection_id, status]
      properties:
        connection_id:
          type: string
          description: Opaque connection id, `con_…`.
        status:
          type: string
          enum: [pending, approved, expired, revoked]
          description: External connection status at the time of the event.

# Outbound webhook events (design §8, §15 Phase 2). Rendered by Redoc-style
# tooling; kept as a vendor extension so the 3.0.3 contract parser is unaffected.
# Delivery is at-least-once with exponential-backoff retry up to 24h; polling
# `GET /v1/connections/{id}` remains the fallback of record.
x-webhooks:
  connection.approved:
    post:
      summary: A primary caregiver approved the partner's connection to an elder.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
  connection.revoked:
    post:
      summary: >-
        An approved connection was severed — by the caregiver or by the partner's
        own DELETE /v1/connections/{id}.
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
