Skip to main content

Subdomain & Domain Routing — Org Portal vs Platform Host

Scope: the rules for when h.work uses the platform host vs a client org portal subdomain ({slug}.{HWORK_DOMAIN}), and the helpers/middleware that enforce them. Source of truth: frontend/src/middleware.ts, frontend/src/lib/subdomain.ts, frontend/src/contexts/AuthContext.tsx, and the backend GET /api/slugs/:slug/resolve.

Two environment-driven domain values, both must vary per environment, never hard-coded:

ValueVardevstagingprod
Portal zone (suffix for {slug}.{zone})NEXT_PUBLIC_HWORK_DOMAINh852.workh853.workh.work
Platform host (login/logout + HP-staff)NEXT_PUBLIC_APP_DOMAINdev.h852.workapp.h853.workapp.h.work

The platform host is its own env var (NEXT_PUBLIC_APP_DOMAIN) — it is NOT derivable from the zone, because the platform label differs per env (dev / staging / app). getPlatformUrl() returns its origin. Safety interlock: while NEXT_PUBLIC_APP_DOMAIN still equals NEXT_PUBLIC_HWORK_DOMAIN (its legacy duplicate value), it is treated as unset and login degrades to a relative /login — so behavior only activates once ops repoints it (#3076).

Host taxonomy

TypeExample (prod)OwnerPurpose
Platform host (NEXT_PUBLIC_APP_DOMAIN)app.h.work (dev dev.h852.work)Platform / globalLogin, logout, HP-staff surfaces (/ops/*, /workspace/*), and the post-login entry that routes clients to their portal
Infrastructure subdomain (reserved)dev.h852.work, staging.h853.work, prod.*, www, apiPlatformEnvironment landing; not any org's portal
Client org portal{slug}.h.work (e.g. kk-franky.h.work)One client orgThat org's client portal (/client/*)
Bare apexh.work (no label)PlatformMarketing/landing; a logged-in client is routed to their portal

Core rules

Rule 1 — Platform functions use the platform host, never an org subdomain

Login / logout / HP-staff back-office are platform-global. Their URL must be on the platform host (NEXT_PUBLIC_APP_DOMAIN), e.g. app.h.work in prod / dev.h852.work in dev.

  • A client logging out on {slug}.h.work must land on app.h.work/login, not {slug}.h.work/login (#3072 / #3076).
  • In code, build the target with getPlatformLoginUrl() (which reads the platform host from getPlatformUrl()) — never a relative /login from a client surface (a relative path stays on the current org subdomain).
    • On an in-zone org subdomain → https://{NEXT_PUBLIC_APP_DOMAIN}/login.
    • Already on the platform host, or off-zone (localhost / *.vercel.app), or NEXT_PUBLIC_APP_DOMAIN unset / still equal to the zone → relative /login (avoids a needless cross-host nav).

Rule 2 — Client business lives on the org's own subdomain

A client's /client/* portal belongs to their org; the canonical address is {slug}.{HWORK_DOMAIN}.

  • After login, client roles are hard-navigated to getPortalUrl(slug) = {slug}.h.work/client/chat (redirectAfterLogin, #2616).
  • HP-staff roles (superadmin / account_manager / expert / ai_operator) stay on the app host and go to /ops/* or /workspace/* — they are never sent to an org subdomain.
  • /client/* must never render on the platform host (#3102). Middleware handles a /client/* hit on the platform host as: authenticated client w/ workspace → 307 to {slug} portal (#3099); unauthenticated → platform login; HP-staff → their own portal; authenticated client with no org membership → branded no_org error ("You don't belong to any organization", /portal-not-found no_org variant).

Rule 3 — Reserved subdomains are never org slugs

Labels like dev / staging / app / www / api / login … are never a client slug. Two lists must stay in sync:

  • Backend canonical: api/src/config/reserved-slugs.ts
  • Frontend mirror: RESERVED_SLUGS in frontend/src/lib/subdomain.ts (must mirror the backend set)

Helpers:

  • getSubdomainSlug() — the org slug for the current host, or null for reserved/apex/server.
  • (#3088) A client logging in is always forwarded to their {slug} portal via redirectAfterLogin, regardless of which host they logged in on (platform host dev.h852.work/app.h.work, apex, or already on the portal). Only HP-staff stay on the platform host. (The earlier isClientStayHost() carve-out that kept clients on dev/staging was removed.)

Rule 4 — Subdomain routing is adjudicated in middleware

frontend/src/middleware.ts, per request:

  1. Extract the slug from Host (reserved/apex → treat as app host).
  2. Call the backend GET /api/slugs/:slug/resolveactive | deactivated | redirect | not_found.
  3. Act on the status:
    • active → pass through; feed orgId to the tenant-isolation gate.
    • redirect → 308 to the canonical slug host.
    • deactivated / not_found → rewrite to /portal-not-found (branded 404).
    • resolve unreachable → fail open (pass through), so a transient backend blip doesn't 404 every client.

Backend resolveSlug returns active only when an organizations row matches slug and slug_reserved = true and deactivated_at IS NULL. An org whose slug was never reserved resolves to not_found.

slug_reserved is set at the END of onboarding, not when the org is created. The slug is reserved only by the client "confirm your workspace" step (confirmSlugreserveSlug, api/src/onboarding/onboarding.service.ts), which runs after AM setup + Specialist assignment. So an org still in a pending_* status (pending_am_setup / pending_specialist_assignment / pending_client_confirmation) has slug_reserved = false → its {slug} portal correctly resolves to not_found and renders the branded 404, even though the org row exists. This is by design: an org row existing ≠ a live workspace. (resolveSlug deliberately does NOT distinguish "no such row" from "exists but unreserved" — both return not_found so a pending/taken slug isn't leaked.) Fix for a stuck org: finish onboarding (which reserves the slug), or — dev only — set slug_reserved = true manually.

The /portal-not-found page must read the reason from the x-pnf-reason request header (set by the middleware rewrite) — it must not write a cookie during render, which throws "Cookies can only be modified in a Server Action or Route Handler" and crashes the RSC render (#3025).

  • The hw_token cookie is scoped to domain=.{HWORK_DOMAIN} (#2616) so a redirect from app.h.work to {slug}.h.work keeps the session.
  • The JWT carries orgMemberships[].slug (#2616); the frontend and middleware use it to decide which subdomain to route to.
  • Logout clears that wildcard cookie (clearAuthCookie clears with the .{HWORK_DOMAIN} domain attribute).

"Which helper do I use?" quick reference

TaskUseDon't
Navigate to login/logoutgetPlatformLoginUrl()relative /login, an org {slug} host
Navigate to a client portalgetPortalUrl(slug)hand-built strings
"Is the current host an org subdomain?"getSubdomainSlug()hand-split the hostname
Get the portal-zone suffixprocess.env.NEXT_PUBLIC_HWORK_DOMAINhard-code h.work
Get the platform host/origingetPlatformUrl() (reads NEXT_PUBLIC_APP_DOMAIN)hard-code app. / derive from the zone
Add a reserved labeledit both api/src/config/reserved-slugs.ts and RESERVED_SLUGSedit only one (they drift)
  • #2616 — cross-subdomain session (wildcard cookie + slug in JWT) + post-login portal redirect
  • #3017 / #3018 / #3019 — (superseded by #3088) earlier kept clients on reserved infra subdomains
  • #3088 — clients ALWAYS route to their {slug} portal post-login; only staff stay on the platform host (removed isClientStayHost)
  • #2870 — apex-host client redirect to the portal
  • #3025 — /portal-not-found cookie-during-render crash → reason via request header
  • #3072 — login/logout must use the platform host, not the org subdomain (getPlatformLoginUrl)
  • #3076 — platform host is its own env var NEXT_PUBLIC_APP_DOMAIN (per-env; not derived from the zone)