Pending refactor: эта страница пока в digest-стиле (списки [Н]/[Р] секциями, как session digest). Concept-page должна быть thin layer-овый narrative со ссылками на entity/decision pages, не full inline. Аналогичная ситуация с patient-summary. Refactor — отдельный pass, не сделан.
Как обеспечить tenant-routing на уровне frontend / ingress: дефолтный subdomain ({hash}.portal.bloodgpt.tech) + опциональный client-owned domain (patient.pzalab.com) через Cloudflare for SaaS, плюс auth-flow через WorkOS. Источник на 2026-04-25 — Claude Code session 871a7608 (2026-02-13 → 02-16). Артём (B2B platform) и Евгений (infra) задавали ключевые вопросы в Slack-треде, инкорпорированном в сессию.
Факты [Н]
[Н1] Hash-based subdomain как Phase 1 default — дешёвый zero-config tenant-routing
Open framing: этот раздел сейчас зафиксирован как “факт”. Реально это был поиск из вариантов (slug vs hash vs custom domain) который сошёлся на hash. Лучше переписать как narrative поиска (что рассматривалось → что выбрали → почему), не как догму. Возможно полнее в других сессиях помимо
871a7608— TBD проверить. См. также hash-based-subdomain (atomic decision-page с альтернативами).
Каждый tenant получает: {hash}.portal.bloodgpt.tech, где hash = sha256(orgId + HOSTING_SECRET).slice(0, 12).
Почему такой:
- Нельзя угадать чужой tenant URL (secret-based hash, не slug)
- Hash сам по себе = proof-of-ownership для опционального custom CNAME (если клиент потом подключит свой домен — отдельный TXT-верификации не нужно, само совпадение CNAME подтверждает)
- Wildcard DNS
*.portal.bloodgpt.tech+ wildcard SSL уже настроены (Cloudflare выпускает edge-cert автоматически) — новые tenants работают без terraform apply - Cookies шарятся через
Domain=.portal.bloodgpt.techwildcard — Phase 1 не требует cross-domain cookie-workaround
Ильдар: «слаг не работает сейчас. через хеш лучше думаю да. может быть типа a1b2c3d4e5f6-portal.bloodgpt.tech?»
[Н2] DNS-имя scope: {hash}.portal.bloodgpt.tech лучше чем {hash}-portal.bloodgpt.tech
| Формат | Wildcard | Conflict |
|---|---|---|
{hash}.portal.bloodgpt.tech | *.portal.bloodgpt.tech | Чисто — scoped к portal |
{hash}-portal.bloodgpt.tech | *.bloodgpt.tech | Ловит api, www, и любые другие неизвестные subdomain’ы |
{hash}.p.bloodgpt.tech | *.p.bloodgpt.tech | Короче, scoped, но менее очевидно |
Выбор: {hash}.portal.bloodgpt.tech. Wildcard *.portal.bloodgpt.tech не конфликтует со существующими A/CNAME записями на bloodgpt.tech (api, www).
[Н3] DDoS-защита для wildcard subdomain — 3 уровня
Контекст: возражение от Евгения (Жени, devops) при ревью архитектуры — “wildcard *.portal.bloodgpt.tech — кто угодно может слать на random123.portal.bloodgpt.tech”. Это важная проверка от инфраструктурной перспективы: wildcard subdomain — DDoS-vector если не ограничить. Решение:
Level 1 — Cloudflare Edge:
*.portal.bloodgpt.tech → proxied: true
+ Rate Limiting per IP
+ WAF rules (subdomain не /^[a-f0-9]{12}$/ → block)
+ Bot protection
Отсеивает 95% мусора до GKE.
Level 2 — Next.js Middleware (in-memory, без DB):
validHashes = new Set<string>() // 50 tenants = 50 строк в RAM
обновляется раз в минуту
if (!/^[a-f0-9]{12}$/.test(hash)) → 404 instant
if (!validHashes.has(hash)) → 404 instant
Невалидный host отбивается за микросекунды без DB.
Level 3 — GKE Gateway:
Rate limit на HTTPRoute, fallback если CF пропустил.
Текущий portal.bloodgpt.tech с одним доменом — тот же риск DDoS. Wildcard не делает хуже потому что in-memory Set делает reject одинаково быстрым на любой невалидный host.
[Н4] Cloudflare Custom Hostnames (бывш. “SSL for SaaS”) — стандартный механизм для client-owned доменов
GitBook, Shopify, Notion — все используют. Для нас Phase 2.
Что делает клиент — добавляет в свой DNS:
CNAME patient.pzalab.com → {hash}.portal.bloodgpt.tech
Одна запись. (Optional CAA для restrict CA.)
Что делаем мы — через Cloudflare API:
POST /zones/{zone_id}/custom_hostnames
{
"hostname": "patient.pzalab.com",
"ssl": { "method": "txt", "type": "dv" }
}
→ verification token + custom_hostname_id
CF автоматически:
- Выпускает SSL через ACME
- Маршрутизирует на наш origin
- Верифицирует (CNAME совпадает → ownership confirmed)
В session: для Африки уже использовалось ручное добавление через Cloudflare Dashboard (app.smartcarelabs.africa) — не автоматизированно, ломалось когда забывали ставить TXT для validation. Phase 2 = автоматизация через CF API + Inngest poll-функция для verification status.
[Н5] Cloudflare Transform Rule — переписать Host для custom доменов, чтобы HTTPRoute matched
Проблема: CF прокидывает оригинальный Host (patient.pzalab.com) → GKE HTTPRoute не сматчит если hostname не в списке. Для Африки приходилось руками добавлять hostname в values.yaml HTTPRoute → деплой через ArgoCD каждый раз.
Mechanics этого Host-rewriting лучше всего смотреть в реализации (CF Worker / Transform Rule config). Краткое резюме потоком: CF получает
patient.pzalab.com→ правило переписываетHostheader на наш wildcard target (e.g.,{hash}.portal.bloodgpt.tech) → GKE HTTPRoute матчит wildcard → ответ возвращается клиенту без изменений (CF проксирует прозрачно).
Решение (Phase 2 — одно правило на всех):
Cloudflare Request Header Transform Rule:
When: (not http.host contains "bloodgpt")
Then:
Set dynamic header: X-Original-Host = http.host
Set static header: Host = portal.bloodgpt.tech
Результат:
Browser: GET patient.pzalab.com/dashboard
→ CF: переписывает Host
Host: portal.bloodgpt.tech ← GKE видит наш домен → match
X-Original-Host: patient.pzalab.com ← Next.js читает → знает tenant
→ GKE: HTTPRoute portal.bloodgpt.tech → match ✅
В Next.js коде НИКОГДА не читать Host напрямую, всегда через хелпер:
function getHostname(req): string {
return req.headers.get('x-original-host') || req.headers.get('host')!;
}
// Использовать для: redirects, ссылок в emails, cookie domainsИначе Next.js будет генерировать редиректы на portal.bloodgpt.tech вместо patient.pzalab.com — сломается user experience.
[Н6] Cross-domain cookies — фундаментальное ограничение браузеров, обходится через token-redirect
Cookie Domain=.portal.bloodgpt.tech НЕ видна на patient.pzalab.com — это правило браузера, обойти нельзя. Все SaaS с custom доменами используют один из вариантов:
- Token redirect (стандарт) — описан ниже. Shopify, Notion, GitBook так делают.
- iframe + postMessage — устарел (browsers blocking third-party cookies)
- Центральный auth-домен — variant token redirect, но фронтенд логин всегда на одном поддомене
Token redirect flow (для Phase 2 custom доменов):
1. GET patient.pzalab.com/dashboard → no cookie → 302
2. → portal.bloodgpt.tech/auth/login → 302 → WorkOS
3. WorkOS: пользователь логинится → 302
4. → portal.bloodgpt.tech/auth/callback?code=xxx
→ exchange code → WorkOS session
→ создаём ONE-TIME token (Redis, TTL 30 сек)
→ 302
5. → patient.pzalab.com/auth/exchange?token=xyz
→ validate + delete token (one-shot)
→ Set-Cookie session=encrypted; Domain=patient.pzalab.com
→ 302
6. → patient.pzalab.com/dashboard ✅ cookie работает
User experience: видит form login на WorkOS (шаг 3), потом несколько мелькающих redirects (~200-400ms суммарно) → dashboard. Незаметно.
Phase 1 ({hash}.portal.bloodgpt.tech) — token exchange НЕ нужен, cookie шарится через .portal.bloodgpt.tech wildcard.
[Н7] WorkOS auth — single redirect_uri + state-parameter для return_to
WorkOS Dashboard конфигурация:
Redirect URIs:
https://portal.bloodgpt.tech/auth/callback ← один на ВСЕ tenants
Organization (per tenant):
PZA Lab (org_01H...)
→ SSO connection (опционально)
→ Branding (logo, colors — показывается на login form)
→ Members
Login flow:
// 1. Detect tenant из Host header в middleware
const orgSlug = resolveTenantFromHost(host);
const org = await getOrgBySlugOrHash(orgSlug);
// 2. Redirect to WorkOS с organization parameter
res.redirect(`https://authkit.bloodgpt.tech/sign-in
?organization=${org.workosOrgId} // ← конкретная org!
&redirect_uri=https://portal.bloodgpt.tech/auth/callback
&state=${JSON.stringify({ return_to: original_url })}`);
// 3. WorkOS показывает login форму с org branding
// Пользователь логинится → 302 на наш callback с code
// 4. Callback handler:
const session = await workos.exchangeCode(code);
const { return_to } = JSON.parse(state);
res.cookie('wos-session', session, { domain: '.portal.bloodgpt.tech' });
res.redirect(return_to);Ключевое: organization=org_01H... в WorkOS URL — показывает login именно для этой org (их SSO провайдер или email+password). State хранит return_to для возврата на правильный subdomain после auth.
Cross-tenant защита в callback handler:
// После exchange code → session содержит userId, orgId
if (session.orgId !== org.workosOrgId) {
return res.status(403); // пациент другой org
}Это catches случай когда юзер залогинен но пытается зайти на чужой subdomain.
[Н8] Tenant-resolution в Next.js middleware — сценарий-aware
function resolveTenant(host: string): { orgId, source } | null {
// 1. Hash-based (Phase 1)
if (host.endsWith('.portal.bloodgpt.tech')) {
const hash = host.split('.')[0];
if (!/^[a-f0-9]{12}$/.test(hash)) return null; // invalid format
return lookupByHash(hash); // in-memory cache
}
// 2. Custom domain (Phase 2)
// Host header переписан CF Transform Rule → читаем X-Original-Host
const original = req.headers.get('x-original-host');
if (original && !original.includes('bloodgpt')) {
return lookupByCustomDomain(original); // DB lookup, cached
}
// 3. Slug-based (legacy / direct portal access)
return lookupBySlug(host.split('.')[0]);
}Все три варианта поддерживаются параллельно.
[Н9] Email отправка с клиентского домена — отдельная domain authentication
Для noreply@pzalab.com нужны DNS records на стороне клиента (через email-провайдер типа SendGrid):
TXT _dmarc → "v=DMARC1; p=quarantine"
TXT v=spf1 → include:sendgrid.net ~all
CNAME s1._domainkey → s1.domainkey.u12345.wl.sendgrid.net
CNAME s2._domainkey → s2.domainkey.u12345.wl.sendgrid.net
Итого ~6 записей на клиентской стороне (3 для domain routing + 3 для email).
Source: Артём в Slack-треде поднял вопрос: «уведомления на почту пациента должны приходить с почты клиента (noreply@pzalab.com а не noreply@bloodgpt.com)».
В сессии — обсуждение, но реализация отложена.
[Н10] Существующая инфраструктура почти готова к hash-based subdomains
В session проверено: GKE Gateway уже имеет:
- Wildcard SSL
*.bloodgpt.tech(cert на GKE Gateway) - HTTPRoute в patient-portal с hostname
*.portal.bloodgpt.tech— уже настроен portal.bloodgpt.techDNS A record → GKE Gateway IP- Cloudflare proxied (edge SSL автоматически)
Не хватает только:
- DNS wildcard
*.portalвbloodgpt-stage-eu/project.hcl+bloodgpt-prod-eu/project.hcl *.portal.bloodgpt.techв SSL cert hostnames (common-infra/cloudflare-ssl/terragrunt.hcl)
Wildcard cert *.bloodgpt.tech покрывает ровно один уровень — portal.bloodgpt.tech ✅, но не abc.portal.bloodgpt.tech. Поэтому добавляем второй wildcard в cert spec.
Implementation note: На Cloudflare proxied трафик edge-cert выпускается автоматически на любой proxied hostname — то есть с CF браузер видит valid SSL для любого subdomain. Но GKE Gateway требует свой cert — оттуда нужно расширение *.portal.bloodgpt.tech в hostnames.
Решения [Р]
[Р1] Hash-based default subdomain {hash}.portal.bloodgpt.tech для Phase 1, custom client domain через CF Custom Hostnames для Phase 2
Статус: active (Phase 1), planned (Phase 2)
Пробовали:
- Slug-based
{slug}.portal.bloodgpt.tech— читаемо, но guessable, нет proof-of-ownership на стороне CNAME - Hash-based с dash
{hash}-portal.bloodgpt.tech— wildcard conflict (*.bloodgpt.techловит больше чем нужно) - Hash-based с dot
{hash}.portal.bloodgpt.tech— clean wildcard scope - Сразу Phase 2 (только client-domains, no default subdomain) — hard onboarding, требует от каждого клиента DNS-настройки
- Никаких subdomain (только path-based
/tenants/{slug}) — несовместимо с per-tenant cookies, custom branding
Выбрали: hash-based default + opt-in custom domain Phase 2.
Причина: zero-friction onboarding (новый tenant → instant URL), proof-of-ownership встроен в hash, infrastructure (wildcard DNS + cert) уже настроена с минимальными правками.
[Р2] Single WorkOS redirect_uri + per-tenant org-context через query-параметр
Статус: active
Пробовали:
- Per-tenant redirect_uri в WorkOS — масштабирование плохо (тысячи URI в WorkOS dashboard, требует API-управление)
- Wildcard redirect URI в WorkOS — WorkOS не поддерживает wildcards
- Single
redirect_uri+stateparameter с return_to — простое решение, return_to разруливается в callback handler
Выбрали: single redirect_uri. WorkOS dashboard содержит ровно одну запись. Tenant определяется через ?organization=org_01H... в URL. state хранит return_to для finalize redirect на правильный subdomain.
Это значит один callback handler на всех tenants — упрощает code и reduces config drift между Stage и Prod.
[Р3] Token-redirect для cross-domain cookies (Phase 2)
Статус: planned (Phase 2, не реализовано в этой сессии)
Пробовали:
- Shared cookie через
.portal.bloodgpt.tech— работает только для Phase 1 (hash subdomains), не для custom client domains (другой parent domain) - iframe с postMessage — отвергнуто, third-party cookies blocked в современных браузерах
- Token-redirect через Redis one-time-token — стандарт индустрии (Shopify/Notion/GitBook)
Выбрали: token-redirect. Phase 2 имплементация откладывается до запроса от первого реального custom-domain клиента.
[Р4] Cloudflare Transform Rule для Phase 2 — одно правило на всех custom domains
Статус: planned (Phase 2)
Пробовали:
- Ручное добавление каждого client hostname в HTTPRoute — текущий подход для Африки, требует ArgoCD деплой каждый раз. Сломалось как минимум один раз (“Timed Out Validation”).
- Wildcard HTTPRoute hostname — невозможно, нельзя match
*без расширения base hostname - Cloudflare Transform Rule переписывающее Host header — одно правило, никаких per-tenant записей в HTTPRoute
Выбрали: Transform Rule. Условие: (not http.host contains "bloodgpt") — ловит все non-наши домены. Set X-Original-Host = http.host + Host = portal.bloodgpt.tech. Next.js code reads X-Original-Host для tenant resolution.
Следствия для BloodGPT
(Черновик — не согласовано с Ильдаром.)
-
Hash-based onboarding = product velocity advantage. Каждый new tenant работает мгновенно, без DNS-плясок и terraform-applies. Это критично для self-service B2B onboarding (Lab signs up → URL ready immediately). У competitors с slug-based — это onboarding friction.
-
CF for SaaS как оборачивание commodity infrastructure — снимает classes проблем разом. SSL provisioning, DDoS edge, automatic verification — всё managed. Разница vs build-our-own = недели разработки + ongoing maintenance + cert rotations.
-
X-Original-Hostdiscipline в Next.js code — single point of failure если игнорится. Один разработчик пишетreq.headers.get('host')для генерации redirect URL — и custom-domain users получают broken UX. Стоит либо: (a) custom ESLint rule запрещающее direct host access, (b) typed wrapper utility с required-import. -
Hash как proof-of-ownership = clever security pattern для onboarding. Не надо separate verification flow с TXT records — сам факт что клиент CNAME-нул на наш hash-subdomain доказывает что он владеет своим доменом. Это сокращает один step в onboarding.
-
Один WorkOS redirect_uri = config-as-code minimalism. Stage и Prod имеют одну redirect URL запись. Меньше шансов on config drift, проще rotate/audit. Trade-off —
stateparameter критичен для security (signing/verification обязательны, иначе open redirect). -
Email-domain authentication (Phase 2) — separate workstream от SaaS routing. Слишком разные concerns: SaaS routing — про trust для traffic, email — про trust для outbound mail. Не пытаться one-flow обнять обоих.
-
DDoS-mitigation 3 levels = quasi-zero-cost защита. В каждом слое работа дешёвая (regex check, in-memory Set lookup, CF edge — все O(1)), и каждый catches что предыдущий пропустил. Operational защита почти бесплатна — настроить раз и работает.
Открытые вопросы [О]
[О1] Email domain authentication — implementation отложена
Артём в Slack-треде поднял “noreply@pzalab.com вместо noreply@bloodgpt.com”. Решение направления (SendGrid / Postmark domain auth) известно, но не реализовано. Зависит от того когда появится первый client с requirement.
[О2] Hash collision protection
sha256.slice(12) даёт 48 bits entropy — 2^48 ≈ 281 trillion. Collision probability мала (Birthday: ~16M tenants для 50% chance), но нужен detection при provisioning: попытка create org с уже существующим hash должна error-нуть и regenerate. Не реализовано в session.
[О3] WorkOS branding consistency на login
WorkOS показывает org branding (logo, colors) на login form. Но если custom-domain client попадает на authkit.bloodgpt.tech/sign-in — увидит WorkOS-domain в URL bar (не pzalab.com). Это user-experience break для white-label.
WorkOS “Custom Domain” feature exists (платный tier), но не настроено. Не решено — критично ли для нашего B2B segment или приемлемо.
[О4] Custom domain verification poll — design
CF API возвращает verification status, нужен Inngest scheduled function (cron каждые 5 мин для pending hostnames) — обновляет UI клиенту “ваш домен подключён”. В session описан flow, но не реализован.
[С1] HTTPRoute hostname management vs Transform Rule trade-off
Текущая практика для Африки — добавлять hostname в HTTPRoute через ArgoCD. После Phase 2 это должно прекратиться в пользу Transform Rule. Migration существующих client domains (Africa) на новый pattern — open work. Может потребовать temporary dual-mode.
Источники
Пересечения
technical/multi-tenant-fhir-storage.md— backend tenant isolation. Frontend routing (эта страница) + backend storage (там) дают полную multi-tenant story.technical/fhir-modeling-ai-content.md— отдельный concern (FHIR data model). Cross-link только для context: AI-output в FHIR ↔ B2B platform UI читает через эти routed endpoints.- TBD (после ingest сессий с продакшн опытом custom domains): Phase 2 implementation, real Africa migration на Transform Rule, WorkOS Custom Domain decision.
Сноски
-
Сессия
ildar/871a7608, 2026-02-13 — 9dc1-4fa7-8f9c-e0f2f0176b3d. ↩ -
Cloudflare Custom Hostnames (SSL for SaaS):, accessed 2026-05-17, https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/. ↩
-
Cloudflare Transform Rules:, accessed 2026-05-17, https://developers.cloudflare.com/rules/transform/. ↩
-
WorkOS organization parameter:, accessed 2026-05-17, https://workos.com/docs/sso/saml-oidc-integration/redirects. ↩
-
SaaS authentication best practices (WorkOS):, accessed 2026-05-17, https://workos.com/blog/saas-authentication. ↩
-
Cross-domain auth patterns (ReachFive):, accessed 2026-05-17, https://developer.reachfive.com/docs/cross-domain-auth.html. ↩