(motir-core) The public surface honours registered addresses — CORS and the hand-off return target accept every registered public address, not only motir.co
Two shipped modules assume the public site has exactly ONE origin, and after this story it has many. Widen both to the set of REGISTERED addresses — read from the store, cached — and nothing else.
Measured on origin/main (2026-09-02): lib/publicProjects/cors.ts — publicCorsHeaders(origin) returns headers only when origin === publicSiteOrigin(); lib/publicProjects/returnTarget.ts — resolvePublicReturnTarget accepts a destination only when parsed.origin === publicSiteOrigin(). Both are correct today and both break the moment a page on acme.<base> pages an items list (a browser fetch to app.motir.co — CORS) or a visitor on roadmap.acme.com presses Follow (the hand-off's return — refused, so they land on the signed-in home instead of the page they left).
What ships
lib/publicAddresses/allowedOrigins.ts—isRegisteredPublicOrigin(origin): Promise<boolean>: true forpublicSiteOrigin(), forhttps://<live workspace_subdomain>, and forhttps://<issued custom_domain>— a read through the repository'sfindByHostname, cached in process for 60 s (a positive AND a negative cache, so a bot sprayingOriginvalues cannot turn the CORS check into a database probe per request). Aliases and non-issued domains are NOT origins that may act.cors.ts—publicCorsHeadersbecomes async and consults the registry; thenull-when-not-allowed contract,Vary: Origin, and neverAccess-Control-Allow-Credentialsare unchanged (AMENDMENT 4 §D: the allow-list is a convenience, not a trust boundary, because no credential can ride the request). Every caller ofpublicCorsHeadersupdated for the async signature —grepthem and list them in the PR.returnTarget.ts—resolvePublicReturnTargetaccepts a destination whose ORIGIN is a registered public origin; the positive-comparison posture is kept (an allow-list compared byURL.origin, never a reflection or astartsWith), and the fallback staysHANDOFF_FALLBACK_PATH.appUrlSeam.test.ts's single-reader rule forMOTIR_PUBLIC_SITE_URLstill holds: the registry callspublicSiteOrigin(), it does not read the variable.- Tests: table-driven over the origin set — the configured site, a live subdomain, an alias (refused), an
unverifiedand anissuedcustom domain (refused / allowed),https://motir.co@evil.test/and the other homographsreturnTarget.ts's header lists (all refused); the cache's negative arm (one DB read for N identical unknown origins); a real hand-off throughapp/act/route.tsreturning to a custom-domain page.
Boundary
No route added, no DTO change, no session or cookie attribute touched — sameSite: 'lax' and the host-only cookie are public-surface-hosts.md §4's and stay exactly as they are; a test in this card re-asserts the cookie attributes did not move. Reads the store only (the store).
Acceptance criteria
- A
fetchfrom a live tenant subdomain origin to a public read route receivesAccess-Control-Allow-Originechoing that origin andVary: Origin, and NOAccess-Control-Allow-Credentials; an alias origin and an unverified custom domain receive no CORS headers. app/act/route.tsreturns a visitor to areturnURL on anissuedcustom domain, and still falls back toHANDOFF_FALLBACK_PATHfor every homograph and for a non-registered origin.- N requests with the same unknown
Originwithin the cache window perform one repository read (asserted with a spy). lib/auth/index.ts's session-cookie attributes are byte-identical toorigin/main(a snapshot test), and noDomainattribute is introduced.- No file outside
motir-coreis touched.
Context refs
motir-core/lib/publicProjects/cors.ts·returnTarget.ts— the two modules, and their header comments on why the allow-list is positive and credential-freemotir-core/app/act/route.ts— the hand-off (MOTIR-4114)motir-core/docs/decisions/public-surface-hosts.md§4 + AMENDMENT 4 §B/§D — the constraints this must not loosenmotir-core/tests/hosting/appUrlSeam.test.ts— the single-reader rule- the store —
findByHostname