openapi: 3.0.3
info:
  title: Editor Backend API
  description: |
    CRUD-Grundgeruest fuer Dokumente.

    Vorbereitete Erweiterungen (noch nicht aktiv):
    - Autorisierung: securitySchemes.bearerAuth ist definiert, wird aber noch
      auf keine Operation angewendet.
    - Live-Editing: Das Feld `version` dient spaeter der Konflikterkennung
      (Optimistic Locking) und als Basis fuer Delta-Updates via WebSocket.
    - Clientseitige Verschluesselung: `content` ist ein opaker String. Der
      Server interpretiert den Inhalt nicht, sodass spaeter Ciphertext
      transportiert werden kann, ohne die API zu aendern.
  version: 0.1.0

servers:
  - url: /api/v1

tags:
  - name: Documents
    description: Verwaltung von Dokumenten

paths:
  /documents:
    get:
      tags: [Documents]
      operationId: listDocuments
      summary: Alle Dokumente auflisten
      description: >
        Verlangt das Master-Passwort (HTTP Basic, Benutzer `werkbaum`).
        Ohne diesen Schutz waere jede Dokument-UUID auflistbar - und das
        Zugriffsmodell "unerratbare UUID" damit hinfaellig. Nach mehreren
        Fehlversuchen wird der Endpunkt fuer eine Weile gesperrt (429).
      security:
        - masterPassword: []
      responses:
        "200":
          description: Liste aller Dokumente
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Document"
        "401":
          description: Master-Passwort fehlt oder ist falsch
        "429":
          description: Zu viele Fehlversuche; `Retry-After` nennt die Restdauer
    post:
      tags: [Documents]
      operationId: createDocument
      summary: Neues Dokument anlegen
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DocumentCreateRequest"
      responses:
        "201":
          description: Dokument wurde angelegt
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Document"
        "400":
          $ref: "#/components/responses/BadRequest"

  /documents/{documentId}:
    parameters:
      - name: documentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [Documents]
      operationId: getDocument
      summary: Einzelnes Dokument abrufen
      responses:
        "200":
          description: Das angeforderte Dokument
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Document"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Documents]
      operationId: updateDocument
      summary: Dokument vollstaendig aktualisieren
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DocumentUpdateRequest"
      responses:
        "200":
          description: Aktualisiertes Dokument
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Document"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Versionskonflikt (fuer spaeteres Optimistic Locking reserviert)
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
    delete:
      tags: [Documents]
      operationId: deleteDocument
      summary: Dokument loeschen
      responses:
        "204":
          description: Dokument wurde geloescht
        "404":
          $ref: "#/components/responses/NotFound"

  /documents/{documentId}/history:
    parameters:
      - name: documentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [Documents]
      operationId: getDocumentHistory
      summary: Historie eines Dokuments abrufen
      description: >
        Liefert die nutzersichtbaren Staende (Meilensteine) in chronologischer
        Reihenfolge, dazu immer den juengsten Stand. Kurzlebige Sync-Versionen
        des Live-Editings bleiben aussen vor. Die Historie ueberlebt das
        Loeschen des Dokuments.
      responses:
        "200":
          description: Meilensteine des Dokuments (aelteste zuerst)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/DocumentHistoryEntry"
        "404":
          $ref: "#/components/responses/NotFound"

  /documents/{documentId}/restore:
    parameters:
      - name: documentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      tags: [Documents]
      operationId: restoreDocument
      summary: Geloeschtes Dokument aus der Historie wiederherstellen
      description: >
        Stellt ein geloeschtes Dokument unter derselben UUID wieder her.
        Ohne Request-Body wird der letzte Stand vor dem Loeschen
        wiederhergestellt; optional kann eine bestimmte Version angegeben
        werden (auch als Rollback fuer ein noch existierendes Dokument).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RestoreRequest"
      responses:
        "200":
          description: Wiederhergestelltes Dokument
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Document"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            Dokument existiert noch und es wurde keine Zielversion angegeben.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"

  /documents/{documentId}/content:
    parameters:
      - name: documentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      tags: [Documents]
      operationId: patchDocumentContent
      summary: Zeilen-Diff auf den Inhalt anwenden (Live-Editing)
      description: >
        Reicht eine Aenderung als Zeilen-Diff gegen eine Basisversion ein.


        Ist die Basis veraltet, ueberschneiden sich die Operationen aber nicht
        mit den zwischenzeitlichen, verschiebt der Server sie selbst und
        akzeptiert (200); die fremden Operationen stehen dann in
        `opsSinceBase`, damit der Client seine Schattenkopie nachzieht. Nur
        bei echter Ueberschneidung antwortet er mit 409.


        `clientId` und `seq` machen den Aufruf wiederholbar: Eine Wiederholung
        liefert das Ergebnis von damals, statt die Aenderung ein zweites Mal
        anzuwenden.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContentPatchRequest"
      responses:
        "200":
          description: Aenderung angenommen (ggf. serverseitig verschoben)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContentPatchResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            Echte Ueberschneidung mit einer zwischenzeitlichen Aenderung. Der
            Client entscheidet - fremde Fassung uebernehmen oder eigene
            durchsetzen -, ohne neu zu laden.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContentConflict"
        "422":
          description: >
            Diff nicht anwendbar: Pruefsummenfehler, Index ausserhalb, oder die
            Basisversion ist nicht mehr verfuegbar. Der Client laedt einmalig neu.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"

  /documents/{documentId}/title:
    parameters:
      - name: documentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      tags: [Documents]
      operationId: patchDocumentTitle
      summary: Dokument umbenennen (Live-Editing)
      description: >
        Aendert nur den Titel - er ist ein Metadatum, kein Zeileninhalt, und
        bekommt deshalb seinen eigenen Weg mit Versionspruefung (D76). Die
        Umbenennung erzeugt eine neue Version vom Typ RENAMED, die der
        Aenderungsfeed mit dem neuen Titel im Klartext zustellt - alle sehen
        denselben Namen.


        Verwaltungs-Aktion: Sie wird kuenftig an das geplante Owner-Passwort
        gebunden (die Bindung kommt als Berechtigungspruefung dazu, die
        Signatur bleibt).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TitlePatchRequest"
      responses:
        "200":
          description: Umbenanntes Dokument
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Document"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            Versionskonflikt - `expectedVersion` ist nicht mehr die aktuelle
            Version. Der Client holt das Dokument frisch und versucht es mit
            dessen Version erneut.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"

  /documents/{documentId}/changes:
    parameters:
      - name: documentId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      tags: [Documents]
      operationId: getDocumentChanges
      summary: Aenderungsfeed (Long Polling)
      description: >
        Liefert alles, was seit `since` geschehen ist. Gibt es nichts, haelt
        der Server die Anfrage bis zu `wait` Sekunden offen und antwortet
        sofort, sobald eine Aenderung eintrifft; sonst 204.


        Der Feed arbeitet auf der **Historie**, nicht am Dokument: Ein
        geloeschtes Dokument muss sein DELETED-Ereignis noch zustellen koennen.
        404 gibt es deshalb nur bei gaenzlich unbekannter UUID.


        Ist `since` bereits verdichtet, kann kein exaktes Diff mehr geliefert
        werden - dann enthaelt die Antwort statt `ops` den **Volltext**, und
        `fromVersion` fehlt. Der Client ersetzt seinen Stand.
      parameters:
        - name: since
          in: query
          required: true
          description: Zuletzt gesehene Version; 0 fuer "noch nichts".
          schema:
            type: integer
            format: int64
            minimum: 0
        - name: wait
          in: query
          required: false
          description: >
            Wartezeit in Sekunden. Serverseitig geklemmt - ein Client darf
            keine beliebig lange Verbindung binden.
          schema:
            type: integer
            format: int32
            minimum: 0
            default: 25
      responses:
        "200":
          description: Aenderungen seit `since`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChangeFeed"
        "204":
          description: Nichts Neues innerhalb der Wartezeit
        "404":
          $ref: "#/components/responses/NotFound"

  /info:
    get:
      tags: [Documents]
      operationId: getInfo
      summary: Name und Version des Dienstes
      description: >
        Offen und ohne Nebenwirkung - gedacht als Lebendprobe fuer Deploy und
        Ueberwachung. Ohne diesen Endpunkt bliebe dafuer nur eine Anfrage nach
        einem Dokument, das es nicht gibt, und man muesste auf eine 404 hoffen:
        Ein erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein
        falsch konfigurierter Proxy liefert.
      responses:
        "200":
          description: Der Dienst laeuft
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ServiceInfo"

