Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-4215Implemented

(motir-core) Claim and rename the workspace subdomain — the service, its reserved-name and retained-alias rules, and the project-settings routes

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.tsgetForWorkspace(workspaceId, actor), claim(workspaceId, label, actor), rename(workspaceId, newLabel, actor). Each write is ONE $transaction: lock the workspace row FOR UPDATE (two admins claiming at once is the warm-pool race entitlementsService documents), re-read the live subdomain inside the lock, validate (isReservedLabel, the label grammar, the ADR's rename cap counted from alias rows), then createSubdomain / retireSubdomainToAlias through the repository. A lost hostname race arrives as HostnameTakenError and maps to 409. 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 project canManage; 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 answer 404 { code } — a self-hosted build has no tenant addresses (ADR Q9).
  • The DTOlib/dto/publicAddresses.ts: PublicSubdomainDto { label, hostname, url, claimedAt, aliases: { hostname, retiredAt }[], renamesLeft }, with url composed from MOTIR_PUBLIC_TENANT_DOMAIN through a single accessor lib/publicAddresses/tenantDomain.ts (tenantBaseDomain() — read at call time, typed not-configured error; the one reader, asserted like appUrlSeam.test.ts asserts MOTIR_PUBLIC_SITE_URL's).
  • Routesapp/api/workspaces/[workspaceId]/public-subdomain/route.ts: GET (the DTO or null), PUT ({ label } — claim when none, rename when one exists), following the shape of app/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, one 409); the rename writes exactly one alias row and the cap refuses the N+1th; authorisation for a workspace member (read yes, write 403) 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-subdomain with a valid unused label answers 201 with the DTO; a reserved label, an invalid label and a taken label each answer 4xx with a distinct code; the same call on a self-hosted build (MOTIR_CLOUD unset) answers 404 { code }.
  • A rename retires the previous label to an alias row 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 member can GET and is refused PUT with 403; a non-member receives 404 on both.
  • tenantBaseDomain() is the only reader of MOTIR_PUBLIC_TENANT_DOMAIN in the repository (a grep-based test, the appUrlSeam.test.ts shape).
  • No file outside motir-core is 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.tslockOrgRowOrRefuse, the lock-then-count pattern to mirror on the workspace row
  • motir-core/lib/services/projectsService.ts — the change-key flow and ProjectKeyAlias write, the in-repo rename precedent
  • motir-core/app/api/projects/[key]/access/route.ts — the route shape; lib/workspaces/ — the workspace access helpers
  • motir-core/tests/hosting/appUrlSeam.test.ts — the single-reader assertion shape
  • motir-core/CLAUDE.md — lock-before-read-derived-update, typed errors, the 4-layer split