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}/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, DELETED, RESTORED, ROLLED_BACK]
        timestamp:
          type: string
          format: date-time

    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, 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.

    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: []).
