Przejdź do głównej zawartości

Podział CLIENT/ADMIN po hoście

Jak jedna aplikacja rozróżnia portal klienta od panelu pracownika, skąd bierze się lista tras „klienckich” i „adminowych” (scripts/route-variants.json) i dlaczego yarn variants:check pilnuje każdej nowej trasy. Rozdział historyczny na końcu wyjaśnia, skąd ten mechanizm pochodzi.

👤 Dla kogo​

Dokument techniczny. Jeśli dodajesz stronę lub endpoint API — przeczytaj Dodawanie nowej trasy. Jeśli konfigurujesz środowisko z osobnymi hostami dla klienta i panelu — Konfiguracja hostów.

🧭 Stan dzisiejszy​

Wszystkie środowiska serwują całą aplikację z jednego hosta: produkcja to klient.acepark.pl (panel pod /dashboard, panel.acepark.pl przekierowuje 301), qa.acepark.pl i dev.acepark.pl tak samo. Zmienne CLIENT_HOSTS / ADMIN_HOSTS są puste, więc middleware nie ingeruje, a menu pokazuje wszystko, na co pozwala rola.

Mechanizm podziału po hoście zostaje w kodzie i jest gotowy do włączenia, gdy panel miałby znów dostać osobny adres — koszt to hostname w tunelu i dwie zmienne środowiska.

🗂️ Klasyfikacja tras​

Źródłem prawdy jest scripts/route-variants.json. Każda reguła przypisuje ścieżkę (plik albo katalog w app/) do listy wariantów; wygrywa reguła o najdłuższym dopasowaniu segmentów, dzięki czemu app/(dashboard)/dashboard/user nie „zjada” user-activities.

{
"path": "app/(dashboard)/dashboard/camps",
"variants": ["admin"],
"reason": "Camp management (seasons, terms, registrations…)"
},
{
"path": "app/(dashboard)/dashboard/camps/offer",
"variants": ["client", "admin"],
"reason": "Public camp offer — route_access grants it to CLIENT"
}

Punktem wyjścia była tabela route_access (rola → trasa). route_access jest edytowalna w czasie działania aplikacji (/dashboard/route-access), a klasyfikacja wariantów jest stała — dlatego plik JSON jest osobnym, jawnym źródłem prawdy, a nie odczytem z bazy.

Z pliku yarn variants:generate buduje lib/variants/client-routes.generated.ts: cztery listy wzorców URL (CLIENT_ROUTE_PATTERNS, ADMIN_ROUTE_PATTERNS, ADMIN_ONLY_ROUTE_PATTERNS, CLIENT_ONLY_ROUTE_PATTERNS). Reguła "variants": [] oznacza trasę deweloperską (dashboard/fakturownia-test, dashboard/notification-tests) — kod zostaje w repo, ale trasa nie figuruje w żadnej liście.

➕ Dodawanie nowej trasy​

  1. Dodaj stronę lub endpoint jak zwykle.
  2. Uruchom yarn variants:check. Jeśli trasa nie jest pokryta żadną regułą — check nie przejdzie (tak samo w CI, job „Route Variant Classification”).
  3. Dopisz regułę w scripts/route-variants.json. W razie wątpliwości wybierz ["client", "admin"] — trasa brakująca po stronie klienta przekierowywałaby klientów na host panelu, a nadmiarowa nic nie kosztuje.
  4. Uruchom yarn variants:generate i zacommituj lib/variants/client-routes.generated.ts.

Trasa dziedziczy regułę katalogu nadrzędnego, więc nowa podstrona w dashboard/statistics/ nie wymaga żadnej akcji.

🔀 Routing po hoście​

lib/variants/routing.ts:

  • getAppVariant(host) — APP_VARIANT z env (używane przez yarn dev:client / yarn dev:admin) wygrywa; inaczej host żądania jest szukany w CLIENT_HOSTS, potem w ADMIN_HOSTS. Brak dopasowania = null = cała aplikacja bez przekierowań.
  • resolveServingVariant(pathname, variant) — czy ścieżka należy do drugiej roli (isAdminOnlyPath / isClientOnlyPath). Trasa kliencka może leżeć pod adminowym segmentem dynamicznym (/dashboard/camps/offer vs /dashboard/camps/[seasonId]), dlatego ścieżka obsługiwana przez wariant CLIENT zawsze wygrywa.

