# Moduldienste

> Code eines NXM-Moduls, der außerhalb von nexxus läuft. Wie nexxus einen Moduldienst aufruft, wie der Dienst mit einem kurzlebigen Token zurückruft und wie Sie einen Dienst bauen, betreiben und anbinden.

Deklarationen und Ansichten decken das meiste ab, was ein Modul tut. Manche Arbeit braucht aber echten Code: eine gesetzliche Frist mit den Feiertagen eines Bundeslands berechnen, reagieren, wenn sich ein Datensatz ändert, den Text eines eingegangenen PDFs lesen. Dieser Code läuft nie innerhalb von nexxus. Er läuft in einem **Moduldienst**, einem eigenen HTTP-Dienst, den das Paket anmeldet und den nexxus aufruft.

Die Regel dahinter ist einfach. Ein Paket kommt von außerhalb der Plattform, also bleibt sein Code auch außerhalb des Plattformprozesses. nexxus schickt dem Dienst, was er braucht, zusammen mit einem signierten Token, das eine Minute gilt. Der Dienst ruft über dieselbe Datensatz-API zurück wie alle anderen, mit genau den Rechten, denen die Administration bei der Installation zugestimmt hat. Er hält keine Passwörter und keine API-Schlüssel.

Ein echtes Beispiel ist der Fristendienst aus der [Fallstudie CuriaOS](/de/modules/curiaos/): Er berechnet Fristen, legt Vorfristen an und schlägt Fristen vor, die er in Gerichtsschreiben findet.

## Wann ein Modul einen Dienst braucht

Ein Dienst lohnt sich, wenn

- **eine Aktion Logik braucht**, die `update`, `create` und Vorlagen nicht abbilden, etwa eine Berechnung mit eigenen Regeln,
- **das Modul auf Änderungen reagieren muss**, die Menschen, Aktionen oder der KI-Assistent vornehmen, zum Beispiel um ein zusammenfassendes Feld aktuell zu halten,
- **die Arbeit eine Bibliothek braucht**, die eine Ansicht nicht laden kann, etwa zum Auslesen von PDF-Text,
- **die Arbeit im Hintergrund läuft**, ohne dass jemand auf der Seite ist.

Reicht eine Ansicht oder eine deklarative Aktion, brauchen Sie keinen Dienst.

## So läuft ein Aufruf ab

<Flow
  steps={[
    { icon: 'zap', title: 'Etwas passiert', text: 'Ein Datensatz ändert sich, die Installation wird installiert, aktualisiert oder aktiviert, oder jemand führt eine Dienst-Aktion aus.' },
    { icon: 'send', title: 'nexxus ruft den Dienst', text: 'POST an den Dienst mit einem signierten Aufruf-Token, gültig für 60 Sekunden.', tone: 'cyan' },
    { icon: 'shield-check', title: 'Der Dienst prüft', text: 'Er prüft das Token gegen die öffentlichen Schlüssel von nexxus.', tone: 'lavender' },
    { icon: 'key-round', title: 'Token-Tausch', text: 'Beim ersten Rückruf tauscht er das Token einmal gegen ein Zugriffstoken, gültig für 10 Minuten.', tone: 'lavender' },
    { icon: 'database', title: 'Der Dienst ruft zurück', text: 'Er liest und schreibt Datensätze über die Datensatz-API. nexxus prüft bei jeder Anfrage die Rechte.' },
  ]}
  caption="Das Aufruf-Token beweist, dass nexxus den Aufruf geschickt hat. Das Zugriffstoken lässt den Dienst als Dienstkonto der Installation handeln, nie mit mehr Rechten."
/>

Im Einzelnen, für einen Aufruf, der Datensätze liest und schreibt:

<Sequence
  actors={[
    { name: 'nexxus', icon: 'server' },
    { name: 'Moduldienst', icon: 'server-cog' },
  ]}
  steps={[
    { from: 0, to: 1, label: 'POST /events oder /actions/<operation>, Bearer-Aufruf-Token (60 s)' },
    { from: 1, to: 0, label: 'GET /api/module-runtime/.well-known/jwks.json (zwischengespeichert)' },
    { from: 1, to: 1, label: 'Signatur, typ, iss, aud, exp prüfen und ob trigger zum Rumpf passt' },
    { from: 1, to: 0, label: 'POST /api/module-runtime/token: Aufruf-Token einmal eintauschen' },
    { from: 0, to: 1, label: 'Zugriffstoken für das Dienstkonto (10 Min.)' },
    { from: 1, to: 0, label: 'GET, POST, PATCH /api/x/<modul>/<fachobjekt> mit dem Zugriffstoken' },
    { from: 0, to: 0, label: 'Dienstkonto, zugestimmte Scopes und, falls gesetzt, die Person prüfen' },
    { from: 1, to: 0, label: 'Antwort 2xx: Das Ereignis gilt als zugestellt, eine Aktion bekommt ihr Ergebnis' },
  ]}
  caption="Der Dienst hat keine eigenen Zugangsdaten. Jedes Token, das er nutzt, stammt aus diesem Aufruf und läuft nach Minuten ab."
