MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
Back to roadmap
Blocked

Public project pages join the brand host — /p/* answers on motir.co, and leaves the session's origin behind

Storyopened by Zhu YueMOTIR-3877

⚠️ RE-SCOPED 2026-08-29 — the destination is decided, and so is the build

This story asked which hostname a public project is addressed at and left the answer open. It is motir.co (Yue, 2026-08-29), not a namespace of its own — and two further decisions land on it:

  • The code stays in motir-core, which this story already argued for below and measured. Nothing changes in that analysis; it is the reason the answer is an ADDRESS change and not a port.
  • /p/* is CLOUD-ONLY. Build-in-public is a hosted-service feature; a self-hosted Motir is single-tenant, a team doing project management for itself. The MOTIR_CLOUD gate is the cloud-gate story's deliverable, not this one's — but this story must not assume /p/* renders on every build.

⚠️ And the trade the destination makes, which this story now owns. The research below concluded that tenant-authored content belongs on a host of its own, because a cross-site-scripting hole in user content reaches whatever session shares its origin — the argument GitHub acted on when Pages left *.github.com. motir.co achieves the primary goal: /p/* leaves app.motir.co, so the authenticated session's origin stops rendering tenant markdown. It achieves it at lower isolation than the mirrors chose, because first-party content and tenant content then share one origin. That is acceptable only while the session cookie is host-only on app.motir.co — so this story asserts that, rather than assuming it, and its test gate is where the assertion lives.


A customer's public project stops living inside the application's origin. /p/<identifier> and its five tabs are TENANT-authored content — markdown, comments, feature requests, votes — and today they are served from the same host, and inside the same cookie scope, as the authenticated product.

The question this story answers, and the one it does not

It answers which hostname a public project is addressed at. It does not move any code out of motir-core, and the difference between those two is the whole story.

⚠️ motir-marketing cannot render /p/* from the public API — measured on origin/main

app/api/public/ exists and is more complete than it looks: /api/public/explore, /api/public/categories, /api/public/p/[identifier]/{tree,items,roadmap,changelog,follow,subscribe} and /api/public/projects/[projectId]/requests. Four findings kill it as a contract for a second renderer:

  1. There is no app/api/public/p/[identifier]/route.ts. The project itself — name, overview/README, tags, workspace, the hero — has NO public endpoint. A second renderer cannot fetch the page's own subject, only its lists.
  2. Six of those routes call getSession() (tree, items, roadmap, changelog, follow, subscribe). They are viewer-aware, not anonymous-only, so a second renderer either loses the viewer or needs the session — which is the thing this story is separating.
  3. /api/public/* is not in the versioned contract. docs/decisions/public-api-conventions.md governs /api/v1, and the published OpenAPI document is v1 REST only. Making a second repository a consumer promotes an internal BFF to a cross-repo contract with no version, no deprecation policy and no contract test.
  4. /p/* is an application, not a page. It is PublicWorkItemTree, PublicRoadmap, PublicChangelog, PublicWorkItemList, PublicRequestComments, PublicRoadmapVote, PublicSubmitRequestForm, PublicFollowControl and PublicOverviewEditor, over overview-actions.ts Server Actions, RLS-bound reads and project access gates. Rebuilding it against JSON is a second motir-core reached through HTTP instead of Prisma.

So the renderer stays where the data and the gates are, and the ADDRESS moves. Repository placement and URL placement are different questions; conflating them is what makes every option in this area look like a hack.

Why it leaves app.motir.co at all

Not for ranking. getSession() appears in twelve files under app/(public)/p/, and what those pages render is content a tenant wrote — MarkdownView over an authored overview, visitor comments, submitted requests. That is precisely the arrangement GitHub dismantled when it moved Pages off *.github.com: its engineering record ("Yummy cookies across domains") describes cookie tossing, session fixation, forced logout and the phishing credibility a parent domain lends, and github.io, notion.site and vercel.app are on the Public Suffix List so that browsers refuse to share cookies with them.

Every mirror that ships tenant public pages does the same thing — Notion <workspace>.notion.site, Canny <company>.canny.io, Statuspage <company>.statuspage.io, Productboard's portal — and each sells a custom domain on top.

The rejected option is worth naming: widening the Better-Auth session cookie to .motir.co. It would make a split trivially easy and it is exactly the wrong direction, because it hands tenant-authored HTML the session's own scope.

Scope

The ADR (the namespace shape, the isolation boundary, and what happens to the session-aware affordances); the routing that makes motir-core answer for the new host; the addressing scheme for a project; the canonical / OpenGraph / JSON-LD / sitemap origin for /p/*; 301s from app.motir.co/p/*; and what PublicTopBar's account menu, PublicAuthDialog, follow, vote and submit-request become once they are cross-origin from the session.

That last clause is the hard part of this story and is deliberately not pre-decided here — the ADR resolves it, having read what the mirrors do (Notion's published pages are read-only and hand you back to the app; Canny's portal carries its own identity).

Boundary

  • Ends at one Motir-owned namespace. Per-tenant subdomains and customer custom domains are the next story; this one may choose a scheme that admits them, and must not ship the certificate-provisioning machinery they need.
  • Does not touch /explore. The directory is first-party and moves to motir.co in the previous story; it will link cross-host into this namespace, exactly as notion.com/templates links into notion.site.
  • Does not move the authenticated application, and does not widen the session cookie.
  • Does not re-plan /p/*'s FEATURES. The tabs, changelog, follow and request intake are shipped and stay as they are.

Verification recipe

  1. Open a public project at its new address — the overview renders for a logged-out visitor.
  2. Move through Board, Items, Tree, Roadmap and Changelog — each stays on the new host, each canonical and OpenGraph URL names it.
  3. Submit a feature request as an anonymous visitor, and follow the project — both still work, by whatever mechanism the ADR chose.
  4. Request the old app.motir.co/p/<identifier> — a 301 to the new address, tabs included.
  5. From a browser signed in to app.motir.co, load the public page and confirm the session cookie is NOT sent to the new host.

Acceptance criteria

  • Every /p/* route, its five tabs, its OpenGraph image and its request pages resolve on the new namespace, and their canonical, og:url, JSON-LD @id and sitemap entries all name that namespace.
  • app.motir.co/p/* 301s to the new address, path and query preserved.
  • The Better-Auth session cookie is not sent to the new namespace — asserted in a test, not assumed — and no cookie's Domain attribute was widened to achieve the split.
  • The ADR names, per affordance (UserMenu, PublicAuthDialog, follow, subscribe, vote, submit-request, in-place overview editing), what happens to it once the page is cross-origin from the session, and which mirror's behaviour that follows.
  • No public-project rendering code is duplicated into another repository, and no new consumer of /api/public/* is created outside motir-core.

Context refs

  • motir-core/app/(public)/p/[identifier]/ — the twelve getSession() sites, overview-actions.ts, opengraph-image.tsx
  • motir-core/app/(public)/_components/PublicTopBar, PublicAuthDialog, PublicFollowControl, PublicRoadmapVote, PublicSubmitRequestForm, PublicRequestComments, PublicOverviewEditor
  • motir-core/app/api/public/ — the BFF this story does NOT promote to a contract
  • motir-core/lib/publicProjects/urls.ts · lib/baseUrl.ts · app/sitemap.ts
  • motir-core/docs/decisions/public-projects.md · organization-url.md — the latter's reversal condition ("a shared read-only surface, a public organization page") is the trigger this story fires
  • GitHub, Yummy cookies across domains; the Public Suffix List — the isolation argument

Comments (0)

No comments yet — be the first to weigh in.

Sign in to comment

Reading is open to everyone; posting needs a Motir account.

Sign in