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 backendGET /api/slugs/:slug/resolve.
Two environment-driven domain values, both must vary per environment, never hard-coded:
| Value | Var | dev | staging | prod |
|---|---|---|---|---|
Portal zone (suffix for {slug}.{zone}) | NEXT_PUBLIC_HWORK_DOMAIN | h852.work | h853.work | h.work |
| Platform host (login/logout + HP-staff) | NEXT_PUBLIC_APP_DOMAIN | dev.h852.work | app.h853.work | app.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
| Type | Example (prod) | Owner | Purpose |
|---|---|---|---|
Platform host (NEXT_PUBLIC_APP_DOMAIN) | app.h.work (dev dev.h852.work) | Platform / global | Login, 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, api | Platform | Environment landing; not any org's portal |
| Client org portal | {slug}.h.work (e.g. kk-franky.h.work) | One client org | That org's client portal (/client/*) |
| Bare apex | h.work (no label) | Platform | Marketing/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.workmust land onapp.h.work/login, not{slug}.h.work/login(#3072 / #3076). - In code, build the target with
getPlatformLoginUrl()(which reads the platform host fromgetPlatformUrl()) — never a relative/loginfrom 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), orNEXT_PUBLIC_APP_DOMAINunset / still equal to the zone → relative/login(avoids a needless cross-host nav).
- On an in-zone org subdomain →
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 → brandedno_orgerror ("You don't belong to any organization",/portal-not-foundno_orgvariant).
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_SLUGSinfrontend/src/lib/subdomain.ts(must mirror the backend set)
Helpers:
getSubdomainSlug()— the org slug for the current host, ornullfor reserved/apex/server.- (#3088) A client logging in is always forwarded to their
{slug}portal viaredirectAfterLogin, regardless of which host they logged in on (platform hostdev.h852.work/app.h.work, apex, or already on the portal). Only HP-staff stay on the platform host. (The earlierisClientStayHost()carve-out that kept clients on dev/staging was removed.)
Rule 4 — Subdomain routing is adjudicated in middleware
frontend/src/middleware.ts, per request:
- Extract the slug from
Host(reserved/apex → treat as app host). - Call the backend
GET /api/slugs/:slug/resolve→active|deactivated|redirect|not_found. - Act on the status:
active→ pass through; feedorgIdto 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_reservedis set at the END of onboarding, not when the org is created. The slug is reserved only by the client "confirm your workspace" step (confirmSlug→reserveSlug,api/src/onboarding/onboarding.service.ts), which runs after AM setup + Specialist assignment. So an org still in apending_*status (pending_am_setup/pending_specialist_assignment/pending_client_confirmation) hasslug_reserved = false→ its{slug}portal correctly resolves tonot_foundand renders the branded 404, even though the org row exists. This is by design: an org row existing ≠ a live workspace. (resolveSlugdeliberately does NOT distinguish "no such row" from "exists but unreserved" — both returnnot_foundso a pending/taken slug isn't leaked.) Fix for a stuck org: finish onboarding (which reserves the slug), or — dev only — setslug_reserved = truemanually.
The
/portal-not-foundpage must read the reason from thex-pnf-reasonrequest 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).
Rule 5 — Cross-subdomain session (cookie + JWT)
- The
hw_tokencookie is scoped todomain=.{HWORK_DOMAIN}(#2616) so a redirect fromapp.h.workto{slug}.h.workkeeps 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 (
clearAuthCookieclears with the.{HWORK_DOMAIN}domain attribute).
"Which helper do I use?" quick reference
| Task | Use | Don't |
|---|---|---|
| Navigate to login/logout | getPlatformLoginUrl() | relative /login, an org {slug} host |
| Navigate to a client portal | getPortalUrl(slug) | hand-built strings |
| "Is the current host an org subdomain?" | getSubdomainSlug() | hand-split the hostname |
| Get the portal-zone suffix | process.env.NEXT_PUBLIC_HWORK_DOMAIN | hard-code h.work |
| Get the platform host/origin | getPlatformUrl() (reads NEXT_PUBLIC_APP_DOMAIN) | hard-code app. / derive from the zone |
| Add a reserved label | edit both api/src/config/reserved-slugs.ts and RESERVED_SLUGS | edit only one (they drift) |
Related issues / ADRs
- #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 (removedisClientStayHost) - #2870 — apex-host client redirect to the portal
- #3025 —
/portal-not-foundcookie-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)