(motir-core) The customer-domain lifecycle — add, verify, issue, make primary, remove — its tier gate, and the project-settings routes
Opened by Zhu Yue ·
The service that walks a customer domain through its life — add it, prove the customer owns it, ask the platform for its certificate, make it the project's canonical, take it away — and the four thin routes the settings pane calls. Built over the store, behind the certificates port, and guarded by the entitlement; the state machine is the ADR's Q4–Q6.
The service — lib/services/customDomainService.ts
| operation | what happens, in order |
|---|---|
add(projectId, hostname, actor) | normalise (lowercase, punycode, no scheme / port / path); refuse the base domain and anything under it, motir.co, and any reserved label (typed errors); one $transaction: assertCanAddCustomDomain(orgId, tx) → createCustomDomain(tx) as unverified with a random verificationToken; HostnameTakenError → 409. Returns the DTO with the DNS instructions: the TXT (_motir-verify.<host> = token) and the pointing record — CNAME → <FLY_CERTS_APP>.fly.dev for a subdomain, A/AAAA for an apex (the app's addresses, read once through the adapter's check or configured — as the ADR's Q4 settles). |
verify(addressId, actor) | outside any transaction: the resolver's resolveTxt('_motir-verify.<host>') must contain the token → verifying → then certificates.request(hostname) (the port) → pending_certificate on success; a DNS miss → stays unverified with a failureReason the pane can show; a platform refusal → failed with the platform's reason. Each is a short write after the side effect, never around it (CLAUDE.md). |
makePrimary(addressId, actor) | only an issued row; setPrimary(tx) on the project; a non-issued row → typed refusal. |
clearPrimary(projectId, actor) | back to the ADR's Q6 default rule. |
remove(addressId, actor) | delete the row in a transaction (the FK's SetNull clears a primary), THEN certificates.remove(hostname) after commit; a platform failure is logged with the hostname and does NOT fail the request — a certificate left on the platform for a hostname no longer pointed at us protects nothing (verify and cite Fly's renewal behaviour for a hostname that stops validating, in the code comment). |
list(projectId, actor) | the DTOs, with isPrimary derived. |
Authorisation: every write asserts the project's manage permission (project:manage_access, the key projectMembersService uses for the access level — read it, do not guess); reads need browse. Cloud gate: isCloud() false ⇒ typed refusal ⇒ 404 { code }.
The two external dependencies, and the SEAM for each — owed here, because this card names the test home
The verify path crosses two systems an automated lane cannot reach: DNS and Fly. The service therefore takes both through injectable ports — CertificateProvider (already the adapter's port) and a DnsResolver port (resolveTxt(name): Promise<string[]>) with the node:dns/promises implementation as the production binding — and a single factory (lib/publicAddresses/providers.ts) selects the production bindings unless the lane's server env sets MOTIR_E2E_FAKE_PUBLIC_ADDRESS_PROVIDERS=1, in which case it binds an in-memory CertificateProvider that reports issued on the first check and a resolver that answers the stored token. The flag is read at call time, is refused in production builds (NODE_ENV === 'production' ⇒ ignored, with a test proving it), and is documented in the E2E lane's server env. Without this seam the E2E card cannot reach issued and would pass vacuously or stub the API in the browser — the failure type-test.md's tell (d) names.
The DTO and routes
lib/dto/publicAddresses.ts—PublicAddressDto { id, kind, hostname, status, isPrimary, verification: { name, value } | null, dns: { type, name, value }[], lastCheckedAt, issuedAt, failureReason };statusis the store's enum crossing the wire as a string-literal union, TOTAL.app/api/projects/[key]/public-addresses/route.ts(GETlist ·POSTadd),…/[addressId]/verify/route.ts(POST),…/[addressId]/primary/route.ts(POSTset ·DELETEclear),…/[addressId]/route.ts(DELETE) — parse → gate → one service call → error mapping, in the shape ofapp/api/projects/[key]/access/route.ts;EntitlementExceededErrormaps to the status and body the billing routes already use for a cap, so the pane's upgrade prompt fires from the same shape.
Tests
Every operation's happy path and every typed error; the DNS verification with a stubbed resolver (token present, absent, wrong); the platform port stubbed for request success / refusal / unavailable; remove with a failing platform call still answers 2xx and logs; makePrimary refuses a non-issued row; add at the cap surfaces the entitlement error through the route; the manage gate (member → 403, non-member → 404); the factory binds production by default, the fakes under the flag, and never the fakes in a production build.
Boundary
No polling (the status job carries later state changes home), no UI, no public-surface change, no subdomain logic (that service).
Acceptance criteria
POST /api/projects/{key}/public-addresseswith a valid hostname answers201with a DTO carrying theTXTand the pointing record; the base domain, a reserved label,motir.coand a taken hostname each answer4xxwith distinct codes; at the cap it answers the billing surface's entitlement-exceeded shape withentitlement: 'custom_domains'.POST …/verifymovesunverified → pending_certificatewhen the stubbed resolver returns the token and the stubbed port accepts; leavesunverifiedwith afailureReasonon a DNS miss; moves tofailedwith the platform's reason on a refusal — and in every case the platform call and the DNS lookup happen outside the write transaction (asserted by the transaction spy pattern the repo's side-effect tests use).POST …/primarysucceeds only forissued;DELETE …/{id}on the primary leavesProject.primaryAddressIdnull and the platform remove is attempted after commit; a failing remove still answers204.- The provider factory binds the fakes only under
MOTIR_E2E_FAKE_PUBLIC_ADDRESS_PROVIDERS=1and never whenNODE_ENVisproduction, both asserted; the E2E lane's server env is documented as the flag's home. - Every route file is parse → gate → one call → mapping; the manage gate and the cloud gate are tested on each.
- No file outside
motir-coreis touched.
Context refs
- the decision — Q3, Q4, Q5, Q6, Q8; the store; the port; the entitlement
motir-core/lib/services/projectMembersService.ts— the manage permission the access level asserts;lib/services/entitlementsService.ts— the cap-inside-the-create-transaction contractmotir-core/app/api/projects/[key]/access/route.ts— the route shape; the billing routes' entitlement-exceeded mappingmotir-core/CLAUDE.md— side effects after commit, typed errors, the 4-layer split;playwright.config.tsand the cloud lane's server env — where the seam flag lives- Fly —
https://fly.io/docs/networking/custom-domain/(validation methods, what happens when a hostname stops validating)
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.