--- source_sha: "1297bb7c3062" --- # Samo wdrożenie { #the-deployment-itself } Większość tego produktu dotyczy agentów. Ta strona dotyczy tego, w czym one działają. **Jedna instalacja, z nazwą, znakiem, regułą mówiącą, kto może do niej dołączyć, i przełącznikiem, który ją zamyka.** Wszystko to mieści się w jednym wierszu bazy danych i jest edytowane z `/admin/settings` przez tego, kto ma `is_app_admin` — bez ponownego wdrożenia, bez zmiennej środowiskowej, bez przebudowy. !!! info "Dlaczego ta władza, a nie uprawnienie" Uprawnienie jest ograniczone do organizacji. Ten wiersz nie należy do żadnej. To ta sama władza, która już administruje użytkownikami i tenantami w całej instalacji. Konsola dzieli ustawienia na Wygląd i nazwę, Dostęp i konta, Komunikaty, Głos i aplikację oraz Utrzymanie. Przełączanie sekcji zachowuje niezapisane zmiany. Zapis obejmuje edytowane pola ze wszystkich sekcji; Odrzuć zmiany przywraca ich zapisane wartości. Wysyłanie obrazów i dane uwierzytelniające powiadomień mobilnych nadal zapisują się niezależnie. ## Tożsamość { #identity } | Pole | Gdzie się pojawia | |---|---| | Name | Panel boczny, nagłówek logowania, karta przeglądarki, karta OpenGraph i każdy e-mail, który to wdrożenie wysyła | | Tagline | Obok nazwy w tytule karty i w udostępnionym linku | | Description | Opis strony i podglądy linków | | Logo | Wszędzie tam, gdzie pojawia się nazwa — link marki, nagłówek logowania, strony prawne | | Favicon | Karta przeglądarki | | Footer text | Pod formularzem logowania | | Terms URL, Privacy URL | Każdy link, który w przeciwnym razie prowadzi do wbudowanych stron `/legal/*` | !!! note "Kolumna null oznacza *wartość wbudowaną*, a nie *pustą*" Operator, który czyści pole, prosi o powrót wartości domyślnej, a nie o nagłówek logowania bez nazwy. API odpowiada *nadpisaniami*, a każdy renderer rozstrzyga null względem własnej wartości wbudowanej. Operator, który nigdy nie otworzył tej strony, nie ma w ogóle wiersza. Konsola rozstrzyga null względem `APP_NAME` i `SITE` w `frontend/src/lib/`; backend rozstrzyga go względem `settings.PROJECT_NAME` dla poczty, którą wysyła sam. Dwie stałe na jedną nazwę produktu mogą się rozjechać, więc `backend/tests/test_deployment_settings.py` przypina je do siebie. Czyta `constants.ts` z frontendu i porównuje go z domyślną wartością klasy `Settings.PROJECT_NAME` — to ta sama umowa, jaką `TestFrontendToolCatalog` zawiera z katalogiem narzędzi. ### Dwa obrazy { #the-two-images } Wgrywane przez `POST /api/v1/admin/settings/{logo,favicon}` i przechowywane tak jak każdy inny obraz na tej platformie: bajty trafiają do skonfigurowanego magazynu plików, a klucz do kolumny. **Klucz nigdy nie pochodzi z ciała żądania** — wywołujący, który mógłby go nazwać, mógłby wskazać publicznemu logo tego wdrożenia cokolwiek, co trzyma magazyn — a zapisana nazwa pliku powstaje ze zwalidowanego typu treści, a nie z nazwy samego przesłanego pliku, ponieważ te pliki są serwowane z tego samego origin, na którym działają strony aplikacji, a `logo.html` jest tam skryptem. JPEG, PNG, WebP i GIF, do 2 MB — to jedyna definicja „obrazu, który ta platforma przyjmuje”. !!! danger "SVG jest nieobecny celowo" To dokument, który może nieść skrypt, a te pliki są serwowane z tego samego origin, na którym działają strony aplikacji. ICO nie daje niczego, czego nie daje favicon w PNG. Odpowiedź brandingu niesie **wersję**, a nie URL. Adres jest stały (`GET /api/v1/branding/{logo,favicon}`), a bajty są serwowane jako `immutable` przez rok, więc z tego wiersza klient potrzebuje tylko tego, czy obraz istnieje i kiedy ostatnio się zmienił; zbudowane z tego `?v=` jest jedynym powodem, dla którego podmiana w ogóle się pojawia. URL byłby dodatkowo czymś, co każdy klient musiałby przepisywać, bo w każdym prawdziwym wdrożeniu API nie stoi na tym samym origin co strony. ## Wdrożenie wewnątrz zgodnego środowiska { #a-deployment-inside-a-compliant-environment } `deploy/profiles/hipaa/` to opiniotwórcza konfiguracja do uruchamiania tego tam, gdzie obowiązują techniczne zabezpieczenia HIPAA — overlay compose odmawiający startu bez ustawień, których nie może domyślnie przyjąć, i opisany plik env — plus `agenticos cmd doctor --profile hipaa`, który sprawdza działające wdrożenie względem niej i kończy się niezerowo przy każdej niespełnionej kontroli. To dowód, a nie certyfikacja, i odpowiada wyłącznie na §164.312: zabezpieczenia administracyjne i fizyczne należą do operatora. Zobacz [Profil HIPAA](security.md#the-hipaa-profile-and-what-it-does-not-claim), gdzie jest arkusz i zdanie o tym, kto jest business associate. ## Nagłówki bezpieczeństwa { #security-headers } Każda strona konsoli niesie Content-Security-Policy oraz zwyczajowe nagłówki utwardzające, zdefiniowane w `frontend/src/lib/csp.ts` i `frontend/src/lib/security-headers.ts`, jedne i drugie potwierdzone testami. Polityka to `default-src 'self'` z `connect-src` nazywającym dokładnie ten origin, `PUBLIC_API_URL` i `PUBLIC_WS_URL`, `img-src` dopuszczającym `data:` dla znaków marki i awatarów, `frame-src` dopuszczającym `'self'` i `blob:` dla podglądów dokumentów oraz trasę treści artefaktów (`{ARTIFACT_ORIGIN or PUBLIC_API_URL}/api/v1/artifact-content/`), `object-src 'none'`, `base-uri 'self'` oraz `frame-ancestors 'none'`. Trasa treści jest wskazana ścieżką, a nie originem, więc ramka może załadować opublikowaną stronę i nic innego, co serwuje API; zobacz [Jak strona jest izolowana](artifacts.md#how-the-page-is-isolated). `script-src` nie zawiera `'unsafe-inline'`. Middleware generuje nonce dla każdego żądania, wpisuje `'nonce-…' 'strict-dynamic'` do dyrektywy, a Next stempluje tym nonce swoje własne skrypty inline — więc skrypt wstrzyknięty na stronę nie ma nonce i się nie wykonuje. `'unsafe-eval'` pozostaje, bo wymaga go deweloperskie środowisko Next. Polityka jest stemplowana przy każdym żądaniu przez middleware frontendu, ponieważ oba publiczne adresy URL są czytane ze środowiska serwera w czasie działania, a nagłówek ustawiony w czasie budowania mógłby nazwać tylko `localhost`. Pozostałe nagłówki są stałymi i ustawia je konfiguracja Next. Zmień publiczne adresy URL, a polityka pójdzie za nimi przy następnym żądaniu; nic nie jest przebudowywane. Obok niej stoją `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` oraz `Permissions-Policy`, która odmawia kamery i geolokalizacji, a mikrofon dopuszcza wyłącznie na tym origin, na potrzeby zamiany mowy na tekst w czacie. !!! warning "Reverse proxy nie może dodawać własnych kopii tych nagłówków" Nginx, Traefik albo ALB stojący przed aplikacją przepuszcza je bez zmian, zamiast ustawiać własne. Dwa nagłówki `Content-Security-Policy` w jednej odpowiedzi przeglądarka łączy w ich część wspólną, więc proxy, które dodaje drugi — nawet luźniejszy — jedynie zacieśnia politykę do czegoś, co blokuje panel, którego nikt nie zamierzał blokować; a przy drugim `X-Frame-Options` przeglądarka może wybrać dowolną z wartości. Dołączony `nginx/nginx.conf` ustawia tylko `Strict-Transport-Security`, który należy do tego, co terminuje TLS; istniejąca konfiguracja proxy, która dodaje pozostałe, powinna je usunąć. ## Kto może się zarejestrować { #who-may-register } `signup_mode`, stosowany w `app/services/signup_policy.py` — w jednym miejscu, i bramkuje **każdą** ścieżkę tworzącą konto: formularz rejestracji i logowanie przez dostawcę tożsamości. Nic w callbacku OAuth ani OIDC nie wygląda jak rejestracja, a wdrożenie z logowaniem jednokrotnym i zamkniętym formularzem rejestracji wcale nie byłoby zamknięte, gdyby ta gałąź nie była bramkowana — więc `get_or_create_oauth_user` pyta tę samą politykę, zanim utworzy konto. Odrzucone logowanie SSO ląduje z powrotem na stronie logowania z własnym zdaniem polityki, tym samym, które pokazuje formularz rejestracji. | Tryb | Skutek | |---|---| | `open` | Każdy może się zarejestrować. Wartość domyślna i to, czym było każde wdrożenie przed tą funkcją. | | `invite_only` | Tylko adres, który jakaś organizacja faktycznie zaprosiła. | | `closed` | Nikt się nie rejestruje, żadną drogą — zaproszenie tego nie omija. | We wszystkich trzech trybach niepusta lista `allowed_email_domains` zawęża to, kto w ogóle może się zarejestrować. **Zaproszenie ma pierwszeństwo przed tą listą** — ktoś, kto ma `members:invite`, wskazał ten adres celowo, a lista domen jest polityką wdrożenia wobec obcych, a nie prawem weta wobec świadomej decyzji. `closed` nie jest omijane przez nic, bo „zamknięte”, które przepuszcza część rejestracji, nie jest zamknięte. **Mapowanie grupy katalogowej liczy się jako zaproszenie.** Pierwsze logowanie przez katalog (LDAP albo Kerberos) albo przez OIDC z claimem grup, którego grupy pasują do mapowania jakiejś organizacji, jest wpuszczane przez `invite_only` i przez listę domen: ktokolwiek napisał mapowanie, trzymał `members:manage` i `roles:manage` i zdecydował, że każdy w tej grupie tu należy. `closed` nadal go odrzuca. Zobacz [Mapowania grup katalogowych](directory.md#directory-group-mappings). **`closed` znaczy zamknięte i nie ma ścieżki, w której administrator tworzy konto.** Celowo: konto potrzebuje hasła wybranego przez właściciela, więc dodanie kogoś oznacza otwarcie rejestracji *dla niego* — i od tego jest `invite_only`. Tryb, który pozwalałby administratorowi wybijać konta, byłby trzecią ścieżką tworzącą konto, a te dwie, które już istnieją, są całym powodem, dla którego `signup_policy` jest modułem, a nie sprawdzeniem wewnątrz `register`. Wdrożenie, które musi wpuścić jeszcze jedną osobę, przełącza się więc na `invite_only` i ją zaprasza. Jeszcze trzy rzeczy, które łatwo tu pomylić — i które pomylono: **Pierwszy użytkownik jest zawsze przyjmowany.** Nowa instalacja nie ma kont. Pierwsze zatwierdzone konto zostaje administratorem wdrożenia (`is_app_admin`), niezależnie od rejestracji hasłem lub przez OAuth. Blokada transakcyjna przyznaje ten wyjątek tylko jednej równoczesnej rejestracji. Kolejne muszą spełniać politykę wdrożenia; jeśli pierwsza zostanie wycofana, inna może przejąć uprawnienia startowe. **`invite_only` istnieje dlatego, że zamknięcie rejestracji zepsułoby inaczej zaproszenia.** `InvitationService.accept` wymaga istniejącego, zalogowanego użytkownika, więc zaproszona osoba musi najpierw się zarejestrować. Polityka pyta `invitation_repo.first_pending_admitting`, które z konstrukcji sięga przez wszystkie tenanty — rejestracja dzieje się, zanim wybrana zostanie organizacja. Bezpieczne trzyma to miejsce, do którego trafia odpowiedź: polityka zamienia ją w logiczne odrzucenie, więc obcy sondujący formularz rejestracji dowiaduje się, że ktoś zaprosił ten adres, a nigdy tego, która organizacja to zrobiła. **To, jak rozpoznawane jest zaproszenie, zależy od tego, czy rejestracja niesie jego token**, a te dwie odpowiedzi obejmują różne kształty: | Przychodzi z | Rozpoznawane przez | Jakie kształty wpuszcza | |---|---|---| | Tokenem (rozstrzygniętym z przygotowanego zaproszenia, nigdy z ciała rejestracji) | `invitation_admission.admits` | Każde żywe zaproszenie, które wpuszcza ten adres — w tym link, który nie ogranicza **żadnego** adresu, czyli kształt, którego nic innego nie widzi | | Bez tokena | `invitation_repo.first_pending_admitting` | Zaproszenie e-mailowe na ten adres albo link ograniczony do jego domeny | Token jest jedynym dowodem dostępnym dla linku do udostępnienia, który nie niesie ani adresu, ani domeny. Zapytanie o przesłany adres nie rozpozna takiego linku, więc uhonorowanie go *bez* dowodu posiadania zamieniłoby jeden otwarty link gdziekolwiek w tym wdrożeniu w otwartą rejestrację dla całego internetu. Posiadanie tokena jest tym dowodem. Token, który nie wskazuje niczego żywego, przechodzi do pytania o adres, zamiast odrzucać: nieaktualny link w zakładkach nie powinien zamieniać rejestracji, która skądinąd byłaby dozwolona, w błąd dotyczący czegoś, czego ta osoba nie może naprawić. **Rejestracja z tokenem nie przyjmuje zaproszenia.** Wpuszcza konto i nic więcej; dołączenie do organizacji to nadal `InvitationService.accept`, które klient wywołuje, gdy ma już sesję. Token w nieuwierzytelnionym ciele rejestracji, który dodatkowo przyznawałby członkostwo, byłby przyznaniem członkostwa na publicznej trasie. Konsola nigdy nie przenosi tokena przez obieg logowania. Zaproszony bez konta otwiera `/invitations/`; `AuthGuard` wymienia token po stronie serwera na nieprzezroczysty uchwyt, który trzyma w ciasteczku `httpOnly`, nieczytelnym dla przeglądarki, a potem odsyła go na `/login?returnTo=/invitations/pending?flow=…` — stronę bez poświadczenia, więc tokena nie ma ani w `returnTo`, ani w historii przeglądarki, ani w session storage. Jeśli wymiana się nie uda — serwer nieosiągalny, limit żądań — guard zostaje przy linku z zaproszeniem i proponuje ponowną próbę, zamiast odejść bez niczego przygotowanego, bo ten link jest jedynym poświadczeniem, jakie zaproszony ma. `flow` to losowy identyfikator, który wymiana wybija przy każdym przygotowaniu, i to od niego nazywane jest ciasteczko. Nie jest poświadczeniem: bez ciasteczka nie wskazuje niczego. Jest po to, że jedna stała nazwa ciasteczka to jedno miejsce — dwa linki z zaproszeniem otwarte obok siebie bez zalogowania nadpisywały się nawzajem, a obie oczekujące karty realizowały potem ten drugi. Teraz każda karta realizuje dokładnie to ciasteczko, które wskazuje jej własny `flow`. „Create an account” niesie tę pozbawioną poświadczenia stronę dalej, a proxy rejestracji przekazuje przygotowany uchwyt wskazanego `flow` jako nagłówek, więc wpuszczenie przy rejestracji nadal ma token, którego potrzebuje, a token nigdy nie znajduje się w URL-u ani w ciele żądania. Po zalogowaniu strona oczekująca realizuje uchwyt i przyjmuje zaproszenie — ten sam kształt co wymiana kodu OAuth: nieprzezroczysty, jednorazowy, wygasający zamiennik poświadczenia, żeby samo poświadczenie nigdy nie jechało w URL-u. Ciasteczko jest czyszczone po wykonanej realizacji; 401, 429 albo awaria serwera je zostawia, bo uchwyt może być wciąż niewykorzystany, a ponowna próba go potrzebuje. **Link z `max_uses` ogranicza konta, a nie tylko dołączenia.** `used_count` liczy przyjęcia, a przyjęcie wymaga sesji — więc pułap odczytywany z niego samego nie ograniczał niczego, co robiła rejestracja. Jeden jednorazowy link wrzucony na kanał wpuszczał tyle kont, ile komukolwiek chciało się utworzyć, na wdrożeniu, które właśnie zamknęło rejestrację. Dlatego użycie jest najpierw **rezerwowane** dla rejestrującego się adresu: `reserved_emails` w wierszu, a „zużyte” znaczy `used_count + reserved_emails`. Rezerwacja to pojedynczy warunkowy `UPDATE`, bo dwie rejestracje ścigające się o ostatnie użycie odczytałyby w przeciwnym razie ten sam licznik. Przyjęcie zaproszenia usuwa adres z listy, zwiększając jednocześnie licznik, co go zachowuje — ktoś, kto zarejestrował się przez jednorazowy link, nadal może dołączyć. Rezerwacja, której nikt nie przyjmie, pozostaje zużyta (`max_uses` mówi, ile osób link wpuszcza, a konto utworzone przy jego użyciu zostało wpuszczone) i umiera razem z zaproszeniem. **Logowanie przez providera także niesie zaproszenie, w postaci jego uchwytu.** Logowanie przez providera zaczyna się na tym samym origin, pod `/api/oauth//login`, więc przygotowany uchwyt `httpOnly` da się dołączyć do międzyoriginowego skoku, który przeglądarka potem wykonuje — `/oauth/google/login?invitation_handle=…`, z którego backend wydobywa token trzymany przez siebie w sesji przez cały obieg. Tokena nigdy nie ma w tym URL-u. Bez tego `invite_only` odrzucał przycisk Google dokładnie dla tych linków, które potrzebują tokena — takich, które nie ograniczają ani adresu, ani domeny — podczas gdy stojący obok formularz z hasłem przyjmował tę samą osobę. **Logowanie przez providera też jest rejestracją.** `get_or_create_oauth_user` to druga ścieżka tworząca konto, a w callbacku od Google nic nie wygląda jak rejestracja — więc wdrożenie z `closed` i przyciskiem Google stało otworem, dopóki nie zabramkowano obu. Ktoś, kto *już* ma konto, nie jest bramkowany ponownie: zamknięcie rejestracji zamyka rejestrację, a odcięcie członka od wdrożenia, do którego należy, nie jest tym, co mówi to ustawienie. Formularz rejestracji czyta politykę z publicznego endpointu brandingu i podaje regułę, **zanim** ktokolwiek cokolwiek wpisze. Formularz, który przyjmuje adres, a potem zgłasza „ta domena e-mail nie może się zarejestrować”, to formularz, który kłamie; odwiedzający nie ma jak wiedzieć, że reguła istnieje, i czyta odrzucenie jako zepsuty produkt. To także powód, dla którego dozwolone domeny są publikowane: nie są tajemnicą, a wdrożenie stoi na własnym hoście firmy. ## Gdzie zaczyna nowe konto { #where-a-new-account-starts } Decydują o tym dwa ustawienia obok trybu rejestracji, oba w sekcji **Nowe konta** na `/admin/settings`, i oba dotyczą kont utworzonych później — istniejąca organizacja osobista zostaje (#2156). | Ustawienie | Domyślnie | Skutek | |---|---|---| | `personal_organizations` | włączone | Każde nowe konto dostaje własną organizację, z sobą jako Ownerem. | | `default_organization_id` | brak | Każde nowe konto dołącza do tej organizacji, z rolą `default_organization_role`. | | `default_organization_role` | `member` | Każda rola poza `owner`; ustawienie, które robiłoby z każdego nowego ownera, dałoby im wszystkim klucze firmy. | Wdrożenie otwarte dla niezależnych osób zostawia wartości domyślne. Wdrożenie dla jednej firmy wyłącza organizacje osobiste i wskazuje organizację firmy, więc współpracownik zaczyna tam, gdzie są agenci i wiedza firmy, i nikt nie uruchamia agentów pod własnym budżetem, polityką zatwierdzeń i dziennikiem audytu obok firmowych. Organizacja osobista nie może być domyślna, a usunięcie domyślnej organizacji czyści to ustawienie. **Zaproszenie do domyślnej organizacji ma pierwszeństwo.** Zaproszenie nadaje własną rolę, a przyjęcie go do organizacji, do której osoba już należy, jest odrzucane — więc nowa osoba, która rejestruje się z zaproszeniem do domyślnej organizacji albo ma oczekujące zaproszenie na swój adres, nie dołącza do niej przy rejestracji. Dołącza, gdy je przyjmie, z rolą z zaproszenia. **Dołącza do niej tylko reguła samego wdrożenia.** Konto wpuszczone przez sam tryb rejestracji — otwarte wdrożenie, adres w dozwolonej domenie — dołącza do domyślnej organizacji. Konto wpuszczone wyłącznie przez zaproszenie albo mapowanie katalogu innej organizacji nie dołącza: menedżerowie tamtej organizacji poręczyli za tę osobę wobec własnej organizacji, a dołączenie do domyślnej pozwoliłoby im dodawać ludzi do organizacji, którą nie zarządzają. **Domyślna organizacja to także miejsce, w którym ląduje sesja.** Żądanie z tokenem sesji i bez `X-Organization-Id` działa w domyślnej organizacji, gdy osoba do niej należy, potem w jej osobistej, potem w jedynej. Osoba, która ma wybór i nie wysłała nagłówka, dostaje `400` wskazujące nagłówek zamiast zgadywania — zobacz [nagłówek organizacji](api.md#the-organization-header). Konsola wybiera tę samą organizację, gdy nic innego nie jest wybrane, a przyjęcie zaproszenia przełącza konsolę na organizację, do której osoba dołączyła. ## Znalezienie jednego tenanta wśród wszystkich { #finding-one-tenant-among-all-of-them } `GET /admin/organizations` to jedyna powierzchnia, która odpowiada na pytanie *jakie tenanty istnieją*, i jest dostępna wyłącznie dla app admina dokładnie z tego powodu, dla którego jest użyteczna: z konstrukcji sięga przez wszystkie tenanty. Odpowiada stroną organizacji, z liczbą członków i agentów każdej z nich oraz z jej najwcześniejszym właścicielem (owner) — czyli tym, kogo o nią zapytać. Wszystkie pola właściciela są puste naraz w organizacji, której ostatni owner odszedł; to stan, który może naprawić tylko admin wdrożenia, a więc taki, który trzeba mu pokazać. | Parametr | | |---|---| | `search` | Nazwa, slug albo adres właściciela. Fraza jest tekstem, a nie wzorcem — `100%` znajduje tenanta o takiej nazwie, a nie wszystkie | | `sort_by` | `name`, `slug`, `members`, `agents`, `created_at`. Cokolwiek innego to 422 | | `sort_dir` | `asc` / `desc`, domyślnie od najnowszych | | `kind` | `personal`, `team` albo `all`. Każde konto dostaje przy rejestracji organizację osobistą, chyba że wdrożenie to wyłączyło, więc na większości wdrożeń to one stanowią większość listy | | `skip`, `limit` | Jedna strona serwera, do 100 | **Wszystko to dzieje się w SQL, przed `OFFSET`/`LIMIT`**, a `total` liczy to, do czego zawężono, a nie całe wdrożenie. To różnica między sortowaniem a jego pozorem: strona posortowana po tym, jak przyszła, rości sobie porządek całej kolekcji, którego pięćdziesiąt wierszy nie jest w stanie dowieźć — dlatego lista tenantów u admina nie miała żadnych kontrolek, dopóki trasa nie odpowiadała na żadną (#921). Porządek rozstrzyga remisy po id, więc stronicowanie po kolumnie, w której wiersze dzielą wartość, wypisuje każdy z nich raz. Kolumna spoza tego zbioru zostaje odrzucona po nazwie, zamiast nie dopasować się do niczego, z tych dwóch powodów, dla których odrzuca ją `GET /runs`: pusta strona czyta się jako *to wdrożenie nie ma tenantów*, a `ORDER BY` sklejane z query stringa jest powierzchnią do wstrzyknięć. ## App admin nie może odciąć się od wdrożenia z poziomu konsoli { #an-app-admin-cannot-lock-the-deployment-out-through-the-console } !!! warning "Samoodcięcie, któremu to zapobiega" Na instalacji z jednym adminem, jaką tworzy `make platform-bootstrap`, przypadkowe kliknięcie we własny wiersz kończyło administrację do czasu, aż ktoś dotarł do terminala. Odzyskanie dostępu to `agenticos cmd create-app-admin ` z powłoki — adres e-mail jest argumentem wymaganym. `is_active` jest egzekwowane przy następnym żądaniu, a `is_app_admin` jest tym, co czytają strony administracyjne, więc app admin działający na **własnym** wierszu z `/admin/users` mógł się wylogować, stracić `/admin` albo usunąć konto. `UserService.admin_update` i `admin_delete` odrzucają samozawieszenie i samousunięcie, a szuflada nie oferuje Suspend, Demote ani Impersonate na twoim własnym wierszu (Delete zostaje, widoczny i odrzucany, bo na pytanie „dlaczego nie mogę usunąć siebie” jest odpowiedź, którą warto pokazać). API odrzuca też działanie jako ty sam — nikt działający jako nikt nie jest impersonacją. Przez API nie da się w ogóle zostawić wdrożenia bez app admina: ten jeden globalny przywilej przyznaje wyłącznie CLI (`agenticos cmd create-app-admin`) i nie ma żądania, które by go zdejmowało, więc zbiór app adminów kurczy się tylko przez usunięcie — a usunięcie *ostatniego* z nich jest usunięciem siebie, co zostaje odrzucone. Usunięcie admina, który naprawdę odchodzi, jest działaniem innego admina, co dodatkowo trzyma ślad audytowy czytelnym. Odzyskanie dostępu, gdyby kiedykolwiek było potrzebne, to nadal `create-app-admin` z powłoki na wdrożeniu. Ten argument dotyczy *zbioru*, a kod przez pewien czas dotyczył jednego wiersza. Dwóch adminów usuwających się nawzajem — żaden z nich nie usuwał siebie. Zablokowali różne wiersze docelowe, nigdy się nie zderzyli i obaj zacommitowali — zero app adminów, do naprawienia wyłącznie zapisem do bazy danych (#1208). Dlatego usunięcie admina bierze `SELECT ... FOR UPDATE` na zbiorze app adminów, uporządkowanym po id, zanim podejmie decyzję. Drugie żądanie czeka, po commicie pierwszego czyta zbiór ponownie i zostaje odrzucone za opróżnienie go. Uporządkowanym, bo dwa żądania biorące te same wiersze w różnej kolejności to zakleszczenie, a nie kolejka. I brane przy każdym usunięciu admina, a nie tylko wtedy, gdy usuwany jest admin: usunięcie użytkownika jest działaniem administratora, a nie gorącą ścieżką, a pełny porządek jest wart więcej niż rywalizacja, którą kosztuje. ## Działanie jako inne konto { #acting-as-another-account } **Impersonate** na `/admin/users` rozpoczyna działanie jako ta osoba z twojej własnej przeglądarki. Nic nigdzie nie jest kopiowane: konsola podmienia ciasteczko dostępu sesji na takie, które wskazuje cel, każda strona renderuje się tak, jak widziałaby ją tamta osoba, a baner na górze mówi, czyje to konto i kto naprawdę działa, z jednym przyciskiem, który to kończy. Impersonacja jest **sesją**, a nie samym poświadczeniem. Token wskazuje wiersz w `sessions` z ustawionym `impersonator_user_id`, a API odrzuca go w chwili, gdy ten wiersz zostanie zakończony albo wygaśnie lub gdy stojący za nim administrator nie jest już aktywnym app adminem — więc kończy się, gdy naciśniesz **End impersonation**, gdy ta osoba wyloguje się wszędzie albo zmieni hasło, gdy minie godzina, albo gdy administrator zostanie zawieszony, zdegradowany lub usunięty, co nastąpi pierwsze. Nie da się jej odświeżyć: oknem jest własne okno tokena dostępu, a godzina jest sufitem, a nie odnawialną dzierżawą. !!! note "Otwarta rozmowa na czacie kończy się razem z nią" Rozmowa na czacie biegnie po WebSockecie, który uwierzytelnia raz, przy handshake'u. Teraz powtarza to sprawdzenie przy każdej wiadomości, więc zakończenie impersonacji — albo zawieszenie konta — zamyka także otwarty czat, zamiast jedynie odrzucać kolejne żądanie HTTP, podczas gdy gniazdo dalej odpowiada. !!! note "Lista urządzeń tej osoby tego nie pokazuje" Impersonacja jest wierszem pod jej id, który trzyma administrator, a nie urządzeniem, z którego ta osoba się zalogowała — więc nie ma jej na liście jej urządzeń, w liczbie otwartych sesji w szufladzie ani w jej „ostatnio widziany”. To, czy w ogóle zostanie o tym powiedziane, rozstrzyga ustawienie poniżej, a wiersz na tej liście rozstrzygnąłby to za nią. **To, czy osoba zostanie poinformowana, jest polityką ustawianą w tym wierszu.** `notify_impersonated_users` jest domyślnie wyłączone; włączone — osoba dostaje jednego e-maila w chwili rozpoczęcia impersonacji, z nazwą administratora. Ślad audytowy zapisuje impersonację tak czy inaczej — i początek, i koniec, wraz z sesją, do której należą — co opisuje [Nadzór](governance.md#audit). ## Komunikaty i zamykanie wdrożenia { #notices-and-closing-the-deployment } **Ogłoszenie** to jedno zdanie w jednym z trzech stylów, pokazywane nad każdą stroną zalogowanym użytkownikom, dopóki go nie zamkną. To jedyne pole w tym wierszu, którego *nie ma* na publicznym endpoincie: ogłoszenie to operator mówiący do ludzi korzystających z wdrożenia — okno aktualizacji, do kogo napisać — więc ma własną trasę, `GET /api/v1/branding/notice`, za sesją. Zamknięcie jest kluczowane po **samej treści komunikatu**, w magazynie samej przeglądarki. Flaga sprawiłaby, że następne ogłoszenie byłoby niewidoczne dla każdego, kto zamknął poprzednie; znacznik czasu z wiersza ustawień odznaczałby zamknięcie komunikatu za każdym razem, gdy wdrożenie zmieniało nazwę. Zmienił się tekst, więc kluczem jest tekst. Magazyn, który odmawia odczytu lub zapisu — tryb prywatny, osadzony webview — oznacza „nic nie zamknięto”, a nie wyjątek: rzucony w trakcie renderowania położyłby dashboard każdemu zalogowanemu użytkownikowi, a baner i tak zamyka się na tak długo, jak długo strona jest otwarta. **Tryb konserwacji trzyma zamknięte API**, a nie tylko konsolę. `app/core/maintenance.py` to czysto-ASGI middleware ponad trasami, więc strona, którą ktoś ma już otwartą, przestaje działać — i na tym polega cała różnica między trybem konserwacji a banerem. Jego lista dozwolonych jest krótka i testowana pozycja po pozycji: - `/health*` — sonda gotowości, która zawodzi w trakcie okna, to orkiestrator restartujący kontener, w którym pracuje operator. - `/api/v1/branding` — zamknięta strona musi móc powiedzieć, jak to wdrożenie się nazywa i dlaczego jest zamknięte. - `/api/v1/auth/*` — administrator musi móc się zalogować **w trakcie** otwartego okna. - `/api/v1/admin/*` — a potem dosięgnąć przełącznika. - Dokumentacja i schemat OpenAPI, które nie serwują żadnych danych. Wszystko inne to 503 z `Retry-After`. Nie czyta w ogóle sesji — oznaczałoby to weryfikowanie tokena ponad grafem zależności — więc poszerzenie ścieżki o `/api/v1/admin/*` nie poszerza władzy: `CurrentAppAdmin` odrzuca tam osobę spoza adminów dokładnie tak, jak robił to zawsze. **Zawodzi w stronę otwartą.** Bramka, która nie potrafi odczytać własnego przełącznika — czkawka Redisa, migracja, która się nie wykonała — przepuszcza ruch, bo alternatywa zamienia potknięcie infrastruktury w całkowitą niedostępność, której nikt nie zaplanował. Werdykt jest cache'owany w tym samym Redisie, który dzieli już każdy worker: zapisywany **po commicie**, więc przełącznik działa natychmiast, a cache nigdy nie może ogłaszać stanu, który baza wycofała — publikowany zachłannie, przy żądaniu, które potem zawiodło, zostawiał wyłączone okno ponownie otwierające wdrożenie na czas nawet całego TTL. Niesie też TTL wynoszący 30 sekund, więc zapis, który nigdy nie dotarł do Redisa, sam się leczy, zamiast zostawić wdrożenie otwarte przez okno, które ktoś zaplanował. **A strona już otwarta się o tym dowiaduje.** Kontekst brandingu jest rozstrzygany raz przez główny layout serwera i nie zmienia się przez całe życie strony, więc okno otwarte później zostawiało każdą otwartą kartę na dashboardzie, którego każde żądanie zaczęło odpowiadać 503, bez niczego na ekranie, co mówiłoby dlaczego — a zamknięcie okna zostawiało kartę na ekranie konserwacji, dopóki ktoś nie przeładował. `GET /api/v1/branding/notice` niesie werdykt konserwacji obok ogłoszenia i jest odpytywany raz na minutę, czyli jedno żądanie po obie odpowiedzi zamiast dwóch, które mogą się o jeden wiersz nie zgadzać. W konsoli administrator widzi pasek, a nie zamkniętą stronę. Jest jedyną osobą, która może zakończyć okno, a tryb konserwacji, który ukrywa także przełącznik, jest awarią. ## Ile jedno konto może zająć { #how-much-one-account-may-take-up } Dwa pułapy, oba w tym samym wierszu i oba **domyślnie null — a null oznacza brak limitu, a nie „nieskonfigurowane”**. Samodzielnie hostowane wdrożenie dla jednej firmy nie chce żadnego z nich; wdrożenie otwarte na rejestracje chce obu, bo w przeciwnym razie jedno konto może wybijać tenanty bez ograniczeń. | Ustawienie | Liczy | Nie liczy | |---|---|---| | Organizacje na konto | Organizacje, które konto **posiada**, wliczając osobistą | Te, do których zaprosił je ktoś inny | | Agenci na organizację | Agenci należący do organizacji | Zarchiwizowanych agentów | **Sprawdzane jest każde przejście do stanu liczonego, a nie tylko utworzenie.** Pułap egzekwowany wyłącznie na nowych wierszach to pułap, który obchodzi się bokiem: organizacja na swoim limicie agentów archiwizuje jednego, tworzy zastępcę i przywraca to, co zarchiwizowała, a konto na swoim limicie organizacji dostaje cudzą przez `transfer_ownership`. Dlatego `unarchive` i `transfer_ownership` zadają to samo pytanie co `create`. **A liczenie odbywa się pod blokadą.** Odczyt `count(...) >= limit`, a potem zapis to dwa polecenia, więc dwa żądania oba przechodzą zliczenie i oba wstawiają wiersz — pułap przekroczony deterministycznie, przez dwukrotne kliknięcie. Żadne ograniczenie nie wyrazi „najwyżej N takich wierszy”, więc `app/db/locks.py` bierze blokadę doradczą w zasięgu transakcji na *podmiocie* pułapu: dwa żądania o jedno konto ustawiają się w kolejce, żądania o różne konta nigdy się nie spotykają, a blokadę zwalnia commit albo rollback. Tylko tam, gdzie limit jest ustawiony, więc wdrożenie bez pułapów nie płaci nic. Oba wykluczenia są sednem tego projektu, a nie jego szczegółem. Zaproszenie do dziesięciu organizacji jest cudzą decyzją, a pułap, którego jedna osoba nie kontroluje, to pułap odcinający ją od tworzenia własnych. A archiwizacja jest sposobem na wycofanie agenta — pułap, który wycofany agent dalej by zajmował, sprawiłby, że jedyną drogą powrotu pod niego jest usunięcie, które zabiera ze sobą historię wersji i przypisanie runów. Odrzucenie nazywa pułap i liczbę, względem której go zmierzono (`{"limit": 5, "held": 5}`), więc na „dlaczego nie mogę” odpowiada odpowiedź, a nie pamięć administratora. Jest podnoszone w serwisie, który tworzy daną rzecz, a nie na trasie, bo trasa nie jest jedyną drogą do środka. Konsola nie czeka na tę odmowę. `GET /agents/ceiling`, dostępne dla wywołującego z `agents:edit`, zwraca limit (`limit`, null, gdy nie jest ustawiony) oraz to, czy utworzenie agenta by go przekroczyło (`at_limit`), a na limicie strona Agenci wyłącza **Nowy agent (New agent)** i instalowanie szablonów i mówi dlaczego, zanim ktokolwiek wypełni formularz. Jeśli inna operacja zapełni limit podczas tworzenia agenta z przewodnikiem, przewodnik kończy się zamiast blokować stronę wokół wyłączonego przycisku. Udane utworzenie agenta w tym przewodniku jest zapisywane przed odświeżeniem limitu, więc zajęcie ostatniego miejsca nie przerywa dalszych kroków w Builderze. Sam licznik (`held`) dostaje tylko wywołujący, którego `agents:view` obejmuje wszystkich agentów organizacji: licznik obejmuje agentów, których Członek (Member) nie widzi, a zestawiony z jego własną listą mówiłby, ilu przed nim ukryto. **Duplikuj (Duplicate)** i **Przywróć (Restore)** są nadal odrzucane dopiero przy próbie. Gwarancją pozostaje odmowa: licznik jest prognozą, czytaną bez blokady. Zero jest odrzucane przez schemat: konto, które nie może posiadać żadnej organizacji, to konto, którego nie da się utworzyć, skoro rejestracja daje każdemu z nich organizację osobistą. ## W jakim formacie można wgrać dyktowanie { #what-a-dictation-may-be-uploaded-as } `dictation_compressed_audio`, **domyślnie wyłączone**, rozstrzyga format każdego [dyktowania](ml-services.md#dictation-in-chat) na wdrożeniu — w composerze `/chat`, na hostowanych stronach i w widgetach, w każdej organizacji. Wyłączone sprawia, że serwer przyjmuje jeden format: WAV z 16-bitowym PCM 16 kHz mono, który nagrywa każda przeglądarka. Jego długość to jego rozmiar, 32 000 bajtów na sekundę, więc serwer liczy próbki, a najdłuższe dyktowanie dokładnie ogranicza koszt jednego nagrania. Włączone dopuszcza dodatkowo WebM, Ogg i MP4, a przesyłane pliki są mniej więcej ośmiokrotnie mniejsze. Ich długość jest odczytywana z własnego nagłówka pliku, który plik zbudowany ręcznie może zaniżyć: jedno dyktowanie może wtedy nieść w ramach limitu rozmiaru nawet około czterdziestokrotność najdłuższego dyktowania. Na [publicznej stronie albo w widgecie](channels.md#what-it-costs-and-what-bounds-it) każdy, kto ma link, może takie wysłać, na rachunek konta organizacji u providera i poza każdym budżetem. Dlatego przełącznik na `/admin/settings` najpierw pyta. Włączenie otwiera okno dialogowe, które nazywa te ryzyka, a jego przycisk potwierdzenia staje się aktywny dopiero po zaznaczeniu pola z oświadczeniem, że zostały przeczytane; wyłączenie o nic nie pyta. Tak czy inaczej zmiana jest zapisywana razem z resztą formularza i audytowana jak każde inne pole. Przeglądarki stosują się do niej, gdy strona następnym razem zapyta: format, w którym nagrywają, to ten, który ogłasza serwer, nigdy lista trzymana w kliencie. ## Ten wiersz { #the-row } Jeden wiersz na całą instalację, pilnowany przez bazę danych, a nie przez konwencję, której nikt nie widzi: `singleton` jest unikalny i ograniczony do wartości prawda, więc druga tożsamość jest `IntegrityError`, a nie wdrożeniem, które po cichu ma dwie i serwuje tę, którą zapytanie ustawiło pierwszą. Zapis to pojedynczy `INSERT ... ON CONFLICT DO UPDATE`, bo odczyt-a-potem-wstawienie ściga się samo ze sobą w chwili, gdy dwóch administratorów zapisuje z dwóch kart. **Nic nie jest zasiewane.** Brak wiersza oznacza same wartości domyślne, czyli dokładnie stan wdrożenia, którego nikt nie skonfigurował — i ma to znaczenie, ponieważ publiczny endpoint brandingu jest nieuwierzytelniony i odpytywany przy każdym zimnym załadowaniu strony, więc odczyt tworzący wiersz pozwoliłby obcemu wywołać `INSERT`. Każdy zapis jest audytowany do `app_admin_audit_logs`, z nazwami **pól**, a nigdy z ich wartościami: ogłoszenie i lista domen są jednym i drugim tekstem operatora, a wiersz audytu żyje dłużej niż ciało żądania, z którego pochodzi. ## Odrzucenie z tego wdrożenia zawsze wygląda tak samo { #a-refusal-from-this-deployment-always-looks-the-same } Warto powiedzieć to tutaj, bo zamykanie wdrożenia to funkcja, która najpewniej wyprodukuje odrzucenie, jakiego nikt wcześniej nie widział. `app/api/exception_handlers.py` wkłada **każde** odrzucenie w `{"error": {"code", "message", "details"}}`: ```json { "error": { "code": "NOT_FOUND", "message": "Agent not found", "details": { "agent_id": "…" } } } ``` Obejmuje to wyjątki domenowe, walidację schematu, a od #917 również `HTTPException`, co pokrywa 405, niedopasowaną ścieżkę oraz te dwadzieścia dwie trasy, które podnoszą go bezpośrednio. Dwa kształty na łączu oznaczają, że każdy wywołujący albo obsługuje oba, albo po cichu źle obsługuje jeden. Żądanie z niewłaściwą metodą odpowiadało dawniej **500** zamiast 405, na każdej trasie. Instrumentacja FastAPI w OpenTelemetry wyprowadza nazwę spanu, chodząc po `app.routes`, a jej gałąź `Match.PARTIAL` — czyli dokładnie „ścieżka pasuje, a metoda nie” — czyta `.path` bez zabezpieczenia; FastAPI 0.141 wkłada do tej listy obiekty `_IncludedRouter`, a te go nie mają. `app/core/otel_compat.py` dostarcza dla tej gałęzi to samo zastępcze zachowanie, którego upstream używa już w gałęzi zabezpieczonej. Wciąż niepoprawione w upstreamie na 0.65b0, a `tests/test_otel_route_details.py` zawodzi, gdy zostanie poprawione — i wtedy moduł znika. ## Zadania w tle { #background-jobs } **Admin → Background jobs** (`/admin/background-jobs`) to kolejka widziana oczami właściciela wdrożenia i tylko app admin może ją otworzyć: kolejka należy do wdrożenia, więc właściciel organizacji dostaje odmowę. Nagłówek liczy zadania w każdym stanie, wskazuje każdy wstrzymany rodzaj i powód, wymienia żyjące workery i najbliższy termin każdego harmonogramu. Pod nim są zadania, od najnowszych, filtrowane po stanie i puli, każde z liczbą prób i błędem, na którym ostatnio się skończyło. Pod licznikami **Activity** pokazuje na wykresie, co zakończyło się w ostatnich 24 godzinach albo 7 dniach, z podziałem na to, jak się skończyło, a obok najdłuższe czekanie zadania, które jest już do wykonania. **By kind** podaje dla każdego rodzaju liczbę zadań, liczbę martwych, czas wykonania w medianie i 95. percentylu oraz to, jak długo jego zadania czekały na workera: rosnące czekanie mówi, że pula potrzebuje więcej workerów. Dashboard wdrożenia pokazuje ten sam dzień jako kartę. Trzy akcje, każda zapisywana w audycie z tym, kto ją wykonał: - **Retry** daje martwemu zadaniu nowy zestaw prób. Martwy ingest dokumentu jest odrzucany: jego kopia do parsowania zniknęła razem z nim, więc ponów dokument z jego bazy wiedzy, co ponownie parsuje zapisany plik. - **Cancel** wycofuje zadanie, którego nikt jeszcze nie zaczął. - **Resume** pozwala workerom znowu pobierać wstrzymany rodzaj. Rodzaj wstrzymuje bezpiecznik workera: jego zadania zabiły swojego workera `WORKER_BREAKER_DEATHS` razy z rzędu, najczęściej dokument, przy którym parsowanie kończy się brakiem pamięci. Każdy właściciel dostaje powiadomienie w aplikacji i e-mailem, którego nie da się wyłączyć - praca tego rodzaju gromadzi się, dopóki któryś z nich jej nie wznowi. Pauza podaje organizację, której zadania ją wywołały, a dla pozostałych ten rodzaj działa dalej. Martwe zadania ponawiaj dopiero po usunięciu przyczyny, inaczej zginą tak samo - martwy ingest dokumentu ponawiaj z jego bazy wiedzy. Ustawienia są w [Konfiguracji](configuration.md#background-work). ## Podsumowanie { #recap } - Tożsamość wdrożenia to **jeden wiersz**, edytowany z `/admin/settings`, a kolumna null oznacza *wartość wbudowaną*, a nie *pustą*. - `signup_mode` jest stosowany w **jednym miejscu** i bramkuje obie ścieżki, które tworzą konto. Zaproszenie ma pierwszeństwo przed listą domen; `closed` nie ustępuje niczemu. - Nowe konto dostaje **organizację osobistą, chyba że to wyłączono**, i dołącza do **domyślnej organizacji**, gdy jest ustawiona; tam też trafia sesja, która nie wskazuje organizacji. - App admin **nie może odciąć sam siebie** z poziomu konsoli. - **Impersonacja jest sesją**: uruchamiana z konsoli bez żadnego tokena w schowku, nazwana w banerze, kończona przez administratora, przez wylogowanie się danej osoby wszędzie albo przez upływ godziny. To, czy ta osoba zostanie poinformowana, rozstrzyga `notify_impersonated_users`, domyślnie wyłączone. - Dyktowanie to **surowy PCM, chyba że `dictation_compressed_audio` jest włączone**, a administrator włącza je dopiero po oknie dialogowym, które nazywa, ile to kosztuje. - Każde odrzucenie z tego wdrożenia wygląda tak samo, niezależnie od tego, która warstwa je wyprodukowała.