openapi: 3.1.0
info:
  title: Personal Data Vault - cross-site API
  # API contract version, deliberately independent of the module release tag.
  # Bump only when the wire contract changes, not on every module release.
  version: "1.0"
  description: |
    The HTTP surface a remote **consumer** site uses to read and write a
    user's vault on a **vault** site, plus the browser consent ceremony that
    mints the consumer's authorization.

    This API maps one-to-one to the vault's internal `ConsumerApiInterface`;
    the raw `Vault` (crypto) is never exposed. Two independent things gate it:

    - **OAuth2 (machine identity).** Every `/pdv-api/*` data call carries a
      bearer token obtained via the OAuth2 *client-credentials* grant. The
      token proves *which consumer* is calling, nothing about a user.
    - **Per-user authorization (the owner's consent).** Within a consumer's
      identity, access to a given user's items is gated by per-kind *trusts*
      and per-item *grants*, established through the consent ceremony. Read
      and write are **orthogonal**: a write authorization does not confer
      read, and vice versa.

    Users are addressed by an opaque **handle** (an AEAD of the uid under a
    per-consumer key), never by uid. A consumer obtains its handle for a user
    by completing the consent ceremony once.

    All data responses are sent `Cache-Control: no-store`.

servers:
  - url: '{vault_base_url}'
    description: The vault site (scheme and host).
    variables:
      vault_base_url:
        default: https://vault.example

security:
  - oauth2: [pdv_api]

paths:
  /oauth/token:
    post:
      operationId: token
      summary: Obtain a client-credentials access token
      description: |
        Standard OAuth2 client-credentials grant, served by simple_oauth. The
        consumer authenticates with its `client_id` and `client_secret`
        (the secret is held in a key module Key on the consumer, never in
        config) and receives a bearer token scoped to `pdv_api`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              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
                  example: pdv_api
      responses:
        '200':
          description: A bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                  expires_in:
                    type: integer
                    example: 3600
                  token_type:
                    type: string
                    example: Bearer
        '401':
          description: Invalid client credentials.

  /pdv-api/user/{handle}/items:
    get:
      operationId: listItems
      summary: List the items the consumer is authorized on
      description: |
        Lists item references the consumer may access for the user, filtered
        by scope. Returns references only (id, kind, label) - never content -
        and an empty list for an unauthorized consumer (no existence oracle).
      parameters:
        - $ref: '#/components/parameters/handle'
        - name: kind
          in: query
          required: false
          schema: { type: string }
          description: >-
            Restrict to one kind machine name, or several as a comma-separated
            list (e.g. `id_card,passport`). Omit for all kinds. Listing the
            exact kinds needed avoids both per-kind calls and over-fetching.
        - name: scope
          in: query
          required: false
          schema:
            type: string
            enum: [read, write]
            default: read
          description: |
            `read` (default) lists items the consumer may read; `write` lists
            items it may write (e.g. to choose create vs. update on a
            save-back). Scopes are orthogonal.
      responses:
        '200':
          description: The authorized items for the scope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/ItemRef' }

  /pdv-api/user/{handle}/item/{item_id}/record:
    get:
      operationId: readRecord
      summary: Read a structured record's field values
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/item_id'
      responses:
        '200':
          description: The decoded record field values.
          content:
            application/json:
              schema:
                type: object
                properties:
                  values:
                    type: object
                    additionalProperties: true
        '403':
          $ref: '#/components/responses/Denied'

  /pdv-api/user/{handle}/item/{item_id}/raw:
    get:
      operationId: readRaw
      summary: Read the raw decrypted bytes of an item (e.g. a file)
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/item_id'
      responses:
        '200':
          description: The decrypted body bytes.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '403':
          $ref: '#/components/responses/Denied'

  /pdv-api/user/{handle}/item/{item_id}/access:
    get:
      operationId: canRead
      summary: Probe whether the consumer may read an item
      description: A side-effect-free read-authorization check.
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/item_id'
      responses:
        '200':
          description: Whether the consumer may read the item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  can_read: { type: boolean }

  /pdv-api/user/{handle}/kind-access:
    get:
      operationId: kindAccess
      summary: Report the consumer's per-kind access (read and/or write)
      description: >-
        A side-effect-free, kind-level access check: which of the given kinds
        the consumer holds a trust for, per scope. Lets a UI learn read and
        write access in one query -- e.g. to offer only save targets that will
        succeed. References kind-level trusts only, not per-item grants.
      parameters:
        - $ref: '#/components/parameters/handle'
        - name: kinds
          in: query
          required: true
          schema: { type: string }
          description: Comma-separated candidate kind machine names.
        - name: scope
          in: query
          required: false
          schema: { type: string }
          description: >-
            Comma-separated scopes to report: `read`, `write`, or both
            (default `read,write`).
      responses:
        '200':
          description: >-
            The trusted kinds keyed by scope (only the requested scopes appear),
            plus `declined`: kinds the owner refused for the requested scopes, so
            a re-prompting UI drops them and stops asking. `declined` is posture,
            not inventory -- it reveals only refusal, never what the owner holds.
          content:
            application/json:
              schema:
                type: object
                properties:
                  read:
                    type: array
                    items: { type: string }
                  write:
                    type: array
                    items: { type: string }
                  declined:
                    type: array
                    items: { type: string }

  /pdv-api/kind-labels:
    get:
      operationId: kindLabels
      summary: Translated, human labels for vault item kinds
      description: >-
        The vault owns kinds and their translations, so it is the single source
        of their labels; a consumer never reconstructs a label from a machine
        name (which is meaningless to a user, in any language). Not user-scoped
        (no handle): kind labels are shared reference metadata, so a consumer
        caches the catalogue and fetches it rarely rather than per call.
      parameters:
        - name: kinds
          in: query
          required: false
          schema: { type: string }
          description: >-
            Comma-separated kind machine names to restrict to; omit for the
            whole catalogue.
        - name: langcode
          in: query
          required: false
          schema: { type: string }
          description: >-
            Language to resolve labels in; defaults to the vault's active
            language.
      responses:
        '200':
          description: A map of kind machine name to human label.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: { type: string }

  /pdv-api/user/{handle}/record/{kind}:
    post:
      operationId: createRecord
      summary: Create a structured record of a kind
      description: |
        Requires a standing write authorization (write-trust on the kind, or a
        write-grant). For a unique kind, fails 409 if one already exists.
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/kind'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecordWrite' }
      responses:
        '201':
          $ref: '#/components/responses/ItemWritten'
        '400': { description: Body is not JSON with a "values" object. }
        '403':
          $ref: '#/components/responses/ConsentRequired'
        '409': { description: A record of this unique kind already exists; use PUT. }
        '413': { description: Request body too large. }
    put:
      operationId: updateRecord
      summary: Update (merge into) a record of a kind
      description: |
        Field values are merged onto the current ones; the stored label is
        kept when none is supplied. Requires write authorization. Fails 409 if
        no record of the kind exists yet.
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/kind'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecordWrite' }
      responses:
        '200':
          $ref: '#/components/responses/ItemWritten'
        '400': { description: Body is not JSON with a "values" object. }
        '403':
          $ref: '#/components/responses/ConsentRequired'
        '409': { description: No record of this kind to update; use POST. }
        '413': { description: Request body too large. }

  /pdv-api/user/{handle}/file/{kind}:
    post:
      operationId: saveFile
      summary: Store (or replace) a file of a kind
      description: |
        The request body is the raw file bytes; the MIME comes from
        `Content-Type`. Requires write authorization. Replaces the existing
        file of a unique kind in place.
      parameters:
        - $ref: '#/components/parameters/handle'
        - $ref: '#/components/parameters/kind'
        - name: filename
          in: query
          schema: { type: string }
        - name: label
          in: query
          schema: { type: string }
          description: Defaults to the filename when omitted.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '200':
          $ref: '#/components/responses/ItemWritten'
        '400': { description: Empty file body. }
        '403':
          $ref: '#/components/responses/ConsentRequired'
        '409': { description: Write conflict for this kind. }
        '413': { description: Request body too large. }

  /pdv-api/consent/start:
    get:
      operationId: consentStart
      summary: Begin the owner consent ceremony (browser flow)
      description: |
        Not an OAuth call: this is opened in the **owner's** browser (they must
        be logged in on the vault). The vault parks a grant request for the
        named consumer and redirects to the approval page; after the owner
        decides, the browser is redirected back to `return_url` with the
        outcome and an opaque `handle` for the (consumer, owner) pair. A
        `scope=write` ceremony pre-enables the write-trust option; `mode=trust`
        (read only) sends the owner to the streamlined "trust these kinds" page
        (one read-trust decision, a checkbox per kind) instead of the per-item
        picker.
      security: []
      parameters:
        - name: consumer
          in: query
          required: true
          schema: { type: string }
          description: The consumer's OAuth client id.
        - name: kinds
          in: query
          required: true
          schema: { type: string }
          description: Comma-separated kind machine names.
        - name: return_url
          in: query
          required: true
          schema: { type: string, format: uri }
          description: |
            Where to send the owner back. Must be on the vault's configured
            allow-list of consumer origins.
        - name: state
          in: query
          required: true
          schema: { type: string }
          description: Anti-CSRF token the consumer verifies on the callback.
        - name: scope
          in: query
          schema:
            type: string
            enum: [read, write]
            default: read
        - name: mode
          in: query
          required: false
          schema:
            type: string
            enum: [trust]
          description: >-
            `trust` (read only) opens the streamlined "trust these kinds"
            page -- one read-trust decision with a checkbox per kind, instead of
            the per-item picker. Omit for the standard item-by-item ceremony.
      responses:
        '302':
          description: Redirect to the approval page (then back to return_url).
        '400': { description: Missing consumer, state, or kinds. }
        '403': { description: Unknown consumer/kind, or a disallowed return_url. }