/>

## Einen Dienst anmelden

Ein Dienst gehört zum Manifest. So meldet das Paket `curiaos.kanzlei` seinen Fristendienst an:

```json
"services": [
  {
    "key": "fristendienst",
    "label": { "de": "Fristendienst", "en": "Deadline service" },
    "description": { "de": "Berechnet Fristen ...", "en": "Computes deadlines ..." },
    "url": "http://localhost:8390/fristendienst",
    "events": ["frist.created", "frist.updated", "frist.deleted", "aktendokument.created"],
    "operations": ["berechnen", "eintragen"]
  }
]
```

| Feld | Typ | Pflicht | Bedeutung |
|---|---|---|---|
| `key` | Text | ja | Name des Dienstes im Paket, z. B. `fristendienst` |
| `label` | `{de, en, pl?}` | ja | Erscheint im Katalog und in Fehlermeldungen |
| `description` | `{de, en, pl?}` | nein | Was der Dienst tut |
| `url` | Text, `http(s)://` | ja | Standardadresse. Eine Instanz kann sie überschreiben (siehe unten) |
| `events` | Liste von Texten | nein | Datensatz-Ereignisse, die der Dienst erhält: `<fachobjekt>.created`, `.updated`, `.deleted` der Fachobjekte des eigenen Pakets. Von anderen Paketen hört ein Dienst nichts |
| `operations` | Liste von Texten | nein | Operationen, die Aktionen mit `type: "service"` aufrufen dürfen |

Eine Aktion ruft eine Operation so auf:

```json
{ "key": "frist-berechnen", "entity": "frist", "type": "service",
  "service": "fristendienst", "operation": "berechnen",
  "label": { "de": "Frist berechnen", "en": "Compute deadline" }, "aiTool": true }
```

Dauert die Operation länger, setzen Sie `"async": true`. nexxus antwortet der Person dann, sobald der Dienst den Aufruf angenommen hat, und zeigt „„&lt;Aktion&gt;“ läuft. Das Ergebnis erscheint, sobald der Dienst fertig ist.“

Welche Kerndaten außerhalb seines eigenen Moduls der Dienst anfassen darf, steht unter `scopes`, zum Beispiel `core:dmsDocument:read`. Jedem Scope und jedem Dienst samt Host stimmt die Administration beim Installieren oder Aktualisieren des Pakets zu. Die vollständige Liste der Scopes steht unter [Paketaufbau](/de/modules/structure/).

<Screenshot src={shot_nxm_41} url="/modules/pakete/curiaos.kanzlei" alt={"Ein Paket mit Moduldienst, wie der Katalog es zeigt"} caption={"Vor der Installation sieht die Administration den Dienst, seinen Host und die Ereignisse, die er erhält."} />

## Was nexxus schickt

Jeder Aufruf ist ein `POST` mit `Authorization: Bearer <Aufruf-Token>` und einem JSON-Rumpf. Jeder Rumpf enthält die empfangende Installation mit Version und Einstellungen. In `settings` stehen nur die Schlüssel, die das Manifest unter `settings` anmeldet. Jede Antwort mit Status 2xx gilt als Erfolg.

### Datensatz-Ereignisse

`POST <url>/events`, wenn ein Datensatz eines abonnierten Fachobjekts angelegt, geändert oder gelöscht wurde:

```json
{
  "id": 4711,
  "type": "frist.updated",
  "module": "curiaos.kanzlei",
  "entity": "frist",
  "recordId": 42,
  "data": { "titel": "Berufungsfrist", "faellig_am": "2026-11-16", "status": "offen" },
  "occurredAt": "2026-10-09T08:15:00Z",
  "installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } }
}
```

`module` ist das empfangende Paket selbst: Ein Dienst erfährt nur von Datensätzen seines eigenen Pakets. `type` ist immer `<fachobjekt>.<vorgang>` ohne Paketpräfix.

### Lebenszyklus-Ereignisse

`POST <url>/events`, wenn die Installation installiert, aktualisiert oder aktiviert wurde, ganz ohne Abonnement:

