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-4206Implemented

Decision (ADR) — public tenant addresses: the base domain, what a subdomain names, the customer-domain lifecycle on Fly certificates, the one-primary canonical, the never-released rename, and the tier gate

Write docs/decisions/public-tenant-addresses.md — the record every other card in this story builds against — and amend public-surface-hosts.md where its §4 reversal condition hands this story a question. Convention as work-item-type-taxonomy.md sets it: Status → Context → Decision → Consequences, load-bearing facts pinned in tables, one authoritative source per shape. No application behaviour ships here; what it freezes is what makes the store, the services, the router and the settings pane buildable.

The questions, and the recommendation each arrives with

Each is answered from rung 1 (the mirror, fetched — the observation and its URL go in the record) and rung 2 (shipped code on origin/main, cited by path). The recommendations below are the planner's; the ADR confirms or overturns each with the evidence beside it.

#questionrecommendation, with its grounds
Q1Which base domain do tenant subdomains hang off?A separate registrable domain, not a subdomain of motir.co. public-surface-hosts.md §4's reversal condition says so and why: a base on a subdomain forces three-level addresses a *.motir.co certificate does not cover, and per-tenant content under the session's registrable domain multiplies the exposure §4 accepted once. Every mirror does this (notion.site, canny.io, statuspage.io, github.io, vercel.app). Name a ranked shortlist and check each candidate's availability by RDAP (https://rdap.org/domain/<name>) — an agent-runnable check — so the buying card gets a list, not a guess. Registrar stays Spaceship (marketing-site-hosting.md §3 — no new vendor, no subprocessor row).
Q2What does a subdomain NAME — an organization, a workspace, or a project?The WORKSPACE. The story's own criterion says a workspace claims it; Project.identifier is unique per workspace (prisma/schema.prisma, ProjectKeyAlias @@unique([workspaceId, identifier])), so acme.<base>/<identifier> is unambiguous where an organization-level address is not (two workspaces of one org may both hold PROD). Organization.slug @unique (organization-url.md §3) is substrate and stays untouched — say so. The workspace root acme.<base>/ lists that workspace's public projects (or is the one project when there is one). Mirror: Notion (a workspace claims one notion.site domain), Canny (a company subdomain, boards under it).
Q3What does a customer domain name?ONE public project, at its rootroadmap.acme.com/board, the way a Statuspage page or a Productboard portal answers at the root of its domain. So an address is either workspace-subdomain + project path or customer-domain root; the store (next card) holds both shapes in one table.
Q4Which customer hostnames are accepted, and how is ownership proven?Any hostname the customer can point — a subdomain by CNAME to the app's Fly hostname, an apex by A/AAAA to the app's IPs (Fly issues both; marketing-site-hosting.md §3 already relies on this shape at motir.co). Ownership is proven by a TXT record (_motir-verify.<host> carrying a per-address token) BEFORE a certificate is requested — Notion's shape; Canny relies on the CNAME alone. State why the TXT is worth its friction (a domain must not be claimable by whoever points it first).
Q5Who issues and renews the certificate?Fly, per hostname, on the motir-marketing app, driven from motir-core through Fly's certificates API (POST /v1/apps/{app}/certificates/acme · …/{hostname}/check, and fly certs add for the wildcard the human runs). Fly renews; we surface the state. The wildcard *.<base> covers every tenant subdomain in ONE certificate (_acme-challenge CNAME delegation). Rejected: Cloudflare for SaaS (a third vendor and a subprocessor row — marketing-site-hosting.md §5's reasoning), a self-run ACME client (a second certificate system beside the one the platform already operates). Note Let's Encrypt's per-registered-domain limits and why they do not bind here.
Q6The canonical rule.Exactly one PRIMARY address per project. Default primary: motir.co/p/<identifier> until a subdomain is claimed, then the subdomain address; a customer domain becomes primary only by an explicit make primary (Canny's make primary"the canonical source"). Every other address for the same page 301s to the primary; canonical, og:url, JSON-LD @id, the sitemap and the Atom feed's links all name the primary. Consequence stated: once a primary exists off motir.co, motir.co/p/* for that project is a redirect — which REDUCES the §4 exposure.
Q7Rename, and the reserved set.An old subdomain keeps redirecting and is never released (Atlassian: a changed site URL "remains as a redirect" and cannot be re-used by another organization; ProjectKeyAlias is the in-repo precedent). Renames are capped. ⚠️ AMENDED ON THE RECORD (MOTIR-4206's own run, 2026-09-03): this card asserted “Atlassian caps at 3”. Read from the cited surface (https://support.atlassian.com/organization-administration/docs/update-a-product-url/) the documented figure is 15“You can update the subdomain up to 15 times.” The instruction (decide a number) stands; the number offered as grounds was not the mirror's. The ADR decides 5, with the deviation's use case stated. Planning bug filed under MOTIR-1465. The reserved-name set is a constant in code, enumerated in the ADR: Motir's own hosts (www, app, api, mail, status, docs, help, support, blog, admin, assets, cdn, motir, moooon), single characters, a minimum length, and the impersonation list.
Q8The tier gate.A new EntitlementKind custom_domains and maxCustomDomains on PmEntitlements (lib/billing/entitlements.ts), read through entitlementsService like every §4 cap — tenant subdomains are free for every tier; custom domains are gated. The ADR NAMES the gate and its provisional seed values (free: 0, scaled: 5, enterprise / meta: unlimited) and states that the VALUES are billing-tiering.md's to change (Story 8.1 / 8.6) — the story ships the capability and the gate it reads, not the price (Notion, Canny and Statuspage all gate custom domains to paid).
Q9The open-core line.The whole capability is cloud-only behind isCloud() — a self-hosted build has no public projects (public-surface-hosts.md §5), so it has no addresses; the settings pane and every route inherit publicSurfaceUnavailable() / isCloud().
Q10Branding on a customer address.The public chrome stays Motir's on every address — no white-label in this story. Recorded as a DECISION with a reversal condition (a paying customer asks for it), not as a deferral.

What the ADR also owes

  • The §4 reversal condition, revisited on the record. public-surface-hosts.md §9 names this story as where the separate-domain decision is taken; write the amendment that closes it (read the last AMENDMENT heading on origin/main before opening the PR — the ordinal moved twice in one day on MOTIR-4108; it is AMENDMENT 5 as of 2026-09-02). organization-url.md's reversal condition is not triggered — Q2 explains why the address names a workspace, not an organization — say so in one line.
  • Every deferral in the record cites a card. The Public Suffix List submission → the PSL card; the per-tier values → billing-tiering.md (Story 8.1); nothing else may be left "for later" without a key.
  • The configuration it decides is NAMED here, so the provisioning card can run before any code lands: MOTIR_PUBLIC_TENANT_DOMAIN (the base, on both apps — on motir-marketing as a NEXT_PUBLIC_* build arg, the way lib/siteOrigin.ts is configured), and FLY_CERTS_TOKEN + FLY_CERTS_APP on motir-core (a token scoped to the motir-marketing app only, following FLY_FLEET_API_TOKEN's "never the token something else deploys with" rule in lib/orchestrator/adapters/fly/flyMachines.ts).
  • The consequences table, card by card, binding the store, the subdomain service, the certificates adapter, the lifecycle, the host contract, the router and the canonical card.

Acceptance criteria

  • docs/decisions/public-tenant-addresses.md exists on the PR, structured Status → Context → Decision → Consequences, and answers Q1–Q10 each with a decision, at least one rejected alternative, and the rung-1 observation (mirror + URL, as fetched) and/or rung-2 path it rests on.
  • Q1 names a ranked shortlist of base-domain candidates with each one's RDAP availability reading and the command that produced it.
  • Q7 enumerates the reserved-name set and the rename cap as literal values, and Q8 names the entitlement kind, the field and its provisional per-tier values, citing billing-tiering.md as the owner of the numbers.
  • The record names every environment variable this story introduces, on which Fly app each lives, and whether it is a runtime secret or a NEXT_PUBLIC_* build argument.
  • public-surface-hosts.md gains the amendment closing §4's reversal condition, numbered from the last heading on origin/main at merge time, and its §9 entry for MOTIR-3878 is struck or updated.
  • Every "later" / "deferred" / "not this story" sentence in the record cites a work-item key or an ADR that owns it (grep the file for the phrase list before opening the PR).
  • No application code, schema or copy changes in this PR.

Context refs

  • motir-core/docs/decisions/public-surface-hosts.md — §4 (the cookie, the deviation, the reversal condition), §5 (cloud gate), §9 (what it does not decide), AMENDMENT 4 §B (sameSite: 'lax')
  • motir-core/docs/decisions/organization-url.md — §3 (Organization.slug stays as substrate) and its reversal condition
  • motir-core/docs/decisions/marketing-site-hosting.md — §3 (Spaceship zone, Fly A/AAAA, the apex MX/TXT constraint), §5 (subprocessor reasoning)
  • motir-core/docs/decisions/billing-tiering.md · lib/billing/entitlements.ts (PmTier, EntitlementKind, PM_ENTITLEMENTS) · lib/services/entitlementsService.ts
  • motir-core/prisma/schema.prismaProject.identifier, ProjectKeyAlias, Organization.slug @unique
  • motir-core/lib/publicProjects/urls.ts (publicSiteOrigin) · cors.ts · returnTarget.ts — the three places that currently assume ONE public origin
  • motir-core/lib/orchestrator/adapters/fly/flyMachines.ts — the Fly boundary and its token rule
  • MOTIR-3879 — the ADR this one sits beside; MOTIR-4108 — the amendment whose ordinal moved twice
  • Fly — https://fly.io/docs/networking/custom-domain/ · https://fly.io/docs/networking/custom-domain-api/ · https://fly.io/docs/flyctl/certs/
  • Notion — https://www.notion.com/help/connect-a-custom-domain-with-notion-sites; Canny — https://help.canny.io/en/articles/1355038-setting-up-your-custom-domain; Statuspage — https://support.atlassian.com/statuspage/docs/set-a-custom-domain-and-ssl/; Atlassian — https://support.atlassian.com/organization-administration/docs/update-your-product-and-site-url/