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.tech wildcard — Phase 1 не требует cross-domain cookie-workaround

Ильдар: «слаг не работает сейчас. через хеш лучше думаю да. может быть типа a1b2c3d4e5f6-portal.bloodgpt.tech?»

[Н2] DNS-имя scope: {hash}.portal.bloodgpt.tech лучше чем {hash}-portal.bloodgpt.tech

ФорматWildcardConflict
{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 → правило переписывает Host header на наш 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.tech DNS 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 + state parameter с 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-Host discipline в 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 — state parameter критичен для 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.

Источники

Источники: 1 2 3 4 5 6.

Пересечения

  • 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.

Сноски

  1. Сессия ildar/871a7608, 2026-02-13 — 9dc1-4fa7-8f9c-e0f2f0176b3d.

  2. Cloudflare Custom Hostnames (SSL for SaaS):, accessed 2026-05-17, https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/.

  3. Cloudflare Transform Rules:, accessed 2026-05-17, https://developers.cloudflare.com/rules/transform/.

  4. WorkOS organization parameter:, accessed 2026-05-17, https://workos.com/docs/sso/saml-oidc-integration/redirects.

  5. SaaS authentication best practices (WorkOS):, accessed 2026-05-17, https://workos.com/blog/saas-authentication.

  6. Cross-domain auth patterns (ReachFive):, accessed 2026-05-17, https://developer.reachfive.com/docs/cross-domain-auth.html.