```json
{
  "id": 4712,
  "type": "installation.installed",
  "module": "curiaos.kanzlei",
  "installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } },
  "occurredAt": "2026-10-09T08:20:00Z"
}
```

Die Typen heißen `installation.installed`, `installation.upgraded` und `installation.activated`. Mit ihnen holt ein Dienst nach, was er getan hätte, wäre er von Anfang an dabei gewesen, etwa ein berechnetes Feld für alle vorhandenen Datensätze füllen. `previousVersion` ist bei Aktualisierungen gesetzt und dann, wenn eine entfernte Installation wieder installiert wird.

### Aktionen

`POST <url>/actions/<operation>`, wenn eine Person oder der KI-Assistent eine Aktion mit `type: "service"` ausführt:

```json
{
  "action": "frist-berechnen",
  "recordId": 42,
  "record": { "id": 42, "module": "curiaos.kanzlei", "entity": "frist", "data": { "titel": "Berufungsfrist" }, "version": 3 },
  "payload": { "ereignisdatum": "2026-10-01", "regel": "berufung" },
  "installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } }
}
```

Das JSON, mit dem Ihr Dienst antwortet, wird zum `result` der Aktion. nexxus wartet bis zu 30 Sekunden. Zum Ablehnen antworten Sie mit einem 4xx-Status und einem JSON-Objekt `{ "errorCode": "COMPOSE_STALE", "message": "...", "messages": { "de": "...", "en": "...", "pl": "..." } }`; jeder Schlüssel ist optional, schicken Sie aber mindestens einen Code oder einen Text. nexxus antwortet der Person mit 422 `SERVICE_REJECTED` und gibt Ihren Code und Status daneben als `serviceCode` und `serviceStatus` weiter, so kann eine Ansicht auf `COMPOSE_STALE` reagieren, ohne den Text zu lesen. Der Text steht in der Sprache der Person: mit `messages` Ihr eigener Text in dieser Sprache (sonst Englisch, sonst Deutsch) ohne Vorsatz; mit nur einer `message` setzt nexxus „Der Dienst „&lt;Label&gt;“ hat die Aktion abgelehnt:“ davor, auf Englisch “The service “&lt;Label&gt;” refused the action:”, auf Polnisch „Usługa „&lt;Label&gt;” odrzuciła akcję:“. Texte kommen als reiner Text an, je höchstens 300 Zeichen.

### Öffentliche Endpunkte

`POST <url>/portal/<key>`, wenn eine Besucherin oder ein Besucher des Kundenportals des Mandanten einen Endpunkt nutzt, den das Paket unter `portal.endpoints` mit diesem Dienst anmeldet. Wer das Portal besucht, ist anonym: Das Token trägt keinen `principal`, sein Auslöser ist `portal:<key>`, ein Token für einen Endpunkt ruft also keinen anderen auf. nexxus reicht nur die angemeldeten `params` weiter (`record` als Zahl, die übrigen so, wie sie kamen) und bei einem `POST` das `form`, das nexxus schon geprüft hat (Typen, Pflichtfelder, E-Mail-Adresse, Einwilligung, Honeypot, Ratenlimit); ein `GET` schickt `form: null`. Vorher prüft nexxus auch, dass `record` ein öffentlicher Datensatz ist. `locale` ist die Sprache der Portalseite, `de`, `en` oder `pl`. Auch ein `GET`-Endpunkt erreicht den Dienst als `POST`. nexxus wartet bis zu 10 Sekunden.

```json
{
  "endpoint": "quote",
  "method": "GET",
  "params": { "record": 12, "check_in": "2026-11-12", "check_out": "2026-11-16", "guests": "4" },
  "form": null,
  "locale": "en",
  "installation": { "id": 7, "version": "1.2.0", "settings": { "currency": "ZAR", "time_zone": "Africa/Johannesburg" } }
}
```

Prüfen Sie alles noch einmal: Der Aufruf kommt aus dem Internet. Das JSON einer 2xx-Antwort (höchstens 64 KB) geht unverändert an die Besucherin oder den Besucher, antworten Sie also nur mit dem, was die Seite zeigen darf. Eine Ablehnung, die dort gelesen werden soll, ist ein 400, 409 oder 422 mit `{ "errorCode": "...", "message": "...", "messages": { "de": "...", "en": "...", "pl": "..." }, "fieldErrors": { "<feld>": "..." } }` (`pl` optional), die Texte in der Sprache der Seite; nexxus reicht sie mit demselben Status weiter (als reinen Text, je höchstens 300 Zeichen). Nehmen Sie 422 für falsche Eingaben und 409, wenn der Stand es nicht zulässt, etwa weil die Nächte inzwischen belegt sind. Einen Datensatz, den die Seite nicht zeigt, lehnt nexxus schon vor dem Weiterreichen mit 404 ab. Jeder andere Status und keine Antwort in der Zeit erreichen die Besucherin oder den Besucher als allgemeiner Fehler. So beantwortet der Higgins-Dienst `availability`, `quote` und `booking_request` der Buchungsseite.

