# Copyright The Gentian OS Authors.
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
#
# SPDX-License-Identifier: MPL-2.0

openapi: 3.1.0
info:
  title: Gentian OS usage statistics report receiver
  version: "1"
  summary: The one request a reporting cluster makes, and what its receiver may answer.
  license:
    name: Mozilla Public License 2.0
    identifier: MPL-2.0
  description: |
    A Gentian OS cluster with usage statistics on sends one report a few
    minutes after its operator starts and one every 24 hours after that.
    This file is the format of that request, for whoever receives it. It
    describes what the cluster already does (`internal/usagestatistics`,
    `docs/concepts/operations.md` §6.2); it changes nothing about it. Where
    this file and the code disagree, the code is what clusters send and
    this file is the defect.

    ## Direction

    The cluster calls the receiver. The receiver never calls the cluster,
    and nothing it answers is acted on: the cluster reads the HTTP status of
    the reply and nothing else, and discards the body. A cluster whose
    reports are refused runs exactly as one whose reports arrive.

    ## What the cluster does

    * It sends to one `https` address fixed at install time
      (`usageStatistics.url` in the operator chart). The installer's default
      is `https://statistics.aluvian.io/api/v1/usage-statistics`; that
      default is provisional. An address that is not
      `https` counts as none and nothing is sent.
    * It follows no redirect. A `3xx` is a report that did not arrive.
    * A status from 200 to 299 is `accepted`. Anything else, or no answer
      within 30 seconds, is a failure, and the report is tried again after a
      wait that doubles from one minute up to one hour. A retry is a new
      report with the next `sequence` and a new `sentAt`, not the same bytes
      again.
    * It never sends unsigned. A cluster without its signing key sends
      nothing.

    ## What a receiver should do

    * Verify the signature over the request body exactly as received, byte
      for byte, before parsing it: `X-Gentian-Signature` is `ed25519=`
      followed by the base64 (standard alphabet, padded) Ed25519 signature,
      made with the key whose public half is `publicKey` in the body.
      `X-Gentian-Key-Id` is the first 16 hexadecimal characters of the
      SHA-256 of the raw 32-byte public key, and should match.
    * The public key travels in the report it verifies, so a signature
      proves only that the sender holds that key. What ties a key to a
      cluster is the receiver's own record, kept per cluster URL: remember
      the key first seen for a `cluster.url`, and treat a report for that
      URL under another key as a different sender. The remembered key is
      reset by the holder of the account that URL belongs to at the
      receiver, and by nothing a report says; the next key seen for the
      URL is then the one remembered.
    * Refuse a `version` it does not know rather than guess at it.
    * Use `sequence` to tell a replayed report from a new one: it counts a
      cluster's reports and never repeats. It may skip numbers — a report
      that left the cluster and whose outcome could not be recorded has
      still used its number.

    ## What is in a report, and what is not

    Counts and addresses of things, never of people: no name, e-mail
    address, user id, group name, tenant display name or administrator's
    address, and nothing about an app or an add-on that was not installed
    at a stated digest.

    ## Changes to the format

    `version` is raised when a report can no longer be read by a receiver
    built to the version before. A member added to an object does not do
    that: no schema here forbids members it does not name, and a receiver
    should ignore one it does not know rather than refuse the report.

    * `apps[].addons` was added under `version` 1. It lists the app's
      add-ons that are pinned to a digest. A report made before it existed
      has no such member; one made since always has it.

    ## What a store does with it

    A store that depends on usage statistics (`store-api.openapi.yaml`,
    `GET /v1/tenant`) matches the tenant URL a person states at sign-in
    against `tenants[].url` of the reports it has received.
servers:
  - url: "{usageStatisticsUrl}"
    description: The address fixed at install time. The report is posted to exactly this address; no path is appended.
    variables:
      usageStatisticsUrl:
        default: https://reports.example/v1/usage-statistics
paths:
  /:
    post:
      operationId: receiveUsageStatistics
      summary: Receive one cluster's report
      security: []
      parameters:
        - name: X-Gentian-Signature
          in: header
          required: true
          description: "`ed25519=` and the base64 Ed25519 signature over the request body, byte for byte."
          schema:
            type: string
            pattern: "^ed25519=[A-Za-z0-9+/]{86}==$"
          example: ed25519=q2Jm0V0sO3o1cT0d6vH1x9y8m2b7nQk4w5e6r7t8y9u0i1o2p3a4s5d6f7g8h9j0k1l2z3x4c5v6b7n8m9A0BA==
        - name: X-Gentian-Key-Id
          in: header
          required: true
          description: The first 16 hexadecimal characters of the SHA-256 of the public key in the body.
          schema:
            type: string
            pattern: "^[0-9a-f]{16}$"
          example: 9f2c4e6a8b0d1f35
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Report"
            examples:
              report:
                value:
                  version: 1
                  sequence: 12
                  sentAt: "2026-03-04T05:06:07Z"
                  cluster:
                    id: 3b1f6c0e-8a52-4d0e-9c1b-2f7a5e9d4c10
                    url: https://cluster.example
                  tenants:
                    - url: https://acme.example
                      users: 40
                      apps:
                        - coordinate: gentian/nextcloud-base-ee
                          digest: sha256:3f6c0a1e5b7d9c2a4e6f8091a3b5c7d9e1f20314253647586970a1b2c3d4e5f6
                          users: 25
                          addons:
                            - coordinate: gentian/nextcloud-talk
                              digest: sha256:7a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9
                              users: 7
                        - coordinate: null
                          digest: sha256:5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e
                          users: null
                          addons: []
                  publicKey: 7Yq0b1J2k3L4m5N6o7P8q9R0s1T2u3V4w5X6y7Z8a9A=
      responses:
        "2XX":
          description: |
            Accepted. Any status from 200 to 299; the cluster records it and
            reads no body. `202` with no body is the usual answer.
        "4XX":
          description: |
            Refused: a signature that does not verify, a version the
            receiver does not know, a body that is not a report. The cluster
            records the status and tries again later with a new report; it
            reads no body and nothing on it changes.
        "5XX":
          description: The receiver failed. Treated by the cluster exactly as a refusal.
