--- source_sha: "1297bb7c3062" --- # Das Deployment selbst { #the-deployment-itself } Der größte Teil dieses Produkts handelt von Agents. Diese Seite handelt von dem, worin sie laufen. **Eine Installation, mit einem Namen, einer Marke, einer Regel darüber, wer beitreten darf, und einem Schalter, der sie schließt.** All das liegt in einer einzigen Datenbankzeile und wird unter `/admin/settings` von demjenigen bearbeitet, der `is_app_admin` hält — kein Redeploy, keine Umgebungsvariable, kein Rebuild. !!! info "Warum diese Autorität und nicht eine Berechtigung" Eine Berechtigung gilt innerhalb einer Organisation. Diese Zeile liegt in keiner. Es ist dieselbe Autorität, die Nutzer und Tenants bereits über die gesamte Installation hinweg verwaltet. Die Konsole gliedert die Einstellungen in Identität, Zugang und Konten, Mitteilungen, Sprache und Mobilgerät sowie Wartung. Beim Abschnittswechsel bleiben ungespeicherte Änderungen erhalten. Speichern übernimmt die bearbeiteten Felder aller Abschnitte; Änderungen verwerfen stellt deren gespeicherte Werte wieder her. Bilduploads und Zugangsdaten für mobile Push-Benachrichtigungen werden weiterhin separat gespeichert. ## Identität { #identity } | Feld | Wo es erscheint | |---|---| | Name | Die Seitenleiste, die Kopfzeile der Anmeldung, der Browser-Tab, die OpenGraph-Karte und jede E-Mail, die dieses Deployment versendet | | Tagline | Neben dem Namen im Tab-Titel und auf einem geteilten Link | | Beschreibung | Die Seitenbeschreibung und Link-Vorschauen | | Logo | Überall dort, wo der Name erscheint — der Markenlink, die Kopfzeile der Anmeldung, die rechtlichen Seiten | | Favicon | Der Browser-Tab | | Fußzeilentext | Unter dem Anmeldeformular | | Terms URL, Privacy URL | Jeder Link, der sonst die eingebauten `/legal/*`-Seiten anbietet | !!! note "Eine Null-Spalte bedeutet *den eingebauten Wert*, nicht *leer*" Wer als Betreiber ein Feld leert, fordert die Voreinstellung zurück und nicht eine Anmelde-Kopfzeile ohne Namen darin. Die API antwortet mit *Überschreibungen*, und jeder Renderer löst eine Null gegen seinen eigenen eingebauten Wert auf. Ein Betreiber, der die Seite nie geöffnet hat, hat überhaupt keine Zeile. Die Konsole löst eine Null gegen `APP_NAME` und `SITE` in `frontend/src/lib/` auf; das Backend löst sie gegen `settings.PROJECT_NAME` für die Mail auf, die es selbst versendet. Zwei Konstanten für einen Produktnamen können auseinanderlaufen, deshalb hält `backend/tests/test_deployment_settings.py` sie gleich. Der Test liest die `constants.ts` des Frontends und vergleicht sie mit dem Klassen-Default von `Settings.PROJECT_NAME` — dieselbe Abmachung, die `TestFrontendToolCatalog` mit dem Tool-Katalog eingeht. ### Die zwei Bilder { #the-two-images } Hochgeladen über `POST /api/v1/admin/settings/{logo,favicon}` und gespeichert wie jedes andere Bild dieser Plattform: die Bytes gehen in den konfigurierten Dateispeicher, der Schlüssel geht in eine Spalte. **Der Schlüssel wird niemals einem Request-Body entnommen** — wer ihn benennen könnte, könnte das öffentliche Logo dieses Deployments auf beliebige Inhalte des Speicher-Backends richten — und der gespeicherte Dateiname wird aus dem validierten Content-Type geprägt statt aus dem Namen des Uploads, denn diese Dateien werden von derselben Origin ausgeliefert, unter der die Seiten der App laufen, und `logo.html` ist dort ein Skript. JPEG, PNG, WebP und GIF, bis 2MB, was die eine Definition von „ein Bild, das diese Plattform annimmt" ist. !!! danger "SVG fehlt absichtlich" Es ist ein Dokument, das Skript tragen kann, und diese Dateien werden von derselben Origin ausgeliefert, unter der die Seiten der App laufen. ICO bringt nichts, was ein PNG-Favicon nicht auch bringt. Die Branding-Antwort trägt eine **Version**, keine URL. Die Adresse ist konstant (`GET /api/v1/branding/{logo,favicon}`) und die Bytes werden ein Jahr lang als `immutable` ausgeliefert; was ein Client also aus der Zeile braucht, ist, ob ein Bild existiert und wann es sich zuletzt geändert hat. Das daraus gebaute `?v=` ist der einzige Grund, weshalb ein Ersatz überhaupt jemals sichtbar wird. Eine URL wäre zudem eine, die jeder Client umschreiben müsste, denn in jedem echten Deployment liegt die API nicht auf derselben Origin wie die Seiten. ## Ein Deployment in einer konformen Umgebung { #a-deployment-inside-a-compliant-environment } `deploy/profiles/hipaa/` ist eine meinungsstarke Konfiguration für den Betrieb dort, wo HIPAAs technische Sicherungen gelten - ein Compose-Overlay, das ohne die Einstellungen, die es nicht vorbelegen kann, nicht startet, und eine kommentierte Env-Datei - plus `agenticos cmd doctor --profile hipaa`, das ein laufendes Deployment dagegen prüft und bei jeder unerfüllten Kontrolle ungleich null endet. Es ist ein Beleg, keine Zertifizierung, und beantwortet allein §164.312: die administrativen und physischen Sicherungen gehören der Betreiberin. Siehe [Das HIPAA-Profil](security.md#the-hipaa-profile-and-what-it-does-not-claim) für das Blatt und für den Satz dazu, wer die Business Associate ist. ## Security-Header { #security-headers } Jede Seite der Konsole trägt eine Content-Security-Policy und die üblichen Härtungs-Header, definiert in `frontend/src/lib/csp.ts` und `frontend/src/lib/security-headers.ts`, beide durch Tests abgesichert. Die Policy ist `default-src 'self'` mit einem `connect-src`, das genau diese Origin, `PUBLIC_API_URL` und `PUBLIC_WS_URL` benennt, einem `img-src`, das `data:` für die Markenglyphen und Avatare erlaubt, einem `frame-src`, das `'self'` und `blob:` für Dokumentvorschauen sowie die Inhaltsroute der Artifacts (`{ARTIFACT_ORIGIN or PUBLIC_API_URL}/api/v1/artifact-content/`) erlaubt, `object-src 'none'`, `base-uri 'self'` und `frame-ancestors 'none'`. Die Inhaltsroute wird über ihren Pfad benannt, nicht über ihre Origin, sodass ein Frame eine veröffentlichte Seite laden kann und sonst nichts, was die API ausliefert; siehe [Wie die Seite isoliert wird](artifacts.md#how-the-page-is-isolated). `script-src` enthält kein `'unsafe-inline'`. Die Middleware erzeugt pro Anfrage einen Nonce, schreibt `'nonce-…' 'strict-dynamic'` in die Direktive, und Next stempelt diesen Nonce auf seine eigenen Inline-Skripte — ein in die Seite eingeschleustes Skript hat also keinen Nonce und läuft nicht. `'unsafe-eval'` bleibt, weil Nexts Entwicklungslaufzeit es benötigt. Die Policy wird pro Request von der Middleware des Frontends gesetzt, weil die beiden öffentlichen URLs zur Laufzeit aus der Umgebung des Servers gelesen werden und ein zur Build-Zeit gesetzter Header nur `localhost` benennen könnte. Die übrigen Header sind Konstanten und werden von der Konfiguration von Next gesetzt. Ändern Sie die öffentlichen URLs, und die Policy folgt beim nächsten Request; nichts wird neu gebaut. Daneben stehen `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` und eine `Permissions-Policy`, die Kamera und Geolocation verweigert und das Mikrofon nur auf dieser Origin erlaubt, für die Spracherkennung im Chat. !!! warning "Ein Reverse Proxy darf keine eigenen Kopien davon ergänzen" Nginx, Traefik oder ein ALB vor der App reicht diese unverändert durch, statt eigene zu setzen. Zwei `Content-Security-Policy`-Header auf einer Antwort werden vom Browser zu ihrer Schnittmenge kombiniert, ein Proxy, der einen zweiten ergänzt — selbst einen laxeren — verschärft die Policy also nur zu etwas, das ein Panel blockiert, das niemand blockieren wollte; und bei einem zweiten `X-Frame-Options` sucht sich der Browser einen der beiden Werte aus. Die mitgelieferte `nginx/nginx.conf` setzt nur `Strict-Transport-Security`, das demjenigen gehört, der TLS terminiert; eine bestehende Proxy-Konfiguration, die die anderen ergänzt, sollte sie fallen lassen. ## Wer sich registrieren darf { #who-may-register } `signup_mode`, angewendet in `app/services/signup_policy.py` — der einen Stelle, und sie kontrolliert **jeden** Pfad, der ein Konto prägt: das Registrierungsformular und eine Anmeldung über einen Identitätsanbieter. Nichts an einem OAuth- oder OIDC-Callback sieht nach einer Registrierung aus, und ein Deployment mit Single Sign-on und geschlossenem Registrierungsformular wäre überhaupt nicht geschlossen, wenn dieser Zweig ungesichert wäre — deshalb fragt `get_or_create_oauth_user` dieselbe Richtlinie, bevor es das Konto anlegt. Eine abgewiesene SSO-Anmeldung landet wieder auf der Anmeldeseite und trägt den eigenen Satz der Richtlinie, denselben, den das Registrierungsformular zeigt. | Modus | Wirkung | |---|---| | `open` | Jeder darf sich registrieren. Die Voreinstellung, und das, was jedes Deployment vor diesem Feature war. | | `invite_only` | Nur eine Adresse, die irgendeine Organisation tatsächlich eingeladen hat. | | `closed` | Niemand registriert sich, auf keinem Weg — eine Einladung setzt das nicht außer Kraft. | Über alle drei hinweg schränkt eine nicht leere `allowed_email_domains` ein, wer sich überhaupt registrieren darf. **Eine Einladung setzt diese Liste außer Kraft** — jemand mit `members:invite` hat die Adresse absichtlich benannt, und eine Domain-Liste ist Deployment-Politik gegenüber Fremden und kein Veto gegen eine bewusste Handlung. `closed` wird durch nichts außer Kraft gesetzt, denn ein „geschlossen", das einige Registrierungen durchlässt, ist nicht geschlossen. Eine **Zuordnung einer Verzeichnisgruppe zählt als Einladung.** Eine erste Anmeldung über das Verzeichnis (LDAP oder Kerberos) oder über OIDC mit einem Gruppen-Claim, deren Gruppen auf die Zuordnung irgendeiner Organisation passen, wird von `invite_only` und von der Domain-Liste zugelassen: Wer die Zuordnung geschrieben hat, hielt `members:manage` und `roles:manage` und hat entschieden, dass jeder in dieser Gruppe dazugehört. `closed` weist sie trotzdem ab. Siehe [Zuordnungen von Verzeichnisgruppen](directory.md#directory-group-mappings). **`closed` heißt geschlossen, und es gibt keinen Pfad, auf dem ein Administrator ein Konto anlegt.** Absichtlich: ein Konto braucht ein Passwort, das sein Eigentümer gewählt hat, jemanden hinzuzufügen heißt also, die Registrierung *für ihn* zu öffnen — wofür `invite_only` da ist. Ein Modus, der einen Administrator Konten prägen ließe, wäre ein dritter Pfad, der eines prägt, und die beiden bereits vorhandenen sind der ganze Grund, weshalb `signup_policy` ein Modul ist und keine Prüfung innerhalb von `register`. Ein Deployment, das eine Person mehr aufnehmen muss, wechselt also zu `invite_only` und lädt sie ein. Drei weitere Dinge daran sind leicht falsch zu machen und waren es: **Der erste Nutzer wird immer zugelassen.** Eine frische Installation hat keine Konten. Das erste bestätigte Konto wird zum Deployment-Administrator (`is_app_admin`), bei Registrierung mit Passwort ebenso wie über OAuth. Eine Transaktionssperre gewährt diese Ausnahme nur einer gleichzeitigen Registrierung. Weitere müssen die Policy erfüllen. Wird die erste zurückgerollt, kann eine andere das Startrecht erhalten. **`invite_only` existiert, weil das Schließen der Registrierung sonst Einladungen kaputtmachen würde.** `InvitationService.accept` verlangt einen bestehenden, angemeldeten Nutzer, eine eingeladene Person muss sich also zuerst registrieren. Die Policy fragt `invitation_repo.first_pending_admitting`, was konstruktionsbedingt tenant-übergreifend ist — die Registrierung findet statt, bevor eine Organisation gewählt wird. Sicher bleibt das durch den Ort, an den die Antwort geht: die Policy verwandelt sie in eine boolesche Ablehnung, ein Fremder, der das Anmeldeformular sondiert, erfährt also, dass jemand die Adresse eingeladen hat, und nie, welche Organisation das war. **Wie eine Einladung erkannt wird, hängt davon ab, ob die Registrierung ihren Token mitbringt**, und die beiden Antworten decken unterschiedliche Formen ab: | Kommt an mit | Erkannt durch | Welche Formen sie zulässt | |---|---|---| | Einem Token (aufgelöst aus der vorgemerkten Einladung, nie am Anmelde-Body) | `invitation_admission.admits` | Jede lebende Einladung, die die Adresse zulässt — einschließlich eines Links, der **keine** Adresse einschränkt, was die Form ist, die sonst nichts sehen kann | | Ohne Token | `invitation_repo.first_pending_admitting` | Eine E-Mail-Einladung für diese Adresse oder ein Link, der auf ihre Domain eingegrenzt ist | Der Token ist der einzige verfügbare Nachweis für einen teilbaren Link, an dem weder eine Adresse noch eine Domain hängt. Eine Abfrage über die übermittelte Adresse kann einen solchen nicht erkennen, ihn *ohne* Besitznachweis zu honorieren würde einen einzelnen offenen Link irgendwo im Deployment also in offene Registrierung für das gesamte Internet verwandeln. Den Token zu halten ist dieser Nachweis. Ein Token, der nichts Lebendes benennt, fällt auf die Adressfrage zurück, statt abzulehnen: ein veralteter Link in einem Lesezeichen sollte eine ansonsten erlaubte Registrierung nicht in einen Fehler über etwas verwandeln, das die Person nicht beheben kann. **Eine Registrierung mit Token nimmt die Einladung nicht an.** Sie lässt das Konto zu und sonst nichts; der Beitritt zur Organisation ist weiterhin `InvitationService.accept`, das der Client aufruft, sobald er eine Session hat. Ein Token in einem unauthentifizierten Anmelde-Body, der auch noch Mitgliedschaft gewährte, wäre eine Mitgliedschaftszuteilung auf einer öffentlichen Route. Die Konsole trägt den Token nie über den Anmelde-Roundtrip. Eine eingeladene Person ohne Konto öffnet `/invitations/`; `AuthGuard` tauscht den Token serverseitig gegen einen undurchsichtigen Handle, den er in einem `httpOnly`-Cookie hält, das der Browser nicht lesen kann, und schickt sie dann zu `/login?returnTo=/invitations/pending?flow=…` — eine Landeseite ohne Credential darin, der Token steht also weder im `returnTo` noch im Browserverlauf noch im Session Storage. Schlägt der Tausch fehl — Server nicht erreichbar, ein Rate Limit — bleibt der Guard auf dem Einladungslink und bietet einen neuen Versuch an, statt ohne etwas Vorgemerktes zu gehen, denn der Link ist das einzige Credential, das die eingeladene Person hält. Der `flow` ist eine zufällige Id, die der Tausch pro Vormerkung prägt, und das Cookie ist nach ihr benannt. Er ist kein Credential: ohne das Cookie benennt er nichts. Er existiert, weil ein fester Cookie-Name ein einziger Platz ist — zwei Einladungslinks, im abgemeldeten Zustand nebeneinander geöffnet, haben einander überschrieben, und beide wartenden Tabs lösten danach den zweiten ein. Jeder Tab löst jetzt genau das Cookie ein, das sein eigener `flow` benennt. „Create an account" trägt diese credential-freie Landeseite weiter, und der Register-Proxy reicht den vorgemerkten Handle des benannten `flow` als Header weiter, die Zulassung bei der Registrierung hat den benötigten Token also weiterhin, ohne dass der Token je in einer URL oder im Body steht. Nach der Anmeldung löst die wartende Seite den Handle ein und nimmt an — dieselbe Form wie der Tausch des OAuth-Codes, ein undurchsichtiger, einmal verwendbarer, ablaufender Stellvertreter für ein Credential, damit das Credential nie auf einer URL mitreist. Das Cookie wird gelöscht, sobald das Einlösen gelaufen ist; ein 401, ein 429 oder ein Serverfehler lässt es stehen, denn der Handle ist womöglich noch ungenutzt und ein neuer Versuch braucht ihn. **Ein Link mit `max_uses` begrenzt Konten, nicht nur Beitritte.** `used_count` zählt Annahmen, und eine Annahme braucht eine Session — eine Obergrenze, die allein daran abgelesen wird, begrenzte also nichts, was eine Registrierung tat. Ein einzelner Einmal-Link, in einem Kanal gepostet, ließ so viele Konten zu, wie irgendjemand anlegen mochte, auf dem Deployment, das die Anmeldung gerade geschlossen hatte. Eine Nutzung wird deshalb zuerst für die sich registrierende Adresse **reserviert**: `reserved_emails` auf der Zeile, und `used_count + reserved_emails` ist das, was „aufgebraucht" heißt. Die Reservierung ist ein einzelnes bedingtes `UPDATE`, denn zwei Registrierungen, die um die letzte Nutzung wettlaufen, würden sonst beide denselben Zählerstand lesen. Das Annehmen nimmt die Adresse aus der Liste, während es den Zähler erhöht, was sie erhält — wer sich über einen Einmal-Link registriert hat, kann weiterhin beitreten. Eine Reservierung, die niemand annimmt, bleibt verbraucht (`max_uses` ist die Anzahl der Personen, die ein Link zulässt, und ein damit angelegtes Konto wurde zugelassen), und sie stirbt mit der Einladung. **Eine Anmeldung über einen Provider trägt die Einladung ebenfalls, als ihren Handle.** Die Provider-Anmeldung beginnt auf derselben Origin, unter `/api/oauth//login`, der vorgemerkte `httpOnly`-Handle kann also an den Origin-übergreifenden Sprung angehängt werden, den der Browser dann macht — `/oauth/google/login?invitation_handle=…`, das das Backend in den Token hineinschauen lässt, den es über den Roundtrip in der Session hält. Der Token steht nie in dieser URL. Ohne das lehnte `invite_only` den Google-Button genau für die Links ab, die einen Token brauchen — einen, der weder eine Adresse noch eine Domain einschränkt — während das Passwortformular daneben dieselbe Person annahm. **Eine Anmeldung über einen Provider ist ebenfalls eine Registrierung.** `get_or_create_oauth_user` ist der zweite Pfad, der ein Konto anlegt, und nichts an einem Google-Callback sieht nach einer Anmeldung aus — ein Deployment mit `closed` und einem Google-Button war also weit offen, bis beide kontrolliert wurden. Wer *bereits* ein Konto hat, wird nicht erneut kontrolliert: das Schließen der Registrierung schließt die Registrierung, und ein Mitglied aus einem Deployment auszusperren, dem es angehört, ist nicht das, was die Einstellung sagt. Das Anmeldeformular liest die Policy vom öffentlichen Branding-Endpunkt und nennt die Regel, **bevor** jemand tippt. Ein Formular, das eine Adresse annimmt und dann meldet, „diese E-Mail-Domain darf sich nicht registrieren", ist ein Formular, das lügt; der Besucher hat keine Möglichkeit zu wissen, dass die Regel existiert, und liest die Ablehnung als ein kaputtes Produkt. Auch deshalb werden die erlaubten Domains veröffentlicht: sie sind kein Geheimnis, und das Deployment läuft auf dem eigenen Host des Unternehmens. ## Wo ein neues Konto beginnt { #where-a-new-account-starts } Zwei Einstellungen neben dem Anmeldemodus entscheiden das, beide unter **Neue Konten** auf `/admin/settings`, und beide gelten für danach angelegte Konten — eine bestehende persönliche Organisation bleibt (#2156). | Einstellung | Standard | Wirkung | |---|---|---| | `personal_organizations` | an | Jedes neue Konto bekommt eine eigene Organisation, mit sich selbst als Owner. | | `default_organization_id` | keine | Jedes neue Konto tritt dieser Organisation bei, mit `default_organization_role`. | | `default_organization_role` | `member` | Jede Rolle außer `owner`; eine Einstellung, die jeden Neuen zum Owner machte, gäbe allen die Schlüssel der Firma. | Ein Deployment, das unabhängigen Personen offensteht, behält die Standards. Ein Deployment für eine Firma schaltet persönliche Organisationen ab und nennt die Firmenorganisation, damit ein Kollege dort beginnt, wo die Agents und das Wissen der Firma sind, und niemand Agents unter einem eigenen Budget, einer eigenen Freigaberegel und einem eigenen Audit-Log neben denen der Firma ausführt. Eine persönliche Organisation kann nicht die Standardorganisation sein, und das Löschen der Standardorganisation leert die Einstellung. **Eine Einladung in die Standardorganisation hat Vorrang.** Eine Einladung nennt ihre eigene Rolle, und eine Einladung in eine Organisation anzunehmen, der die Person schon angehört, wird abgelehnt — wer sich also mit einer Einladung in die Standardorganisation registriert oder für dessen Adresse eine aussteht, tritt ihr bei der Registrierung nicht bei. Der Beitritt erfolgt beim Annehmen, mit der Rolle der Einladung. **Nur die eigene Regel des Deployments lässt jemanden beitreten.** Ein Konto, das der Anmeldemodus selbst zulässt — ein offenes Deployment, eine Adresse unter einer erlaubten Domain — tritt der Standardorganisation bei. Eines, das nur durch die Einladung oder Verzeichniszuordnung einer anderen Organisation zugelassen wurde, nicht: Deren Manager haben für die Person bei ihrer eigenen Organisation gebürgt, und ein Beitritt zur Standardorganisation ließe sie Personen zu einer Organisation hinzufügen, die sie nicht verwalten. **Die Standardorganisation ist auch, wo eine Sitzung landet.** Eine Anfrage mit Sitzungs-Token und ohne `X-Organization-Id` handelt in der Standardorganisation, wenn die Person ihr angehört, dann in ihrer persönlichen, dann in ihrer einzigen. Wer eine Wahl hat und keinen Header sendet, erhält `400` mit dem Namen des Headers statt einer Vermutung — siehe [den Organisations-Header](api.md#the-organization-header). Die Konsole wählt dieselbe Organisation, wenn nichts anderes ausgewählt ist, und das Annehmen einer Einladung schaltet die Konsole auf die beigetretene Organisation um. ## Einen Tenant unter allen finden { #finding-one-tenant-among-all-of-them } `GET /admin/organizations` ist die einzige Oberfläche, die beantwortet, *welche Tenants existieren*, und sie ist aus genau dem Grund App-Admin-only, aus dem sie nützlich ist: sie ist konstruktionsbedingt tenant-übergreifend. Sie antwortet mit einer Seite von Organisationen samt der Anzahl ihrer Mitglieder und Agents und ihres frühesten Owners — wen man dazu fragt. Jedes Owner-Feld ist zugleich null, bei einer Organisation, deren letzter Owner gegangen ist, was ein Zustand ist, den nur der Deployment-Admin beheben kann und der ihm deshalb gezeigt werden muss. | Parameter | | |---|---| | `search` | Name, Slug oder die Adresse des Owners. Der Begriff ist Text, kein Muster — `100%` findet den so genannten Tenant und nicht alle | | `sort_by` | `name`, `slug`, `members`, `agents`, `created_at`. Alles andere ist ein 422 | | `sort_dir` | `asc` / `desc`, standardmäßig neueste zuerst | | `kind` | `personal`, `team` oder `all`. Jedes Konto bekommt bei der Anmeldung eine persönliche Organisation, sofern das Deployment das nicht abgeschaltet hat, auf den meisten Deployments machen sie also den Großteil der Liste aus | | `skip`, `limit` | Eine Serverseite, bis zu 100 | **All das geschieht in SQL, vor `OFFSET`/`LIMIT`**, und `total` zählt das, worauf eingegrenzt wurde, und nicht das Deployment. Das ist der Unterschied zwischen einer Sortierung und ihrem Anschein: eine Seite, die nach der Ankunft sortiert wird, behauptet eine Ordnung über die ganze Sammlung, die fünfzig Zeilen nicht liefern können, weshalb die Tenant-Liste des Admins gar keine Bedienelemente trug, solange die Route keine beantwortete (#921). Die Ordnung bricht Gleichstände über die Id, das Blättern über eine Spalte, in der Zeilen einen Wert teilen, listet jede von ihnen also genau einmal. Eine Spalte außerhalb der Menge wird namentlich abgelehnt, statt gegen nichts abgeglichen zu werden, aus den beiden Gründen, aus denen `GET /runs` eine ablehnt: eine leere Seite liest sich als *dieses Deployment hat keine Tenants*, und ein aus einem Query-String zusammengesetztes `ORDER BY` ist eine Angriffsfläche für Injection. ## Ein App-Admin kann das Deployment über die Konsole nicht aussperren { #an-app-admin-cannot-lock-the-deployment-out-through-the-console } !!! warning "Die selbstverschuldete Aussperrung, die das verhindert" Auf der Installation mit einem einzigen Admin, die `make platform-bootstrap` erzeugt, beendete ein versehentlicher Klick auf die eigene Zeile die Administration, bis jemand ein Terminal erreichte. Die Wiederherstellung ist `agenticos cmd create-app-admin ` aus einer Shell — die E-Mail-Adresse ist ein Pflichtargument. `is_active` wird beim nächsten Request durchgesetzt und `is_app_admin` ist das, was die Admin-Seiten lesen, ein App-Admin, der unter `/admin/users` auf **seiner eigenen** Zeile handelt, könnte sich also selbst abmelden, `/admin` verlieren oder das Konto löschen. `UserService.admin_update` und `admin_delete` lehnen die Selbstsperrung und die Selbstlöschung ab, und die Schublade bietet auf Ihrer eigenen Zeile weder Suspend noch Demote noch Impersonate an (Delete bleibt, sichtbar und abgelehnt, denn „warum kann ich mich nicht selbst löschen" hat eine Antwort, die es wert ist, gezeigt zu werden). Die API lehnt es auch ab, als man selbst zu handeln — niemand, der als niemand handelt, ist keine Impersonation. Das Deployment kann über die API überhaupt nicht ohne App-Admin zurückbleiben: das eine globale Privileg wird nur per CLI vergeben (`agenticos cmd create-app-admin`) und es gibt keinen Request, der es entfernt, die Menge der App-Admins schrumpft also nur durch Löschung — und den *letzten* zu löschen heißt, sich selbst zu löschen, was abgelehnt wird. Einen Admin zu entfernen, der tatsächlich geht, ist die Handlung eines anderen Admins, was zugleich den Audit-Trail lesbar hält. Die Wiederherstellung, falls sie je gebraucht wird, ist weiterhin `create-app-admin` aus einer Shell auf dem Deployment. Dieses Argument handelt von der *Menge*, und eine Zeit lang handelte der Code von einer Zeile. Zwei Admins, die einander löschten, löschten jeweils nicht sich selbst. Sie sperrten unterschiedliche Zielzeilen, gerieten nie in Konkurrenz und committeten beide — null App-Admins, wiederherstellbar nur durch Schreiben in die Datenbank (#1208). Eine Admin-Löschung nimmt deshalb `SELECT ... FOR UPDATE` über die Menge der App-Admins, nach Id geordnet, bevor sie entscheidet. Der zweite Request wartet, liest die Menge erneut, sobald der erste committet hat, und wird dafür abgelehnt, dass er sie leeren würde. Geordnet, denn zwei Requests, die dieselben Zeilen in unterschiedlicher Reihenfolge nehmen, sind ein Deadlock und keine Warteschlange. Und genommen bei jeder Nutzerlöschung statt nur bei der eines Admins: einen Nutzer zu löschen ist die Handlung eines Administrators und kein heißer Pfad, und eine totale Ordnung ist mehr wert als die Konkurrenz, die sie kostet. ## Als ein anderes Konto handeln { #acting-as-another-account } **Impersonate** unter `/admin/users` beginnt, aus Ihrem eigenen Browser heraus als diese Person zu handeln. Nichts wird irgendwohin kopiert: die Konsole tauscht das Access-Cookie der Session gegen eines, das das Ziel benennt, jede Seite rendert so, wie diese Person sie sähe, und ein Banner über die volle Breite sagt, wessen Konto das ist und wer wirklich handelt, mit der einen Schaltfläche, die es beendet. Eine Impersonation ist eine **Session**, kein bloßes Credential. Der Token benennt eine Zeile in `sessions` mit gesetztem `impersonator_user_id`, und die API lehnt ihn in dem Moment ab, in dem diese Zeile beendet ist oder abgelaufen ist oder der Administrator dahinter kein aktiver App-Admin mehr ist — sie endet also, wenn Sie **End impersonation** drücken, wenn die Person sich überall abmeldet oder ihr Passwort ändert, wenn die Stunde um ist, oder wenn der Administrator gesperrt, herabgestuft oder gelöscht wird, was auch immer zuerst eintritt. Sie kann nicht erneuert werden: das Fenster ist das des Access-Tokens selbst, und die Stunde ist die Obergrenze und keine verlängerbare Leihfrist. !!! note "Eine offene Chat-Unterhaltung endet mit ihr" Eine Chat-Unterhaltung läuft über einen WebSocket, der sich einmal authentifiziert, beim Handshake. Er führt diese Prüfung jetzt bei jeder Nachricht erneut aus, das Beenden einer Impersonation — oder das Sperren des Kontos — schließt also auch einen offenen Chat, statt nur den nächsten HTTP-Request abzulehnen, während der Socket weiter antwortet. !!! note "Die Geräteliste der Person selbst zeigt sie nicht" Eine Impersonation ist eine Zeile unter ihrer Id, die ein Administrator hält, und kein Gerät, von dem sie sich angemeldet hat — sie steht also nicht in ihrer Geräteliste, nicht in der Zahl offener Sessions in der Schublade und nicht in ihrem „zuletzt gesehen". Ob sie überhaupt informiert wird, ist die Einstellung unten, und eine Zeile in dieser Liste würde das für sie entscheiden. **Ob die Person informiert wird, ist eine Policy, gesetzt auf dieser Zeile.** `notify_impersonated_users` ist standardmäßig aus; eingeschaltet bekommt die Person eine einmalige E-Mail, wenn die Impersonation beginnt, mit dem Namen des Administrators. Der Audit-Trail hält die Impersonation ohnehin fest — sowohl den Beginn als auch das Ende, mit der Session, zu der sie gehören — was [Governance](governance.md#audit) beschreibt. ## Hinweise, und das Schließen des Deployments { #notices-and-closing-the-deployment } **Die Ankündigung** ist ein Satz in einem von drei Stilen, angemeldeten Nutzern über jeder Seite gezeigt, bis sie ihn ausblenden. Sie ist das eine Feld dieser Zeile, das *nicht* am öffentlichen Endpunkt hängt: eine Ankündigung ist ein Betreiber, der zu den Menschen spricht, die das Deployment nutzen — ein Wartungsfenster, wen man anpingt — sie hat also ihre eigene Route, `GET /api/v1/branding/notice`, hinter einer Session. Das Ausblenden hängt an der **Nachricht selbst**, im Speicher des Browsers. Ein Flag würde die nächste Ankündigung für alle unsichtbar machen, die die letzte ausgeblendet haben; der Zeitstempel der Einstellungszeile würde einen Hinweis wieder einblenden, sobald das Deployment umbenannt wird. Der Text ist das, was sich geändert hat, also ist der Text der Schlüssel. Ein Speicher, der sich weigert, gelesen oder geschrieben zu werden — ein Privatmodus, eine eingebettete Webview — bedeutet „nichts ausgeblendet" statt einer Exception: während des Renderns geworfen, würde sie das Dashboard für jeden angemeldeten Nutzer lahmlegen, und das Banner lässt sich weiterhin schließen, solange die Seite offen ist. **Der Wartungsmodus hält die API zu**, nicht nur die Konsole. `app/core/maintenance.py` ist eine reine ASGI-Middleware über den Routen, eine Seite, die jemand bereits offen hat, hört also auf zu funktionieren — was der ganze Unterschied zwischen einem Wartungsmodus und einem Banner ist. Seine Allow-List ist kurz und wird Eintrag für Eintrag getestet: - `/health*` — eine Readiness-Probe, die während eines Fensters fehlschlägt, ist ein Orchestrator, der den Container neu startet, in dem der Betreiber arbeitet. - `/api/v1/branding` — die geschlossene Seite muss sagen können, wie dieses Deployment heißt und warum es zu ist. - `/api/v1/auth/*` — ein Administrator muss sich anmelden können, **während** das Fenster offen ist. - `/api/v1/admin/*` — und dann den Schalter erreichen. - Die Docs und das OpenAPI-Schema, die keine Daten ausliefern. Alles andere ist ein 503 mit `Retry-After`. Es liest überhaupt keine Session — das hieße, einen Token oberhalb des Abhängigkeitsgraphen zu prüfen — das Erweitern des Pfads auf `/api/v1/admin/*` erweitert also nicht die Autorität: `CurrentAppAdmin` lehnt dort einen Nicht-Admin genau so ab wie immer. **Es fällt offen aus.** Ein Tor, das seinen eigenen Schalter nicht lesen kann — ein Redis-Aussetzer, eine nicht gelaufene Migration — lässt Verkehr durch, denn die Alternative verwandelt einen Infrastruktur-Schluckauf in einen totalen Ausfall, den niemand geplant hat. Das Urteil wird in dem Redis zwischengespeichert, das jeder Worker ohnehin teilt: geschrieben **nach dem Commit**, der Schalter wirkt also sofort und der Cache kann nie einen Zustand bewerben, den die Datenbank zurückgerollt hat — eifrig veröffentlicht ließ ein danach fehlgeschlagener Request ein deaktiviertes Fenster das Deployment für bis zu die TTL wieder öffnen. Es trägt zusätzlich eine TTL von 30 Sekunden, ein Schreibvorgang, der Redis nie erreichte, heilt sich also selbst, statt das Deployment durch ein von jemandem geplantes Fenster hindurch offen zu lassen. **Und eine bereits offene Seite erfährt davon.** Der Branding-Kontext wird einmal vom Root-Server-Layout aufgelöst und ändert sich für die Lebensdauer einer Seite nie, ein danach geöffnetes Fenster ließ also jeden offenen Tab auf einem Dashboard zurück, dessen sämtliche Requests mit 503 zu antworten begannen, ohne dass etwas auf dem Bildschirm sagte, warum — und das Schließen eines Fensters ließ einen Tab auf dem Wartungsbildschirm zurück, bis jemand neu lud. `GET /api/v1/branding/notice` trägt das Wartungsurteil neben der Ankündigung und wird einmal pro Minute abgefragt, was ein Request für beide Antworten ist statt zwei, die über eine Zeile uneins sein können. In der Konsole sieht der Administrator einen Streifen statt der geschlossenen Seite. Er ist die einzige Person, die das Fenster beenden kann, und ein Wartungsmodus, der auch den Schalter verbirgt, ist ein Ausfall. ## Wie viel ein Konto belegen darf { #how-much-one-account-may-take-up } Zwei Obergrenzen, beide auf derselben Zeile und beide **standardmäßig null — und null heißt keine Grenze statt „nicht konfiguriert"**. Ein selbst gehostetes Deployment für ein Unternehmen will keine von beiden; ein für Anmeldungen offenes Deployment will beide, denn ein Konto kann sonst unbegrenzt Tenants prägen. | Einstellung | Zählt | Zählt nicht | |---|---|---| | Organisationen pro Konto | Die Organisationen, die ein Konto **besitzt**, die persönliche eingeschlossen | Solche, in die jemand anderes es eingeladen hat | | Agents pro Organisation | Agents, die die Organisation hält | Archivierte Agents | **Jeder Übergang in den gezählten Zustand wird geprüft, nicht nur ein Anlegen.** Eine Obergrenze, die allein auf neuen Zeilen durchgesetzt wird, ist eine, an der man seitlich vorbeigeht: eine Organisation an ihrem Agent-Limit archiviert einen, legt einen Ersatz an und stellt den archivierten wieder her, und einem Konto an seinem Organisationslimit wird über `transfer_ownership` die eines anderen überreicht. `unarchive` und `transfer_ownership` stellen deshalb dieselbe Frage wie `create`. **Und die Zählung erfolgt unter einer Sperre.** `count(...) >= limit` zu lesen und dann zu schreiben sind zwei Statements, zwei Requests bestehen also beide die Zählung und fügen beide ein — die Obergrenze deterministisch überschritten, durch zweimaliges Klicken. Kein Constraint kann „höchstens N solche Zeilen" ausdrücken, `app/db/locks.py` nimmt deshalb eine transaktionsbezogene Advisory Lock auf den *Gegenstand* der Obergrenze: zwei Requests zu einem Konto stellen sich an, Requests zu unterschiedlichen Konten begegnen einander nie, und die Sperre wird vom Commit oder vom Rollback freigegeben. Nur dort, wo ein Limit gesetzt ist, ein ungedeckeltes Deployment zahlt also nichts. Beide Ausschlüsse sind der Sinn des Entwurfs und nicht Details daran. In zehn Organisationen eingeladen zu werden ist die Entscheidung von jemand anderem, und eine Obergrenze, die eine Person nicht steuern kann, ist eine Obergrenze, die sie davon aussperrt, eigene anzulegen. Und das Archivieren ist die Art, wie ein Agent in Rente geht — eine Obergrenze, die ein Agent in Rente weiterhin belegte, machte den einzigen Weg zurück unter sie zu einer Löschung, die die Versionshistorie und die Zuordnung der Runs mitnimmt. Die Ablehnung benennt die Obergrenze und den Zählerstand, gegen den sie gemessen wurde (`{"limit": 5, "held": 5}`), „warum kann ich nicht" wird also von der Antwort beantwortet und nicht vom Gedächtnis eines Administrators. Sie wird in dem Service ausgelöst, der die Sache anlegt, und nicht an der Route, denn die Route ist nicht der einzige Weg hinein. Die Konsole wartet nicht auf diese Ablehnung. `GET /agents/ceiling`, offen für einen Aufrufer mit `agents:edit`, liefert das Limit (`limit`, null, wo keines gesetzt ist) und ob eine Erstellung daran scheitern würde (`at_limit`), und am Limit deaktiviert die Seite Agenten **Neuer Agent (New agent)** und das Installieren von Vorlagen und sagt, warum, bevor jemand ein Formular ausfüllt. Wenn eine andere Erstellung während der geführten Agent-Erstellung das Limit erreicht, endet die Anleitung, statt die Seite um einen deaktivierten Button zu sperren. Ein erfolgreich in dieser Anleitung erstellter Agent wird vor der Aktualisierung des Limits erfasst. Der letzte freie Platz unterbricht deshalb nicht die weiteren Schritte im Builder. Die Zählung selbst (`held`) erhält nur ein Aufrufer, dessen `agents:view` jeden Agenten der Organisation erreicht: Sie umfasst Agenten, die ein Mitglied (Member) nicht sieht, und neben seiner eigenen Liste würde sie verraten, wie viele vor ihm verborgen sind. **Duplizieren (Duplicate)** und **Wiederherstellen (Restore)** werden weiterhin erst beim Versuch abgelehnt. Die Garantie bleibt die Ablehnung: Die Zählung ist eine Vorhersage, gelesen ohne die Sperre. Null wird vom Schema abgelehnt: ein Konto, das keine Organisation besitzen darf, ist ein Konto, das nicht angelegt werden kann, denn die Anmeldung gibt jedem von ihnen eine persönliche Organisation. ## In welchem Format ein Diktat hochgeladen werden darf { #what-a-dictation-may-be-uploaded-as } `dictation_compressed_audio`, **standardmäßig aus**, entscheidet über das Format jedes [Diktats](ml-services.md#dictation-in-chat) im Deployment — im Eingabefeld von `/chat`, auf gehosteten Seiten und in Widgets, in jeder Organisation. Aus nimmt der Server ein Format an: WAV mit 16 kHz, Mono und 16-Bit-PCM, das jeder Browser aufnimmt. Seine Länge ist seine Größe, 32.000 Bytes pro Sekunde, der Server zählt also die Samples, und das längste Diktat begrenzt exakt, was eine Aufnahme kostet. Ein werden zusätzlich WebM, Ogg und MP4 angenommen, und Uploads sind etwa achtmal kleiner. Ihre Länge wird aus dem eigenen Header der Datei gelesen, den eine von Hand gebaute Datei zu kurz angeben kann: Ein Diktat kann dann innerhalb der Größengrenze bis zu etwa vierzigmal das längste Diktat tragen. Auf einer [öffentlichen Seite oder in einem Widget](channels.md#what-it-costs-and-what-bounds-it) kann jeder, der den Link hat, eines senden, abgerechnet über das Provider-Konto der Organisation und außerhalb jedes Budgets. Deshalb fragt der Schalter auf `/admin/settings` zuerst. Das Einschalten öffnet einen Dialog, der diese Risiken benennt, und aktiviert dessen Bestätigungsschaltfläche erst, wenn ein Kästchen angehakt ist, das bestätigt, dass sie gelesen wurden; das Ausschalten fragt nichts. In beiden Fällen wird er mit dem Rest des Formulars gespeichert und wie jedes andere Feld auditiert. Browser folgen ihm, sobald eine Seite das nächste Mal fragt: Das Format, in dem sie aufnehmen, ist das, das der Server ankündigt, nie eine im Client gepflegte Liste. ## Die Zeile { #the-row } Eine Zeile für die gesamte Installation, von der Datenbank bewacht statt von einer Konvention, die niemand sehen kann: `singleton` ist unique und auf wahr eingeschränkt, eine zweite Identität ist also ein `IntegrityError` statt eines Deployments, das stillschweigend zwei hat und diejenige ausliefert, die eine Abfrage zuerst sortiert hat. Der Schreibvorgang ist ein einzelnes `INSERT ... ON CONFLICT DO UPDATE`, denn ein Lesen-dann-Einfügen läuft gegen sich selbst, sobald zwei Administratoren aus zwei Tabs speichern. **Nichts wird vorbelegt.** Keine Zeile bedeutet lauter Voreinstellungen, was genau der Zustand eines Deployments ist, das niemand konfiguriert hat — und es ist wichtig, weil der öffentliche Branding-Endpunkt unauthentifiziert ist und bei jedem kalten Seitenaufruf erreicht wird, ein Read-Through, das eine Zeile anlegte, ließe einen Fremden also ein `INSERT` provozieren. Jeder Schreibvorgang wird in `app_admin_audit_logs` auditiert, wobei die **Felder** benannt werden und nie deren Werte: eine Ankündigung und eine Domain-Liste sind beide Text eines Betreibers, und eine Audit-Zeile überlebt den Request-Body, aus dem sie stammt. ## Eine Ablehnung dieses Deployments sieht immer gleich aus { #a-refusal-from-this-deployment-always-looks-the-same } Hier erwähnenswert, weil das Schließen eines Deployments das Feature ist, das am ehesten eine erzeugt, die eine Person noch nie gesehen hat. `app/api/exception_handlers.py` legt **jede** Ablehnung in `{"error": {"code", "message", "details"}}`: ```json { "error": { "code": "NOT_FOUND", "message": "Agent not found", "details": { "agent_id": "…" } } } ``` Das deckt Domain-Exceptions ab, Schema-Validierung und seit #917 auch `HTTPException`, was einen 405, einen nicht getroffenen Pfad und die zweiundzwanzig Routen abdeckt, die direkt eine auslösen. Zwei Formen auf der Leitung bedeuten, dass jeder Aufrufer entweder beide behandelt oder eine stillschweigend falsch behandelt. Ein Request mit falscher Methode antwortete früher mit **500** statt 405, auf jeder Route. Die FastAPI-Instrumentierung von OpenTelemetry leitet einen Span-Namen ab, indem sie `app.routes` durchläuft, und ihr `Match.PARTIAL`-Zweig — der genau „der Pfad passt und die Methode nicht" ist — liest `.path` ungesichert; FastAPI 0.141 legt `_IncludedRouter`-Objekte in diese Liste, und die haben keinen. `app/core/otel_compat.py` liefert für diesen Zweig denselben Fallback, den Upstream in dem Zweig bereits verwendet, den es abgesichert hat. Upstream ist das ab 0.65b0 weiterhin nicht behoben, und `tests/test_otel_route_details.py` schlägt fehl, wenn es behoben ist, was der Zeitpunkt ist, an dem das Modul verschwindet. ## Hintergrund-Jobs { #background-jobs } **Admin → Background jobs** (`/admin/background-jobs`) ist die Queue, wie der Eigentümer des Deployments sie sieht, und nur ein App-Admin kann sie öffnen: Die Queue gehört dem Deployment, der Eigentümer einer Organisation wird also abgewiesen. Der Kopf zählt die Jobs in jedem Zustand, nennt jede pausierte Art und den Grund, listet die lebenden Worker und den nächsten Termin jedes Zeitplans. Darunter stehen die Jobs, neueste zuerst, gefiltert nach Zustand und Pool, jeder mit seinen Versuchen und dem Fehler, mit dem er zuletzt endete. Unter den Zählern zeigt **Activity** als Diagramm, was in den letzten 24 Stunden oder 7 Tagen beendet wurde, gestapelt danach, wie es endete, daneben die längste Wartezeit eines jetzt fälligen Jobs. **By kind** nennt für jede Art die Menge, die Toten, die Laufzeit im Median und im 95. Perzentil und wie lange ihre Jobs auf einen Worker warteten: Eine wachsende Wartezeit sagt, dass einem Pool Worker fehlen. Das Dashboard des Deployments zeigt denselben Tag als Karte. Drei Aktionen, jede mit dem Handelnden auditiert: - **Retry** gibt einem toten Job eine neue Reihe von Versuchen. Ein toter Dokument-Ingest wird abgelehnt: seine Parse-Kopie ist mit ihm gegangen, also wiederholen Sie das Dokument aus seiner Wissensbasis, die die gespeicherte Datei erneut parst. - **Cancel** zieht einen Job zurück, den noch niemand begonnen hat. - **Resume** lässt die Worker eine pausierte Art wieder abholen. Eine Art pausiert der Schutzschalter des Workers: Ihre Jobs haben ihren Worker `WORKER_BREAKER_DEATHS`-mal in Folge umgebracht, meist ein Dokument, dessen Parsen den Speicher sprengt. Jeder Eigentümer erfährt es in der App und per E-Mail, und die Benachrichtigung lässt sich nicht abschalten - Arbeit dieser Art staut sich, bis einer von ihnen sie fortsetzt. Eine Pause nennt die Organisation, deren Jobs sie ausgelöst haben, und für alle anderen läuft die Art weiter. Versuchen Sie die toten Jobs erst erneut, wenn die Ursache behoben ist, sonst sterben sie auf dieselbe Weise - einen toten Dokument-Ingest stattdessen aus seiner Wissensbasis. Die Einstellungen stehen unter [Konfiguration](configuration.md#background-work). ## Zusammenfassung { #recap } - Die Identität des Deployments ist **eine Zeile**, bearbeitet unter `/admin/settings`, und eine Null-Spalte bedeutet *den eingebauten Wert* statt *leer*. - `signup_mode` wird an **einer Stelle** angewendet und kontrolliert beide Pfade, die ein Konto prägen. Eine Einladung setzt eine Domain-Liste außer Kraft; nichts setzt `closed` außer Kraft. - Ein neues Konto bekommt eine **persönliche Organisation, sofern das nicht abgeschaltet ist**, und tritt der **Standardorganisation** bei, wenn eine gesetzt ist; dort landet auch eine Sitzung, die keine Organisation nennt. - Ein App-Admin **kann sich nicht selbst aussperren** über die Konsole. - **Impersonation ist eine Session**: aus der Konsole gestartet, ohne Token in der Zwischenablage, in einem Banner benannt, beendet vom Administrator, davon, dass die Person sich überall abmeldet, oder von der Stunde. Ob die Person informiert wird, ist `notify_impersonated_users`, standardmäßig aus. - Diktieren ist **rohes PCM, es sei denn, `dictation_compressed_audio` ist an**, was ein Administrator erst nach einem Dialog einschaltet, der benennt, was es kostet. - Jede Ablehnung dieses Deployments sieht gleich aus, welche Schicht sie auch erzeugt hat.