components:
  responses:
    NotFound:
      description: Ressource nicht gefunden
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
    BadRequest:
      description: Ungueltige Anfrage
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"

  schemas:
    Document:
      type: object
      required: [id, title, content, version, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
        title:
          type: string
          maxLength: 255
        content:
          type: string
          description: >
            Opaker Inhalt. Bei clientseitiger Verschluesselung enthaelt dieses
            Feld spaeter den Ciphertext.
        version:
          type: integer
          format: int64
          description: Wird bei jeder Aenderung inkrementiert (Basis fuer Live-Editing/Konflikterkennung).
        createdAt:
          type: string
          format: date-time
          readOnly: true
        updatedAt:
          type: string
          format: date-time
          readOnly: true

    DocumentCreateRequest:
      type: object
      required: [title, content]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        content:
          type: string

    DocumentUpdateRequest:
      type: object
      required: [title, content]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        content:
          type: string
        expectedVersion:
          type: integer
          format: int64
          description: Optional; wird spaeter fuer Optimistic Locking ausgewertet.

    DocumentHistoryEntry:
      description: >
        Ein Stand der nutzersichtbaren Historie. Sync-Versionen des
        Live-Editings erscheinen hier nicht - sie tragen das Protokoll, nicht
        die Erzaehlung.
      type: object
      required: [documentId, version, title, content, changeType, timestamp]
      properties:
        documentId:
          type: string
          format: uuid
        version:
          type: integer
          format: int64
        title:
          type: string
        content:
          type: string
        changeType:
          type: string
          description: >
            RESTORED heisst: ein geloeschtes Dokument ist wieder da.
            ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine
            aeltere Version.
          enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK]
        timestamp:
          type: string
          format: date-time
        clientId:
          type: string
          description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT).
        displayName:
          type: string
          description: >
            Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der
            Oberflaeche nicht wie ein Nachweis darstellen. Traegt das
            "geaendert von" der Historie (D86).

    TitlePatchRequest:
      type: object
      required: [title, expectedVersion]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        expectedVersion:
          type: integer
          format: int64
          description: >
            Die Version, auf der die Umbenennung aufsetzt - weicht sie von der
            aktuellen ab, antwortet der Server mit 409.

    RestoreRequest:
      type: object
      properties:
        version:
          type: integer
          format: int64
          description: >
            Optionale Zielversion. Ohne Angabe wird der letzte inhaltliche
            Stand vor dem Loeschen wiederhergestellt.

    LineOperation:
      description: >
        Eine Zeilen-Operation relativ zur Basisversion (0-basierter Index).
        Operationen sind aufsteigend sortiert und ueberschneiden sich nicht.
      type: object
      required: [op, index]
      properties:
        op:
          type: string
          enum: [replace, insert, delete]
        index:
          type: integer
          format: int32
          minimum: 0
        count:
          type: integer
          format: int32
          minimum: 0
          description: Zahl der betroffenen Basiszeilen; bei `insert` ohne Bedeutung.
        lines:
          type: array
          description: Einzusetzende Zeilen; bei `delete` ohne Bedeutung.
          items:
            type: string

    ContentPatchRequest:
      type: object
      required: [baseVersion, checksum, clientId, seq, ops]
      properties:
        baseVersion:
          type: integer
          format: int64
          description: Version, gegen die das Diff gebildet wurde.
        checksum:
          type: string
          description: >
            Pflicht. `sha256:<hex>` des vollstaendigen Basistexts mit
            LF-Zeilenenden. Die Versionsnummer bestaetigt nur, dass die Basis
            dieselbe Version ist, nicht dass beide Seiten sie gleich lesen.
        clientId:
          type: string
          maxLength: 64
          description: Zufaellige, pseudonyme Kennung des Clients.
        seq:
          type: integer
          format: int64
          description: Laufende Nummer dieses Clients; macht den Aufruf wiederholbar.
        displayName:
          type: string
          maxLength: 64
          description: >
            Selbstgewaehlter Anzeigename. Ohne Anmeldung ist er eine
            Behauptung und darf in der Oberflaeche nicht wie ein Nachweis
            aussehen.
        milestone:
          type: boolean
          default: false
          description: >
            `true` schreibt einen nutzersichtbaren Stand (Knopfdruck).
            Der getaktete Strom laesst das Feld weg.
        ops:
          type: array
          items:
            $ref: "#/components/schemas/LineOperation"

    ContentPatchResult:
      type: object
      required: [version, opsSinceBase]
      properties:
        version:
          type: integer
          format: int64
          description: Neue Version des Dokuments.
        opsSinceBase:
          type: array
          description: >
            Leer, wenn die Basis noch aktuell war. Sonst die fremden
            Operationen, um die der Server verschoben hat.
          items:
            $ref: "#/components/schemas/LineOperation"

    ContentConflict:
      type: object
      required: [currentVersion, opsSinceBase]
      properties:
        currentVersion:
          type: integer
          format: int64
        opsSinceBase:
          type: array
          description: Diff von der eingereichten Basis bis zur aktuellen Version.
          items:
            $ref: "#/components/schemas/LineOperation"

    ChangeEvent:
      description: Was an einer Version geschehen ist und wer sie eingereicht hat.
      type: object
      required: [version, changeType]
      properties:
        version:
          type: integer
          format: int64
        changeType:
          type: string
          enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK]
        clientId:
          type: string
          description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT).
        displayName:
          type: string
          description: >
            Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der
            Oberflaeche nicht wie ein Nachweis darstellen.
        title:
          type: string
          description: >
            Nur bei RENAMED - der neue Titel im Klartext, damit der Client
            ihn ohne weiteren Abruf uebernehmen kann (D76).

    ChangeFeed:
      type: object
      required: [currentVersion, events]
      properties:
        fromVersion:
          type: integer
          format: int64
          description: >
            Basis des mitgelieferten Diffs. Fehlt, wenn stattdessen `content`
            geliefert wird.
        currentVersion:
          type: integer
          format: int64
        ops:
          type: array
          description: Kumuliertes Diff von `fromVersion` bis `currentVersion`.
          items:
            $ref: "#/components/schemas/LineOperation"
        content:
          type: string
          description: >
            Volltext statt Diff - wenn `since` bereits verdichtet ist oder der
            Client noch gar nichts hat (`since=0`).
        events:
          type: array
          items:
            $ref: "#/components/schemas/ChangeEvent"

    ServiceInfo:
      type: object
      required: [name, version]
      properties:
        name:
          type: string
        version:
          type: string
        builtAt:
          type: string
          format: date-time
          description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE).

    ProblemDetail:
      type: object
      description: Fehlerformat nach RFC 9457 (Problem Details)
      properties:
        type:
          type: string
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string

  securitySchemes:
    masterPassword:
      type: http
      scheme: basic
      description: >
        Master-Passwort fuer die Dokumentenliste. Der Hash liegt serverseitig
        in einer Umgebungsvariable (`werkbaum.master-password.hash`); ist er
        nicht gesetzt, bleibt die Liste gesperrt.

    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Noch nicht aktiv. Wird bei Einfuehrung der Autorisierung auf die
        Operationen angewendet (security: - bearerAuth: []).