components:
  schemas:
    Report:
      type: object
      description: |
        The body, whole. The cluster sends these members and no others, as
        compact JSON in this order; a receiver verifies the signature over
        the bytes received and does not re-serialise them first.
      required: [version, sequence, sentAt, cluster, tenants, publicKey]
      properties:
        version:
          type: integer
          const: 1
          description: The format of the body.
        sequence:
          type: integer
          format: int64
          minimum: 1
          description: Counts this cluster's reports. Never repeats.
        sentAt:
          type: string
          format: date-time
          description: When the report was made. RFC 3339, UTC, to the second.
        cluster:
          type: object
          required: [id, url]
          properties:
            id:
              type: string
              description: The id the cluster is known by.
            url:
              type: string
              description: "`https://` and the cluster's own domain."
        tenants:
          type: array
          description: Every tenant that has a host, sorted by `url`. A tenant being deleted is left out. May be empty.
          items:
            $ref: "#/components/schemas/Tenant"
        publicKey:
          type: string
          description: The Ed25519 public key the signature verifies with — 32 bytes, base64, standard alphabet, padded.
          pattern: "^[A-Za-z0-9+/]{43}=$"
    Tenant:
      type: object
      required: [url, users, apps]
      properties:
        url:
          type: string
          description: "`https://` and the tenant's host. No path, no trailing slash. The tenant's name is never sent in its place."
        uid:
          type: string
          description: |
            The tenant's unique id, its `metadata.uid`: random, never reused,
            not derived from its name. Optional: absent where the tenant has
            none. Added without raising `version`. It is the name a store
            gives the tenant's realm as a partner (AD-17).
        issuer:
          type: string
          description: |
            The tenant's realm, `https://id.<cluster domain>/auth/realms/<realm>`:
            the issuer of the assertions its people's apps send a partner
            such as a store. Optional; added without raising `version`.
        terms:
          type: object
          description: |
            The store's terms the tenant accepted: which version and when,
            as its App Store recorded it on the tenant (`spec.storeTerms`).
            Optional: absent where the tenant has accepted none. Added
            without raising `version`. With `uid` and `issuer`, what a store
            holds a free acquisition without registration to (store API
            1.13, `POST /v1/acquisitions/free`); nothing on the cluster turns
            on it.
          required: [version, acceptedAt]
          properties:
            version:
              type: string
              pattern: "^[A-Za-z0-9._-]{1,64}$"
            acceptedAt:
              type: string
              format: date-time
        users:
          type: [integer, "null"]
          minimum: 0
          description: |
            The number of enabled accounts in the tenant's realm. `null`
            when they could not be counted, which is not the same as none.
        apps:
          type: array
          description: |
            The tenant's apps that were installed at a stated digest, sorted
            by `digest`. An app that arrived with the platform, or was
            installed with no build named, is not listed. May be empty.
          items:
            $ref: "#/components/schemas/App"
    App:
      type: object
      required: [coordinate, digest, users, addons]
      properties:
        coordinate:
          type: [string, "null"]
          description: |
            `<catalogue>/<app>`. `null` when the install did not record
            which catalogue its build was fetched from; `digest` still says
            which build it is.
        digest:
          type: string
          description: The content digest the install is pinned to.
          pattern: "^sha256:[0-9a-f]{64}$"
        users:
          type: [integer, "null"]
          minimum: 0
          description: The number of people entitled to the app — the members of its group. `null` when they could not be counted.
        addons:
          type: array
          description: |
            The app's add-ons that are pinned to a digest, sorted by
            `digest`. An add-on activated with no build named is not listed.
            A cluster pins an add-on only inside an app that is itself
            pinned, so a pinned add-on has a listed app to appear under;
            a cluster whose configuration was written before that rule may
            still hold one inside an app that is not listed, and such an
            add-on is still not reported. Always sent; empty for an app
            with none.
          items:
            $ref: "#/components/schemas/Addon"
    Addon:
      type: object
      required: [coordinate, digest, users]
      properties:
        coordinate:
          type: [string, "null"]
          description: |
            `<catalogue>/<add-on>`. `null` when the pin did not record which
            catalogue its build was fetched from; `digest` still says which
            build it is.
        digest:
          type: string
          description: The content digest the add-on is pinned to.
          pattern: "^sha256:[0-9a-f]{64}$"
        users:
          type: [integer, "null"]
          minimum: 0
          description: The number of people entitled to the add-on — the members of its own group. `null` when they could not be counted.