## Die Tokens

### Aufruf-Token

nexxus signiert jeden Aufruf mit ES256. Der Kopf trägt `kid` und `typ: nexxus-module-it`. Die öffentlichen Schlüssel liegen unter `GET <nexxus>/api/module-runtime/.well-known/jwks.json`, ohne Sitzung abrufbar.

| Claim | Bedeutung |
|---|---|
| `iss` | `nexxus` |
| `aud` | Der Modulschlüssel, z. B. `curiaos.kanzlei` |
| `sub` | `module:<modulschlüssel>` |
| `tenant` | ID des Mandanten |
| `inst` | ID der Installation |
| `ver` | Installierte Version des Moduls |
| `trigger` | Was den Aufruf ausgelöst hat: `event:<typ>`, `lifecycle:<typ>` oder `action:<schlüssel>` |
| `principal` | `{userId, name}` der Person, die die Aktion ausgeführt hat; fehlt bei Ereignissen |
| `jti`, `iat` | Token-ID und Ausstellungszeit |
| `exp` | `iat` plus 60 Sekunden |

Prüfen Sie `typ`, `iss`, `aud`, `sub` und `exp`, und prüfen Sie, dass `trigger` zum Aufruf passt: Ein Ereignis mit `type` `frist.updated` braucht `event:frist.updated`. So lässt sich ein Token für einen Aufruf nicht für einen anderen wiederverwenden. nexxus nimmt nur ES256 an und duldet 5 Sekunden Uhrenabweichung.

### Tausch gegen ein Zugriffstoken

Beim ersten Rückruf tauscht der Dienst das Aufruf-Token gegen ein Zugriffstoken ([RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)):

```http
POST /api/module-runtime/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<Aufruf-Token>
&subject_token_type=urn:ietf:params:oauth:token-type:jwt
```

```json
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 600,
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}
```

Dieselben Felder nimmt der Endpunkt auch als JSON an. Jedes Aufruf-Token lässt sich genau einmal tauschen, und nur solange die Installation aktiv ist. Fehler folgen RFC 6749: Status 400 mit `{error, error_description}`, `error` ist `invalid_grant`, `invalid_request` oder `unsupported_grant_type`.

| Claim des Zugriffstokens | Bedeutung |
|---|---|
| `typ` (Kopf) | `nexxus-module-at` |
| `aud` | `nexxus` |
| `sub` | ID des Dienstkontos der Installation |
| `act` | `{sub: "module:<modulschlüssel>"}` |
| `tenant`, `inst` | Mandant und Installation |
| `principal` | Aus dem Aufruf-Token übernommen, falls eine Person gehandelt hat |
| `scope` | Die zugestimmten Scopes, durch Leerzeichen getrennt |
| `exp` | Höchstens 10 Minuten nach Ausstellung |

nexxus nimmt das Zugriffstoken **nur** auf `/api/x/**` (Datensätze und Aktionen), `/api/module-runtime/lookup` und `/api/module-runtime/documents/**` an. Überall sonst antwortet es mit 401 und „Ein Modul-Token gilt nur für die Modul-Schnittstelle.“

## Was ein Dienst darf

Ein Dienst handelt als **Dienstkonto** seiner Installation. nexxus legt dieses Konto bei der Installation an, eines je Installation, mit einer Rolle, die alle Rechte des Moduls hält. Mit dem Konto kann sich niemand anmelden, und nexxus schaltet es ab, solange das Modul deaktiviert oder deinstalliert ist. Datensätze, die der Dienst schreibt, nennen das Konto als Ersteller. Es heißt in der Standardsprache des Mandanten, der Name des Moduls und dahinter „(Moduldienst)“ auf Deutsch, „(module service)“ auf Englisch oder „(usługa modułu)“ auf Polnisch, und nexxus benennt es bei jedem Installieren, Aktualisieren, Aktivieren und Wiederinstallieren neu. Seine Rolle heißt ebenso.

| Daten | Zugriff |
|---|---|
| Datensätze des eigenen Moduls | Lesen und schreiben, auch Felder mit `readonly` |
| Kerndaten (Partner, Benutzer, Dokumente, ISMS-Maßnahmen) | Nur im Rahmen der zugestimmten `core:`-Scopes |
| Dokumente (PDF-Inhalt) | Mit `core:dmsDocument:read`, und nur Dokumente, auf die ein Datensatz des eigenen Moduls verweist |

