(motir-marketing) Host routing — a request on a tenant or customer host resolves through the public contract and renders that project's /p/* pages at the root
motir.co's renderer learns to answer for OTHER hosts. Today motir-marketing has no host awareness at all — no proxy.ts, no middleware, one SITE_ORIGIN baked at build time, and a /p/[identifier] route tree that assumes it is reached by that path on that host (measured on origin/main, 2026-09-02). This card adds the router that turns a request on a tenant or customer host into a render of the right project's existing pages, at the root of that host, without duplicating a single page.
What ships
proxy.ts(Next 16's middleware file — the namemotir-coreuses): on every request, readhost(andx-forwarded-host, which Fly sets — prefer the forwarded value and normalise to lowercase without a port). If the host ismotir.co/ the configuredSITE_ORIGINhost orlocalhost, do nothing — today's behaviour is untouched. Otherwise resolve it through the host contract (GET ${APP_ORIGIN}/api/public/hosts/{host}, honouring itsCache-Controlwith an in-process cache) and:workspace_subdomain→ rewrite/to the workspace's public-project list, and/<identifier>[/tab…]to/p/<identifier>[/tab…], when<identifier>is one of the resolution's projects; otherwisenotFound;workspace_subdomain_alias→301to the same path onredirectTo;custom_domain(issued) → rewrite/[tab…]to/p/<identifier>[/tab…]— the project lives at the host's root (ADR Q3);404from the contract → the site's not-found page (which MOTIR-4193 is giving a chrome);failed(the API unreachable) → the design's ERROR state, never a 404 (lib/publicProject.ts's three-outcome rule).- The rewrite carries the resolved host and kind to the page in request headers (
x-motir-public-host,x-motir-address-kind) so the pages can build host-relative links and the canonical card can decide redirects.
- The workspace root page — a small new route the rewrite targets (
app/w/[hostname]/page.tsxor the shape the design chose), listing that workspace's public projects as/explore-style cards, built todesign/public-projects/for chrome — no new design asset is needed for a list of the shipped project cards; if the design card's notes say otherwise, stop and read them. - Host-relative links:
projectTabHrefand everyhrefthe/p/*pages emit today are site-relative (/p/<identifier>/board); on a tenant host they must become/<identifier>/board(subdomain) or/board(custom domain). Add ONE helper (lib/publicHost.ts—publicPathFor(host, identifier, segment)) reading the request header, and route every link through it; a grep for'/p/'string literals inapp/p/**after this card finds only the helper. - Configuration:
NEXT_PUBLIC_MOTIR_TENANT_DOMAIN(the base domain) as a build arg infly.toml[build.args], theDockerfileARG,ci.yml's env and.env.example, exactly asNEXT_PUBLIC_MOTIR_APP_ORIGINtravels — with alib/tenantDomain.tsaccessor that throws a named error when unset in production and defaults for tests. (Set the value infly.tomlon this PR; the provisioning card covers themotir-coreside.) - Tests (jsdom / node): the router's four arms over a stubbed contract; host normalisation (
Hostwith port, uppercase,x-forwarded-hostprecedence); the link helper for all three host kinds; the cache honoursmax-age; the E2E lane can reach a tenant host — Chromium resolves*.localhostto loopback, soacme.localhost:<port>is the harness's tenant host and needs no/etc/hostschange; record this ine2e/stub/origin.tsfor the E2E card.
Boundary
No canonical, og:url, sitemap or robots change and no motir.co/p/* redirect — the next card. No page is duplicated: the existing app/p/[identifier]/** tree renders every host. No database client (the standing rule). The certificate is Fly's; this card never sees TLS.
Acceptance criteria
- With the contract stubbed, a request to
acme.<base>/PROD/boardrenders the same page asmotir.co/p/PROD/board;acme.<base>/renders the workspace's public-project list;roadmap.acme.com/boardrenders/p/PROD/boardfor the project the host resolves to; an alias host answers301to the live host with the path preserved. - A request on
motir.conever calls the host contract (asserted with a spy), and a request on an unknown host renders the site's not-found page with a404status. - Every
hrefemitted on a tenant host is host-relative (no/p/prefix) — a rendered-page test asserts it for the tab bar, the items pager and a detail link on each host kind. NEXT_PUBLIC_MOTIR_TENANT_DOMAINis set infly.toml, theDockerfile,ci.ymland.env.example, and the build fails with a named error when it is unset in production.pnpm testincludes a spec that walksacme.localhost:<port>in the Playwright lane's stub configuration, proving the tenant-host harness is reachable.- No
@prisma/clientor database URL appears in the repository after this card (the standing guard); no file outsidemotir-marketingis touched.
Context refs
motir-marketing/lib/publicProject.ts(PROJECT_TABS,projectTabHref, the three-outcome read) ·lib/siteOrigin.ts·lib/appOrigin.ts·app/p/[identifier]/layout.tsxmotir-marketing/fly.toml[build.args]·Dockerfile(ARG NEXT_PUBLIC_MOTIR_APP_ORIGIN) ·.github/workflows/ci.ymlenv ·.env.example— the build-time variable patternmotir-marketing/e2e/stub/origin.ts·playwright.config.ts— the lane this must stay reachable inmotir-core/proxy.ts— the middleware precedent (PUBLIC_REDIRECT_SEGMENTS, theCURRENT_PATH_HEADERwarning on trusting request-derived values)- the host contract — the DTO union this switches on; the decision — Q2, Q3
motir-marketing/design/public-projects/design-notes.md— the chrome and the states