(motir-core) Claim and rename the workspace subdomain — the service, its reserved-name and retained-alias rules, and the project-settings routes
Opened by Zhu Yue ·
The service and routes behind a workspace's tenant subdomain — claim it, rename it with the old name retained as a permanent redirect, and read it back as a DTO — the middle two layers over the store. Custom domains are a separate card; this one is the FREE tier's address and it must work on every tier.
What ships
lib/services/publicSubdomainService.ts—getForWorkspace(workspaceId, actor),claim(workspaceId, label, actor),rename(workspaceId, newLabel, actor). Each write is ONE$transaction: lock the workspace rowFOR UPDATE(two admins claiming at once is the warm-pool raceentitlementsServicedocuments), re-read the live subdomain inside the lock, validate (isReservedLabel, the label grammar, the ADR's rename cap counted from alias rows), thencreateSubdomain/retireSubdomainToAliasthrough the repository. A losthostnamerace arrives asHostnameTakenErrorand maps to409. Typed errors for reserved, invalid, taken, cap reached, and no subdomain yet on rename.- Authorisation: a subdomain is a WORKSPACE-level resource (ADR Q2), so claim and rename require workspace
owner/admin(WorkspaceMembership.role), asserted in the service via the shipped workspace access helpers — not projectcanManage; reads are allowed to any workspace member. Say so in the service header and test both gates. - Cloud gate:
isCloud()false ⇒ the service refuses with the same typed error the public routes'publicSurfaceUnavailable()implies, and the routes answer404 { code }— a self-hosted build has no tenant addresses (ADR Q9). - The DTO —
lib/dto/publicAddresses.ts:PublicSubdomainDto { label, hostname, url, claimedAt, aliases: { hostname, retiredAt }[], renamesLeft }, withurlcomposed fromMOTIR_PUBLIC_TENANT_DOMAINthrough a single accessorlib/publicAddresses/tenantDomain.ts(tenantBaseDomain()— read at call time, typed not-configured error; the one reader, asserted likeappUrlSeam.test.tsassertsMOTIR_PUBLIC_SITE_URL's). - Routes —
app/api/workspaces/[workspaceId]/public-subdomain/route.ts:GET(the DTO ornull),PUT({ label }— claim when none, rename when one exists), following the shape ofapp/api/projects/[key]/access/route.ts: parse → gate → one service call → error mapping; no logic in the route. - Tests: the service's happy paths and every typed error; a real-concurrency test (two claims of one label in parallel → one
201, one409); the rename writes exactly one alias row and the cap refuses the N+1th; authorisation for a workspacemember(read yes, write403) and a non-member (404).
Boundary
No UI (the pane), no custom domains (the lifecycle), no public read of a host (the host contract), no redirect behaviour (that is the router's, in the other repository). The "redirect forever" promise is DATA here — the alias row exists and is never deleted — and behaviour there.
Acceptance criteria
PUT /api/workspaces/{id}/public-subdomainwith a valid unused label answers201with the DTO; a reserved label, an invalid label and a taken label each answer4xxwith a distinctcode; the same call on a self-hosted build (MOTIR_CLOUDunset) answers404 { code }.- A rename retires the previous label to an
aliasrow in the same transaction and refuses when the ADR's cap is reached; a test proves the alias row survives and the old hostname cannot be claimed by another workspace afterwards. - Two concurrent claims of the same label produce exactly one success under a real connection pool (a serial test is not sufficient).
- A workspace
membercanGETand is refusedPUTwith403; a non-member receives404on both. tenantBaseDomain()is the only reader ofMOTIR_PUBLIC_TENANT_DOMAINin the repository (a grep-based test, theappUrlSeam.test.tsshape).- No file outside
motir-coreis touched; the route files contain no business logic.
Context refs
- the decision — Q2, Q7, Q9; the store — the repository methods this calls
motir-core/lib/services/entitlementsService.ts—lockOrgRowOrRefuse, the lock-then-count pattern to mirror on the workspace rowmotir-core/lib/services/projectsService.ts— the change-key flow andProjectKeyAliaswrite, the in-repo rename precedentmotir-core/app/api/projects/[key]/access/route.ts— the route shape;lib/workspaces/— the workspace access helpersmotir-core/tests/hosting/appUrlSeam.test.ts— the single-reader assertion shapemotir-core/CLAUDE.md— lock-before-read-derived-update, typed errors, the 4-layer split
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.