App Directory — kontrakt /api/dealerid/user-apps
DealerID renderuje dashboard /account/apps agregując karty z wszystkich aplikacji platformy.
Każda apka, która chce się tam pojawić, wystawia endpoint /api/dealerid/user-apps (lub inny
URL — wystarczy wpisać go w oauth2_client.app_directory_url). DealerID woła go
server-to-server, podpisując request krótkim JWT z dedykowanym scope.
Po co to istnieje
Dział zatytułowany „Po co to istnieje”Bez tego endpointu DealerID musiałby albo:
- trzymać własną kopię „kto ma dostęp do której apki” (== mirror tabeli membership każdej apki, dryft, koszmar),
- albo zwracać statyczny katalog aplikacji bez kontekstu „twoich” Applications (user musiałby zgadywać).
Z endpointem każdy /account/apps request to live, real-world snapshot dostępów usera. CRM zwraca
N kart (po jednej na każdą Application widoczną dla usera — patrz
Multi-tenancy: Aplikacje), BMS zwraca M, itd.
Sekwencja
Dział zatytułowany „Sekwencja”sequenceDiagram
autonumber
actor U as User
participant DID as DealerID<br/>(/account/apps)
participant CRM as crm-api<br/>(/api/dealerid/user-apps)
participant BMS as bms-api<br/>(/api/dealerid/user-apps)
participant DB as DealerID DB<br/>(oauth2_client)
U->>DID: GET /account/apps
DID->>DB: SELECT identifier, name, app_directory_url<br/>FROM oauth2_client<br/>WHERE active = TRUE<br/>AND app_directory_url IS NOT NULL
DB-->>DID: [crm-frontend, bms-frontend, ...]
Note over DID: Mint short-lived JWT<br/>RS256, 5 min TTL,<br/>sub=user.email,<br/>scopes=["app.directory.read"]<br/>(NIE wysyła X-Organization-Id —<br/>backend wyciąga app context z hosta)
par parallel I/O — filtrowanie per oauth2_client.app_directory_url
DID->>CRM: GET /api/dealerid/user-apps<br/>Authorization: Bearer <JWT><br/>timeout: 2s
CRM->>CRM: JwtAuthenticator → AuthenticatedUser<br/>(lazy provision iam_user)
CRM->>CRM: enumerate Applications<br/>(per kind + membership)
CRM-->>DID: 200 { apps: [{name, host, role, status, ...}] }
and
DID->>BMS: GET /api/dealerid/user-apps<br/>Authorization: Bearer <JWT>
BMS-->>DID: 200 { apps: [...] }
end
Note over DID: Aggreguje wpisy,<br/>dorzuca lokalne APP_PRESENTATION<br/>(logo monogram + color)
DID-->>U: HTML dashboard z N×M kartami
Zmiana vs poprzedni model: DealerID nie wysyła już
X-Organization-Idprzy discovery call. Backend (CRM) wyciąga context aplikacji wyłącznie zHostheadera w kolejnych requestach po userze. JWT z scopeapp.directory.readsłuży wyłącznie do autoryzacji discovery callu — bez context tenanta, apka sama enumeruje wszystkie Applications widoczne dla usera.
Kontrakt — request
Dział zatytułowany „Kontrakt — request”GET /api/dealerid/user-appsHost: <twoja apka, internal network>Authorization: Bearer <JWT minted by DealerID>Accept: application/jsonJWT (struktura, z HttpAppDirectoryFetcher::mintToken()):
{ "iss": "dealerid", "aud": "dealercrm-apps", "sub": "lukasz.kopcza@grupadealer.pl", // email usera, dla którego DealerID pyta "iat": 1715900000, "nbf": 1715900000, "exp": 1715900300, // 5 min TTL "scopes": ["app.directory.read"]}Podpis: RS256, tym samym kluczem prywatnym, którym DealerID podpisuje normalne access tokeny.
Twoja apka weryfikuje tym samym publicznym kluczem (env OAUTH_PUBLIC_KEY) — nie potrzebujesz
osobnej infry kryptograficznej.
Uwaga: scope
app.directory.readjest dedykowany M2M (server-to-server). Nigdy nie wystawiaj go w SPA tokenu —client_idw SPA nie powinien być w stanie go zażądać. To jest token mintowany wyłącznie wewnętrznie przez DealerID.
Kontrakt — response
Dział zatytułowany „Kontrakt — response”// HTTP/1.1 200 OK// Content-Type: application/json{ "apps": [ { "name": "Gezet Główny", // co user zobaczy na karcie "host": "gezet.localhost:3001", // dokąd user trafia po kliknięciu (BFF wczyta Host) "role": "2 role", // bezpieczny opis roli (display only, nie permission check) "status": "active", // "active" | "suspended" | "pending" "subtitle": "Gezet Sp. z o.o.", // nazwa organizacji "lastSeen": null, // ISO 8601 lub null jeśli nie wiadomo "brandingSkinSlug": "default" // dziedziczony z organization.branding_skin_slug }, { "name": "Gezet — Renault", "host": "gezet-renault.localhost:3001", "role": "1 rola", "status": "active", "subtitle": "Gezet Sp. z o.o.", "lastSeen": null, "brandingSkinSlug": "renault" } ]}| Pole | Typ | Wymagane | Opis |
|---|---|---|---|
name | string | tak | Display name karty. CRM łączy nazwę organizacji + ew. brand (np. „Gezet — Renault”). |
host | string | tak | Host, na który trafia user po kliknięciu („Idź do apki”). Z portem dla dev. Nie URL — sam host. DealerID skleja https:// (lub http:// dla *.localhost). To jednoznacznie wyznacza Application po stronie apki. |
role | string | tak | Krótki, human-readable opis. Nie powinien zawierać wrażliwych szczegółów permission. Może być np. „Admin”, „2 role”, „Staff”. |
status | string | tak | Jeden z "active", "suspended", "pending". DealerID może wyszarzać karty suspended/pending. CRM dziś zwraca tylko "active" (suspended są filtrowane wcześniej). |
subtitle | string? | nie | Drugorzędna linijka (np. pełna nazwa organizacji). Pomijaj jeśli zduplikowałoby name. |
lastSeen | string? | nie | Timestamp ISO 8601 ostatniej aktywności usera w danej apce / Application. CRM dziś zwraca null (nie trackujemy). Może być wzbogacone w przyszłości. |
brandingSkinSlug | string? | nie | Skin (logo/paleta) — apka downstream ustawia data-skin="..." po loginie. Wartość dziedziczona z organization.branding_skin_slug (od organization_id lub importer_id zależnie od kind). Patrz Multi-tenancy: Aplikacje. |
Uwaga:
applicationKind(dealer_main/importer_main/importer_network/staff) nie jest polem response. To atrybut karty aplikacji nadawany lokalnie po stronie DealerID (na podstawie kontekstuoauth2_client.identifier+ heurystyk). Apka downstream nie musi tego ujawniać.
Niezmienniki
Dział zatytułowany „Niezmienniki”- Lista może być pusta (
{ "apps": [] }) — user nie ma żadnego membership w twojej apce. To nie błąd. DealerID po prostu nie pokaże twojej apki dla tego usera. - Best-effort: jeśli twoja apka padnie / timeout / 5xx, DealerID po prostu pomija ją w agregacie. Inne apki nadal pojawią się userowi. Nie blokuj dashboardu — return 200 ze pustą listą jest preferowane nad błędem, jeśli wiesz, że odpowiedź jest „użytkownik nie ma dostępu”.
- Staff platformy (
is_internal_staff) zwykle dostaje wszystko (bypass membership) — w CRM oznacza to wszystkie Applications × wszystkie organizations. Patrz implementacja CRM poniżej. - Każda Application widoczna dla usera = osobny wpis. User z 3 dealerami zobaczy 3+ kart CRM (po jednej na każdą Application, do której ma dostęp przez membership). To jest cecha, nie bug — pozwala mu wybrać kontekst pracy bez przelogowywania.
Przykład — implementacja w CRM
Dział zatytułowany „Przykład — implementacja w CRM”apps/crm-api/src/Module/Iam/Ui/Http/UserAppsController.php:
#[Route('/api/dealerid/user-apps', name: 'iam_user_apps', methods: ['GET'])]public function __invoke(): JsonResponse{ $user = $this->currentUserProvider->current(); if (null === $user) { return new JsonResponse(['type' => '/errors/401', 'title' => 'Unauthorized'], 401); }
$entries = [];
if ($user->isStaff) { // Staff: widzimy wszystkie aktywne Applications (bypass membership scope). // Dla kind=staff zwracamy wpis platformowy; dla pozostałych kindów — po jednym wpisie na // Application × organization w której jesteśmy uprawnieni (= wszystkie, bo staff). $apps = $this->applicationFinder->list(active: true); foreach ($apps as $app) { $brandingSkin = $this->resolveBrandingSkin($app); // dziedziczone z org per kind $entries[] = $this->renderEntry($app, 'Staff', $brandingSkin); } } else { // Regular user — membership-driven listing. // Dla każdej Application sprawdzamy, czy user ma odpowiednie membership: // - kind=dealer_main / importer_main → membership w application.organization_id // - kind=importer_network → membership w application.organization_id (dealer) // - kind=staff → nigdy (regular user nie widzi staff Application) foreach ($user->memberships as $m) { if ('active' !== $m->status) continue; $apps = $this->applicationFinder->listForOrganization( organizationId: $m->organizationId, active: true, ); foreach ($apps as $app) { if ('staff' === $app->kind) continue; $brandingSkin = $this->resolveBrandingSkin($app); $entries[] = $this->renderEntry( $app, $this->primaryRoleName($m->roleIds), $brandingSkin, ); } } }
return new JsonResponse(['apps' => $entries]);}
private function resolveBrandingSkin(ApplicationView $app): ?string{ // Branding dziedziczony z organization (jedno źródło per firma): // - kind=importer_network → bierzemy z importera (application.importer_id) // - pozostałe kindy → z organization_id $orgId = 'importer_network' === $app->kind ? $app->importerId : $app->organizationId; return $this->organizationFinder->findById($orgId)?->brandingSkinSlug;}Application access validator — bypass dla /api/dealerid/*
Dział zatytułowany „Application access validator — bypass dla /api/dealerid/*”W CRM, ApplicationAccessValidator (apps/crm-api/src/Module/Iam/Infrastructure/Security/ApplicationAccessValidator.php)
resolve’uje Application z Host headera dla /api/* i sprawdza membership. Ale dla discovery
endpointu DealerID nie ma konkretnej Application — chcemy enumeracja wszystkich. Zwolnienie:
private const array EXEMPT_PATHS = [ '/api/me', '/api/docs', '/api/public', // DealerID server-to-server discovery — bez application context, scope `app.directory.read`. '/api/dealerid',];Jeśli twoja apka też używa walidatora — pamiętaj o tym wyjątku.
Co nie jest częścią kontraktu (na razie)
Dział zatytułowany „Co nie jest częścią kontraktu (na razie)”- Pagination —
appsto flat list. Realistycznie user ma ≤ 50 wpisów; reuse tej listy bez paginacji jest OK. - Filtrowanie po statusie / org — DealerID dostaje wszystko, sam zrobi grouping na froncie.
- Polish layout / order — DealerID sortuje po
namealfabetycznie. Twoja apka nie musi sortować. - Logos / colors — patrz Rejestracja aplikacji → APP_PRESENTATION. Apka nie wystawia własnego logo / koloru — trzymane spójnie po stronie DealerID.