Hat eine Person die Aktion ausgeführt, trägt das Token sie als `principal`, und nexxus prüft zusätzlich die Rechte dieser Person. Beides muss erfüllt sein. Ereignis-Zustellungen tragen keine Person.

## Das Service-Kit

In den Quellen von nexxus liegt unter `module-services/` ein Kit für Moduldienste: ein Prozess mit Bun und TypeScript, der je Dienst eine Gruppe von Routen bedient. Das Kit prüft die Tokens, bindet sie an den Aufruf, tauscht sie beim ersten Rückruf und protokolliert jede Anfrage ohne Tokens und ohne Rumpf. Sie schreiben die Handler.

Ein Dienst ist eine `ModuleServiceDefinition`:

| Eigenschaft | Bedeutung |
|---|---|
| `name` | Ordnername und Pfadpräfix: `rueckgabe` bedient `POST /rueckgabe/events`, `POST /rueckgabe/actions/<operation>` und `POST /rueckgabe/portal/<key>` |
| `moduleKey` | Das Modul, zu dem der Dienst gehört; das `aud` des Tokens muss es nennen |
| `events` | Handler für Datensatz-Ereignisse |
| `lifecycle` | Handler für `installation.installed`, `.upgraded` und `.activated` |
| `actions` | Handler je Operation, z. B. `{ zaehlen }` |
| `portal` | Handler der öffentlichen Portal-Endpunkte je Schlüssel, z. B. `{ quote }`. Ein Handler darf mit einer eigenen `Response` antworten, um den Status einer Ablehnung zu wählen |

Jeder Handler bekommt `{claims, body, log, platform}`. `platform` ist ein `PlatformClient`, der mit dem Zugriffstoken zurückruft:

| Methode | Tut |
|---|---|
| `search(entity, {q?, ids?, filter?, sort?, from?, size?})` | Eine Seite Datensätze, `{items, total}`. `ids` beschränkt auf diese Datensätze, höchstens 200; mehr wirft einen Fehler, bevor etwas gesendet wird |
| `searchAll(entity, query?)` | Alle passenden Datensätze, Seite für Seite (200 je Seite, nach ID sortiert). Nimmt beliebig viele `ids` und fragt sie zu je 200 ab |
| `get(entity, id)` | Einen Datensatz |
| `create(entity, data)` | Legt einen Datensatz an |
| `patch(entity, id, data, version?)` | Ändert Felder; mit `version` antwortet ein neuerer Stand mit `CONFLICT` |
| `documentContent(documentId)` | Den Inhalt eines Dokuments, auf das verwiesen wird |

`entity` ist ein Fachobjekt des eigenen Moduls; ein anderes Paket erreicht das Kit nicht. Ein fehlgeschlagener Plattform-Aufruf wirft einen `PlatformError` mit `status`, `errorCode` und `message`. Mit `HttpError(status, errorCode, message)` antworten Sie selbst mit einem Fehler.

### Ein kleiner Dienst

Dieser Dienst gehört zum Paket `acme.leihgeraete` aus dem [Tutorial zum ersten Modul](/de/modules/first-module/). Er gibt jedem verliehenen Gerät ohne Rückgabedatum eines in 14 Tagen und bietet eine Operation, die überfällige Geräte zählt.

```ts

export function rueckgabedienst(): ModuleServiceDefinition {
  return {
    name: 'rueckgabe',
    moduleKey: 'acme.leihgeraete',

    async events({ body, platform, log }) {
      if (body.type !== 'geraet.updated') return { ok: true, ignoriert: body.type };
      // Ereignisse können spät oder doppelt kommen: den aktuellen Datensatz lesen statt body.data zu trauen.
      const geraet = await platform.get('geraet', body.recordId);
      if (geraet.data.status !== 'verliehen' || geraet.data.rueckgabe_am) return { ok: true };
      const faellig = new Date(Date.now() + 14 * 86_400_000).toISOString().slice(0, 10);
      await platform.patch('geraet', geraet.id, { rueckgabe_am: faellig }, geraet.version);
      log.info('rueckgabedatum gesetzt', { recordId: geraet.id });
      return { ok: true };
    },

    actions: {
      async zaehlen({ platform }) {
        const heute = new Date().toISOString().slice(0, 10);
        const { total } = await platform.search('geraet', {
          filter: { status: 'verliehen', rueckgabe_am: { to: heute } }, size: 1,
        });
        return { faellig: total };
      },
    },
  };
}
```