middleware.ts (handleForeignVariantPath) działa w obie strony:

  • nawigacja przeglądarki (GET/HEAD strony) → 307 przez handoff sesji na origin drugiej roli (ADMIN_ORIGIN / CLIENT_ORIGIN) z zachowaniem ścieżki i query; gdy origin nie jest skonfigurowany — rewrite na stronę 404 aplikacji (app/not-found.tsx, status 404, URL zostaje),
  • /api/* oraz metody inne niż GET/HEAD → JSON 404 { "error": "route_not_served_by_this_worker", "variant": … } z nagłówkiem x-app-variant,
  • żądania z nagłówkiem Next-Action przechodzą do Next — Server Actions rozwiązują się po globalnym ID akcji, nie po trasie, dzięki czemu „Zgłoś błąd” na stronie 404 działa.

Host w przekierowaniu pochodzi zawsze ze zmiennej środowiskowej, a z żądania brana jest tylko ścieżka i query (parseOrigin + new URL()), co uniemożliwia open-redirect (test: should_never_let_the_path_change_the_host).

route_access odpowiada na pytanie „czy ta rola może wejść”, a nie „czy trasa jest serwowana na tym hoście” — część stron (/dashboard/admin-panel, /dashboard/activity-types) w ogóle nie ma wiersza w tej tabeli. Root layout liczy osobną listę collectUnavailableRoutes(routes, variant) i RouteAccessProvider odrzuca je przed logiką ról. Przy wariancie null lista jest pusta.

app/page.tsx (resolveEntryTarget) kieruje po zalogowaniu wg roli: ADMIN/BACKOFFICE/INSTRUCTOR → /dashboard/schedule, MARKETING → /dashboard/statistics, pozostali → /dashboard/user-activities. Gdy rola nie pasuje do hosta (pracownik na hoście klienckim), cel jest owinięty w handoff na drugi origin; bez skonfigurowanych originów zwracana jest ścieżka względna.

🔐 Sesja między hostami​

Better Auth trzyma sesję w cookie przypiętym do hosta, więc zalogowanie na hoście klienckim nie daje sesji na hoście panelu. Rozwiązuje to handoff sesji, nie współdzielone cookie:

klient.example/dashboard/finances
→ 307 klient.example/api/auth/cross-worker-handoff?to=admin&returnTo=…
→ 307 panel.example/api/auth/mobile-session?code=…&returnTo=…
→ 307 panel.example/dashboard/finances

Endpoint generuje jednorazowy kod w APP_CACHE (cache.db, wspólny dla obu hostów, bo to ten sam proces), a drugi host wymienia go na własne cookie. To ten sam mechanizm, którego używa aplikacja mobilna (generateMobileAuthCode / consumeMobileAuthCode). Kod żyje 60 sekund i jest jednorazowy. returnTo przechodzi przez sanitizeReturnPath (tylko ścieżki same-origin).

Alternatywa — AUTH_COOKIE_DOMAIN (advanced.crossSubDomainCookies w lib/auth.ts) — jest dostępna, ale nieużywana:

:::danger Zanim ustawisz AUTH_COOKIE_DOMAIN domain=.acepark.pl wysyła cookie sesji do każdego hosta w tej domenie — łącznie z www.acepark.pl, na którym stoi WordPress. httpOnly chroni tylko przed odczytem z JavaScriptu, nie przed wyciekiem po stronie serwera. :::

trustedOrigins w lib/auth.ts zawiera https://*.acepark.pl, więc nowe hosty są pokryte. BETTER_AUTH_URL musi wskazywać host, pod którym użytkownik faktycznie się loguje.

⚙️ Konfiguracja hostów​

Żeby rozdzielić panel od portalu (np. qa.acepark.pl + qa-panel.acepark.pl):

  1. Hostname w tunelu Cloudflare (/etc/cloudflared/config.yml) i domena usługi web w Coolify.
  2. Zmienne zasobu: CLIENT_HOSTS=qa.acepark.pl, ADMIN_HOSTS=qa-panel.acepark.pl, CLIENT_ORIGIN=https://qa.acepark.pl, ADMIN_ORIGIN=https://qa-panel.acepark.pl.
  3. Dostawcy: redirect URI Google, Return URL Apple, webhook SendGrid dla nowego hosta (lista w planie migracji, pkt 4.14).
  4. Redeploy. Wejście pracownika na host kliencki kończy się handoffem na panel; klient na hoście panelu — handoffem w drugą stronę.

Jeden poziom subdomeny (qa-panel.acepark.pl, nie panel.qa.acepark.pl) mieści się w Universal SSL Cloudflare.

💻 Development lokalny​

yarn dev # cała aplikacja, wariant null
yarn dev:client # port 3002, APP_VARIANT=client
yarn dev:admin # port 3001, APP_VARIANT=admin

APP_VARIANT wystarczy, żeby middleware zachowywało się jak host danej roli: wejście na /dashboard/user-activities pod yarn dev:admin renderuje stronę 404 aplikacji, a wycięte API zwraca JSON 404. Ustawienie ADMIN_ORIGIN/CLIENT_ORIGIN zamienia 404 na przekierowania — lokalnie ma to sens przy dwóch działających serwerach (skrypty dev:client/dev:admin ustawiają je na localhost:3002 / localhost:3001). Oba serwery dzielą ./.data/cache.db, więc handoff sesji działa lokalnie w całości.

Sprawdzenie handoffu bez konta (getSessionCookie tylko czyta cookie):

curl -si -b "better-auth.session_token=TEST" \
"http://localhost:3002/api/auth/cross-worker-handoff?to=admin&returnTo=%2Fdashboard%2Ffinances" \
| grep -i location
curl -si "http://localhost:3001/api/auth/mobile-session?code=<KOD>&returnTo=%2Fdashboard%2Ffinances" \
| grep -iE "location|set-cookie"

Oczekiwane: location: http://localhost:3001/dashboard/finances, ustawione cookie, a drugie użycie tego samego kodu → 401. returnTo=//evil.example.com musi wylądować na /.

📜 Historia: dwa workery Cloudflare (07.2026 – 09.2026)​

Mechanizm powstał w AP-773, gdy aplikacja działała na Cloudflare Workers. Izolat workera ma twardy limit 128 MB pamięci, a patch OpenNext materializował manifesty wszystkich tras przy starcie izolatu (audyt 27.07.2026: 227 manifestów / 19,39 MB, handler.mjs 41,62 MB). Worker klient regularnie ginął z „Worker exceeded memory limit”, a błąd Connection closed wpadał w pętlę auto-retry w app/error.tsx.

Rozwiązaniem był podział na dwa buildy i dwa workery z jednego repozytorium: CLIENT (104 trasy, 21,71 MB) i ADMIN (195 tras, 35,50 MB). Skrypt scripts/variants/build.mjs fizycznie przenosił pliki tras poza app/ na czas buildu, build-and-migrate.sh deployował oba workery po kolei (ADMIN bindował Durable Objects klienta przez script_name), a crony żyły tylko na workerze ADMIN, żeby zadania wsadowe nie dzieliły puli izolatów z płacącymi klientami. Domeny: klient.acepark.pl / panel.acepark.pl (prod), klientdev / paneldev (qa), dev.klient / dev.panel (dev).

Po migracji na VPS (10.09.2026) limit pamięci zniknął, więc buildy wariantowe, budżety manifestów i sekwencyjny deploy zostały usunięte (faza 6 planu, AP-1011). Zostało to, co jest niezależne od platformy: klasyfikacja tras, routing po hoście, handoff sesji i sprawdzenie w CI. Produkcja świadomie wróciła do jednego hosta (decyzja z 10.09.2026), bo dwa hosty istniały wyłącznie z powodu limitu izolatu.