components:
  securitySchemes:
    oauth2:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes:
            pdv_api: Access the Personal Data Vault cross-site API.

  parameters:
    handle:
      name: handle
      in: path
      required: true
      schema: { type: string }
      description: The opaque per-(consumer, owner) handle from the consent ceremony.
    item_id:
      name: item_id
      in: path
      required: true
      schema: { type: integer }
    kind:
      name: kind
      in: path
      required: true
      schema: { type: string }
      description: An item-kind machine name (e.g. `civil_status`, `id_card`).

  schemas:
    ItemRef:
      type: object
      properties:
        id: { type: integer }
        kind:
          type: string
          nullable: true
        label: { type: string }
    RecordWrite:
      type: object
      required: [values]
      properties:
        values:
          type: object
          additionalProperties: true
          description: The record's field values, keyed by field name.
        label:
          type: string
          description: Optional; on update the stored label is kept when omitted.
    ConsentRequiredError:
      type: object
      properties:
        error:
          type: string
          enum: [consent_required]
        consent_url:
          type: string
          format: uri
          description: The consent landing to send the owner to (append return_url + state).
        kinds:
          type: array
          items: { type: string }

  responses:
    ItemWritten:
      description: The stored item reference.
      content:
        application/json:
          schema:
            type: object
            properties:
              item: { $ref: '#/components/schemas/ItemRef' }
    Denied:
      description: |
        The consumer is not authorized for this item, or the stored ciphertext
        could not be decrypted (tampered or wrong key). Uniform regardless of
        whether the item exists, so it is neither an existence nor an integrity
        oracle.
    ConsentRequired:
      description: |
        No standing write authorization. The consumer should send the owner
        through `consent_url` to grant access, then retry. Uniform regardless
        of item/kind existence.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ConsentRequiredError' }