Nehmen Sie ihn in `src/main.ts` in die Liste der Dienste auf und melden Sie ihn im Manifest an:

```json
"services": [{
  "key": "rueckgabe",
  "label": { "de": "Rückgabedienst", "en": "Return service" },
  "url": "http://localhost:8390/rueckgabe",
  "events": ["geraet.updated"],
  "operations": ["zaehlen"]
}],
"actions": [{
  "key": "faellige-zaehlen", "entity": "geraet", "type": "service",
  "service": "rueckgabe", "operation": "zaehlen",
  "label": { "de": "Fällige zählen", "en": "Count due" }
}]
```

Sein eigenes `patch` kommt beim Dienst nicht als Ereignis an, er kann sich also nicht selbst auslösen.

### Starten

Das Kit braucht eine Umgebungsvariable und hält keine Geheimnisse:

| Variable | Standard | Bedeutung |
|---|---|---|
| `PLATFORM_URL` | Pflicht | Basis-URL des nexxus-Backends, wie der Dienst es erreicht, ohne Schrägstrich am Ende. Von dort holt er JWKS, Token-Tausch und Datensatz-API |
| `PORT` | `8390` | Port, auf dem der Dienst lauscht |

```bash
bun install
PLATFORM_URL=https://nexxus.example.com bun dev     # mit Neustart bei Änderungen
bun test
bun run typecheck
```

`GET /health` antwortet ohne Token mit `{"status": "UP", "services": [...]}`. Im Ordner liegt ein `Dockerfile`, das das Kit als Benutzer ohne Root-Rechte startet. Nur nexxus muss den Dienst erreichen, und der Dienst muss nexxus erreichen.

Das Kit ist kein Muss. Jeder HTTP-Dienst in jeder Sprache funktioniert, wenn er das Aufruf-Token wie oben beschrieben prüft, es tauscht und die Datensatz-API aufruft.

## Einen Dienst auf einer Instanz anbinden

Die `url` im Manifest ist nur ein Standard, und `localhost` ist selten der Ort, an dem der Dienst läuft. nexxus nimmt die erste brauchbare Adresse aus

1. der Betreiberkonfiguration der Instanz,
2. der Installationseinstellung `serviceUrls.<dienst>`,
3. der `url` aus dem Manifest.

Es zählen nur absolute `http`- und `https`-URLs; alles andere fällt auf die nächste Quelle durch.

Zeigt die `url` aus dem Manifest auf `localhost` oder `127.0.0.1` und setzt die Instanz `nexxus.modules.local-service-base-url`, ersetzt nexxus Schema, Host und Port durch diese Adresse; Pfad und Abfrage bleiben. Laufen alle Dienste einer Instanz hinter einer Adresse, etwa `http://module-services:8390`, braucht so nicht jeder Dienst eine eigene Zeile (Umgebungsvariable `NEXXUS_MODULES_LOCALSERVICEBASEURL`). Für die Zustimmung zählt der Host nach dem Ersetzen. Die Installationseinstellung bleibt, wie sie ist, auch wenn sie `localhost` nennt.

```yaml
nexxus:
  modules:
    service-urls:
      "[acme.leihgeraete.rueckgabe]": http://rueckgabe:8390/rueckgabe
```

Der Schlüssel ist `<modulschlüssel>.<dienstschlüssel>` in eckigen Klammern, weil Modulschlüssel einen Punkt enthalten. Als Umgebungsvariable lautet derselbe Schlüssel `NEXXUS_MODULES_SERVICEURLS_ACME_LEIHGERAETE_RUECKGABE`. Über die API setzt die Administration die Installationseinstellung mit `PATCH /api/module-installations/<schlüssel>/settings` und `{"settings": {"serviceUrls": {"rueckgabe": "https://..."}}}`.

Die Zustimmung gehört zum Host. Die Administration stimmt `service:<schlüssel>@<host>` zu (mit Port, wenn die URL einen nennt), im Installationsdialog zu lesen als „Dienst „&lt;Label&gt;“ unter &lt;host&gt; aufrufen und ihm Ereignisse schicken“. Zeigt ein Dienst auf einen anderen Host, braucht er deshalb eine neue Zustimmung. Setzt die Administration die URL über die Installationseinstellungen, gilt diese Änderung als Zustimmung. Eine Änderung des Hosts in der Betreiberkonfiguration wirkt genauso auf die Zustimmung: Solange sie nicht passt, verweigert nexxus den Dienst.

## Zustellung, Wiederholungen und die Karte der Zustellungen

