(motir-core) The public contract resolves a host — GET /api/public/hosts/{host}, and the subject DTO carries the project's addresses and its primary
Opened by Zhu Yue ·
The producer card for the other repository — the one anonymous read motir-marketing's router needs to turn a Host header into a project, and the address fields the renderer needs to name a canonical. Without this route the router has nothing to call, and a consumer card describing "resolve the host through the public contract" is describing an integration with two ends (plan-rules/phase-deepen.md's cross-repo limb) — this is the end that lives here.
What ships
GET /api/public/hosts/{host}—app/api/public/hosts/[host]/route.ts, in the shape ofapp/api/public/p/[identifier]/route.ts:publicSurfaceUnavailable()FIRST, then the rate-limit guard, no session read (this route is always anonymous — it is called server-side by the router before any page renders), onepublicAddressesService.resolveHost(host)call, error mapping. AnswersPublicHostResolutionDto:- for a live
workspace_subdomain:{ kind, workspace: { name }, projects: [{ identifier, name }] }— the public projects the host lists at its root (ADR Q2), each with its path; - for a
workspace_subdomain_alias:{ kind, redirectTo: <live hostname> }— the router 301s; - for an
issuedcustom_domain:{ kind, project: { identifier, name }, primary: boolean }; - for a custom domain in any other status, an unknown host, or the base domain itself:
404 { code }— the surface's one refusal shape, no existence leak (a bot enumerating hosts learns nothing). Cache-Control: public, max-age=60, stale-while-revalidate=300— the router will read this on EVERY request to a tenant host, and a resolution changes only when a customer acts.
- for a live
- The subject DTO gains its addresses —
PublicProjectOverviewDto.addresses: { primary: string (absolute URL), alternates: string[] }computed by the ADR's Q6 rule, inlib/dto/publicProjects.tsand the service mapper; the renderer namesprimaryin its canonical and treats every request on analternateshost as a redirect. - The crawl index gains
primaryHost—GET /api/public/projectsrows carry the host the project's primary lives on, somotir.co's sitemap can omit projects whose canonical is elsewhere (a sitemap may list only its own host's URLs) and a tenant host's sitemap can list only its own. - Contract: the operation and the two schema changes declared in
lib/api/public/openapi/operations.ts; a MINOR bump inlib/api/public/contractVersion.ts— read that file onorigin/mainimmediately before opening the PR and renumber if a sibling took the number (its own header names the hazard);contract-coverage,contract-drift,cloud-gate-totalityandanonymous-postureall green with no exemption added. - Service:
publicAddressesService.resolveHost(read-only,dbsingleton path asfindPublicByIdentifier), lower-casing and punycode-normalising the host, refusing anything with a port, path or scheme. - Tests: every branch of the DTO union; the 404 arms (unknown, non-issued, the base domain,
motir.coitself); the cache header; the subject DTO'saddressesunder each of the Q6 cases (no address, subdomain only, custom primary); the contract tests.
Boundary
Reads only — no writes, no session, no CORS change (that card), no rendering. The route is the PRODUCER; the consumer is the router in motir-marketing, which is blocked_by this card.
Acceptance criteria
GET /api/public/hosts/{host}answers each of the three200shapes above for fixtures of each kind, and404 { code: 'NOT_FOUND' }for an unknown host, a non-issued custom domain, the base domain andmotir.co; the gate runs before the rate limit and nogetSession()call exists in the file.- The response carries the
Cache-Controlvalue above, asserted in a test. PublicProjectOverviewDto.addresses.primaryfollows the ADR's Q6 default rule in a table-driven test (no address →motir.co/p/<id>; subdomain only → the subdomain path; custom primary set → that host), andalternateslists every other live address.GET /api/public/projectsrows carryprimaryHost; the OpenAPI document declares the new operation and both schema changes;contractVersion.tsis bumped MINOR against the value onorigin/mainat merge time.- The four public-surface guard tests are green with no exemption entries added.
- No file outside
motir-coreis touched.
Context refs
- the decision — Q2, Q3, Q6; the store —
findByHostname,listForWorkspace motir-core/app/api/public/p/[identifier]/route.ts·app/api/public/projects/route.ts— the shapes to copy (gate first,{ code }refusals, index paging)motir-core/lib/api/public/openapi/operations.ts·lib/api/public/contractVersion.ts— the contract and its serialized versionmotir-core/tests/api/public/—contract-coverage,contract-drift,cloud-gate-totality,anonymous-posturemotir-core/lib/dto/publicProjects.ts—PublicProjectOverviewDto;lib/services/publicProjectsService.ts—getOverview,listPublicForSitemapmotir-core/docs/decisions/public-surface-hosts.md§3 + AMENDMENT 1 §D — the contract's deprecation policy this addition lives under
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.