nexxus schreibt jedes Ereignis in derselben Transaktion wie die Änderung in einen Ausgang. Kein Ereignis geht verloren, wenn eine Änderung gespeichert wird, und keines geht hinaus, wenn die Änderung zurückgerollt wurde.

- Alle 2 Sekunden verschickt nexxus die fälligen Ereignisse, mit einer Zeitgrenze von 10 Sekunden.
- Die Zustellung erfolgt **mindestens einmal und nicht in Reihenfolge**. Ein wiederholtes Ereignis kann nach einem späteren ankommen.
- Nach dem n-ten Fehlschlag wartet das Ereignis 10 s × 2<sup>n−1</sup> (10, 20, 40, 80, 160, 320, 640 Sekunden). Nach dem 8. Fehlschlag gilt es als fehlgeschlagen, etwa 21 Minuten nach dem ersten Versuch.
- Ist die Installation nicht mehr aktiv oder der Dienst nicht mehr zugestimmt, scheitert das Ereignis sofort mit „Installation nicht aktiv“ oder „Dienst „&lt;key&gt;“ ist nicht freigegeben“.
- Ein Dienst erhält keine Ereignisse zu seinen eigenen Schreibvorgängen. Folgearbeit muss er in derselben Bearbeitung erledigen.
- Bei einer Aktualisierung schickt jeder Migrationsschritt ein Ereignis je geändertem Datensatz, noch vor `installation.upgraded`. Mehr dazu unter [Daten und Aktualisierungen](/de/modules/data-and-upgrades/).
- Zugestellte und fehlgeschlagene Ereignisse bewahrt nexxus 30 Tage auf.

Schreiben Sie Handler also so, dass sie zweimal laufen dürfen: Erkennen Sie Wiederholungen an der `id` des Ereignisses, lesen Sie den aktuellen Datensatz, statt `data` zu vertrauen, und schreiben Sie nur Werte, die sich unterscheiden.

Die Zustellungen sieht die Administration auf der Paketseite unter <MenuPath to="Administration > Betrieb > Module" />, Reiter <Ui>Pakete</Ui>, in der Karte <Ui>Ereigniszustellungen</Ui>: die letzten 100 Ereignisse mit <Ui>Ereignis</Ui>, <Ui>Zustand</Ui> (<Ui>Wartet</Ui>, <Ui>Zugestellt</Ui> oder <Ui>Fehlgeschlagen</Ui>), der Zahl der Versuche, dem letzten Fehler und <Ui>Erzeugt</Ui>. <Ui>Aktualisieren</Ui> lädt die Liste neu, außerdem aktualisiert sich die Karte alle 5 Sekunden selbst.

## Wenn etwas schiefgeht

Diese Meldungen sieht eine Person, wenn eine Dienst-Aktion scheitert:

| Meldung | Ursache | Abhilfe |
|---|---|---|
| „Der Dienst „&lt;Label&gt;“ ist für diesen Mandanten nicht freigegeben. Eine Administratorin oder ein Administrator muss ihm unter System › Module zustimmen.“ | Die Zustimmung der Installation enthält den Dienst mit seinem aktuellen Host nicht | Die Administration stimmt erneut zu: Das Setzen der Dienst-URL in den Installationseinstellungen gilt als Zustimmung, und der Aktualisierungsdialog einer neuen Version fragt danach |
| „Der Dienst „&lt;Label&gt;“ hat die Aktion abgelehnt: &lt;message&gt;“, für Personen, die auf Englisch oder Polnisch arbeiten, in deren Sprache, oder der eigene Text des Dienstes aus `messages` | Der Dienst hat mit 4xx und einer `message` oder `messages` geantwortet | Lesen Sie die Meldung; sie ist die Begründung des Dienstes |
| „Der Dienst „&lt;Label&gt;“ hat die Aktion abgelehnt (&lt;Code&gt;).“ | Der Dienst hat mit 4xx und einem `errorCode` ohne Text geantwortet | Schicken Sie zum Code eine `message` oder `messages`, damit die Person den Grund erfährt |
| „Der Dienst „&lt;Label&gt;“ hat die Aktion abgelehnt (HTTP &lt;status&gt;).“ | Der Dienst hat mit 5xx geantwortet oder mit 4xx ohne Code und ohne Text | Beheben Sie den Fehler im Dienst oder begründen Sie eine Ablehnung mit JSON `{"errorCode": "...", "message": "..."}` |
| „Der Dienst „&lt;Label&gt;“ ist nicht erreichbar.“ oder „Der Dienst „&lt;Label&gt;“ wurde nicht erreicht.“ | Keine Verbindung oder keine Antwort innerhalb von 30 Sekunden | Prüfen Sie, ob der Dienst läuft und ob die wirksame URL ihn erreicht |
| „Der Dienst „&lt;Label&gt;“ hat keine lesbare Antwort geliefert.“ | Die Antwort war kein JSON | Antworten Sie mit JSON oder mit leerem Rumpf |

Bei Ereignissen hilft die Karte der Zustellungen: Der letzte Fehler jedes Ereignisses steht in der Spalte <Ui>Zustand</Ui>. Prüfen Sie, ob der Dienst `PLATFORM_URL` erreicht und ob seine Uhr richtig geht, denn beide Tokens werden gegen ihre Ablaufzeit geprüft. Prüft ein Dienst Tokens gegen die Schlüssel einer anderen nexxus-Instanz, antwortet er mit 401 und `kid unknown`: Sein `PLATFORM_URL` zeigt auf die falsche Plattform.

<Screenshot src={shot_nxm_42} url="/modules/pakete/curiaos.kanzlei" alt={"Ereigniszustellungen, die nach einem abgelehnten Token auf den nächsten Versuch warten"} caption={"Der letzte Fehler sagt, warum: Hier prüft der Dienst Tokens gegen die Schlüssel einer anderen Plattform."} />

## Konfiguration

| Schlüssel | Standard | Bedeutung |
|---|---|---|
| `nexxus.modules.service-urls."[<modul>.<dienst>]"` | keiner | Wo ein Moduldienst läuft |
| `nexxus.modules.local-service-base-url` | keiner | Ersetzt `localhost` und `127.0.0.1` in der `url` der Manifeste, etwa durch `http://module-services:8390` |
| `nexxus.modules.tokens.invocation-ttl-seconds` | `60` | Lebensdauer eines Aufruf-Tokens |
| `nexxus.modules.tokens.access-ttl-seconds` | `600` | Lebensdauer eines Zugriffstokens |
| `nexxus.modules.tokens.clock-skew-seconds` | `5` | Geduldete Uhrenabweichung |
| `nexxus.modules.events.dispatcher-enabled` | `true` | Nur auf Instanzen abschalten, die nicht zustellen sollen |
| `nexxus.modules.events.dispatch-interval-ms` | `2000` | Wie oft fällige Ereignisse hinausgehen |
| `nexxus.modules.events.batch-size` | `20` | Ereignisse je Durchgang |
| `nexxus.modules.events.max-attempts` | `8` | Versuche, bevor ein Ereignis als fehlgeschlagen gilt |
| `nexxus.modules.events.backoff-base-seconds` | `10` | Wartezeit nach dem n-ten Fehlschlag: Basis × 2<sup>n−1</sup> |
| `nexxus.modules.events.timeout-seconds` | `10` | Zeitgrenze je Zustellung |
| `nexxus.modules.events.retention-days` | `30` | Wie lange zugestellte und fehlgeschlagene Ereignisse bleiben |
| `nexxus.modules.actions.timeout-seconds` | `30` | Zeitgrenze einer Dienst-Aktion |

Die Signaturschlüssel für die Tokens erzeugt nexxus beim ersten Start und legt sie verschlüsselt in seiner Datenbank ab, gemeinsam für alle Instanzen der Plattform.

## Bekannte Grenzen

- **Ein Tausch, zehn Minuten.** Ein Aufruf-Token lässt sich einmal tauschen, und das Zugriffstoken lässt sich nicht verlängern. Ein Aufruf kann also höchstens 10 Minuten mit nexxus arbeiten.
- **Lange Handler gelten als gescheitert.** Ein Ereignis-Handler, der länger als 10 Sekunden braucht, zählt als fehlgeschlagener Versuch, auch wenn er später fertig wird. Halten Sie jeden Schreibvorgang idempotent, damit die Wiederholung die Arbeit erledigt vorfindet.
- **Eine Instanz des Kits.** Das Kit merkt sich zugestellte Ereignis-IDs im Speicher. Zwei parallel laufende Instanzen könnten dieselbe Arbeit doppelt tun.

## Wie es weitergeht

<CardGrid>
  <LinkCard title="Fallstudie: CuriaOS" description="Der Fristendienst: Fristen, Vorfristen und Vorschläge aus Gerichtsschreiben." href="/de/modules/curiaos/" />
  <LinkCard title="Daten und Aktualisierungen" description="Versionen, Migrationsschritte und die Ereignisse einer Aktualisierung." href="/de/modules/data-and-upgrades/" />
  <LinkCard title="Paketieren, installieren und aktualisieren" description="Ein Paket hochladen, zustimmen und Rollen zuweisen." href="/de/modules/publish/" />
</CardGrid>