Public REST API — the versioned `/api/v1` integration surface every client shares
Motir has no public API. Every programmatic surface it exposes today is either the web app's internal cookie-authenticated app/api/** tree or the MCP endpoint at /api/mcp. For an open-source Jira alternative that is a product gap, not plumbing: a self-hoster cannot script it, no third party can integrate with it, and there is no surface an SDK, a CI action or a Terraform provider could target.
This epic builds that surface — one public, versioned, documented HTTP API, and makes every client a peer consumer of it.
Why this exists (Yue, 2026-07-29)
The trigger was a narrower question — is the CLI using MCP, and is that standard? It is (exclusively: packages/cli/src/mcpClient.ts is the only file importing the MCP SDK), and it is not. The industry pattern for a product CLI is unambiguous and was verified per-product, not from memory:
| Product | CLI | Talks to |
|---|---|---|
| GitLab (open-core) | glab | public REST /api/v4 + GraphQL |
| Gitea / Forgejo | tea | public /api/v1, OpenAPI-documented |
| Sentry (open-core) | sentry-cli | public Web API /api/0/ |
| Plane (OSS PM — the closest product analogue) | — | public REST /api/v1/ + API keys |
| Mattermost | mmctl | REST v4 |
| GitHub | gh | public REST + GraphQL |
The invariant is not the transport — it is that one public, versioned API is a first-class product surface and the CLI is merely its first client, with no private endpoints and no privileges. So the gap is not "the CLI needs REST" (a CLI-private API would itself be non-standard); the gap is that Motir has nothing for anyone else to call. Note the asymmetry already in the tree: 7.16.4b has Motir importing from Plane over Plane's public REST API, while offering no equivalent outward.
It also fixes a real structural defect. The MCP tool surface currently serves two consumers with opposed change-rate requirements: agents want churn (tool names, descriptions and argument shapes get rewritten for prompt-engineering reasons), while a published npm CLI wants a frozen contract. One unversioned surface serving both makes agent-driven churn into client breakage. After this epic each consumer gets a contract shaped for it — the REST API is stable because third parties depend on it, MCP stays fluid because only agents do. That is the shape GitLab and GitHub use: their MCP servers wrap their public APIs. Motir has it inverted today.
Target architecture
services (the single source of truth — the 4-layer stack, already built)
├── web app → session cookie (unchanged)
├── public REST /api/v1 → PAT bearer · versioned · OpenAPI ──┬── @motir/cli
│ └── third parties, SDKs, CI
└── MCP server → thin adapter over the SAME services + response schemas
Both REST routes and MCP tools stay thin adapters over services — the MCP tools are NOT re-pointed at HTTP routes (that would add an internal network hop). What unifies them is the response SCHEMA, not the transport: one schema source, both surfaces derive from it, a conformance test proves they cannot drift.
What makes this cheaper than it looks (rung-2 verified on origin/main @ d5b03e5f)
motir-core's architecture discipline pays off here — almost no business logic moves. Verified substrate:
lib/apiTokens/routeAuth.ts—authenticateApiToken(req, requiredScope)already exists: genericAuthorization: Bearer motir_pat_…auth for a plain REST route, shipped by MOTIR-1631 for the acceptance-video publish endpoint, returning{ ok, userId, workspaceId }or{ ok: false, reason: 'unauthenticated' | 'forbidden' }. The public API's auth is a REUSE, not a build.lib/mcp/scopes.ts— the PAT scope model is shipped and total:read·work_items:write·work_items:archive·work_items:delete·sprints:write·integration, typed so an ungated operation fails typecheck.- The PAT-minting UI is shipped —
app/(authed)/settings/account/api-tokens/(ApiTokensManager,CreateTokenModal), with real non-route callers. "Mint a token" resolves to an existing surface; no card is missing for it. - The error envelope convention is established —
{ code, error }+ an HTTP status, e.g.app/api/work-items/[id]/route.ts. - Opaque cursor pagination exists —
lib/mcp/searchCursor.ts. - Every service is already DTO-returning and route-agnostic (
workItemsService,sprintsService,projectsService), so a v1 route is a thin adapter exactly like an MCP tool.
Genuinely new: the /api/v1 tree itself, per-token rate limiting (no primitive exists anywhere in lib/), the OpenAPI spec + published reference, the shared response-schema source, and the CLI transport swap.
Scope BOUNDARY
This epic delivers: the /api/v1 route tree over existing services; PAT bearer auth + scope enforcement + rate limiting + cursor pagination + the error envelope; the work-item, project, sprint and ready-set resources; a published OpenAPI 3.1 spec and API reference; the CLI migrated onto it; and MCP payloads derived from the same schemas.
It ENDS at the HTTP surface and its first two clients. It does NOT:
- Rewrite
app/api/**. That tree is the web app's INTERNAL cookie-authenticated surface and stays exactly as it is./api/v1is a NEW, separately-versioned tree — never a promotion or rename of internal routes, which carry no stability promise. - Ship a GraphQL surface (GitLab and GitHub both have one; it is a separate, later decision — no card here defers work to it).
- Ship SDKs, a Terraform provider or a GitHub Action. Those become possible once the spec exists and are their own epics.
- Grow BEHAVIOUR at the edge. No new filter axis, access gate, response field or write path is invented in a v1 route: if an endpoint needs DATA a service does not expose, that is a card in the owning feature's epic, not here. The one bounded exception, recorded as an ADR amendment by 11.2.1 rather than assumed: a v1 endpoint may add a page ADDRESSING over an existing predicate — a keyset read, or a page-size parameter where the shipped read has a fixed one — because the result SET is unchanged and the conventions mandate keyset paging that no shipped read offers. Everything else about that read stays as it is.
- Touch
motir-ai. Every deliverable ismotir-core— one repo, so no card in this epic straddles the open-core boundary.
Where it sits
blocked_by Epic 6: Search, reporting & admin — the PM core this API publishes. That epic is done, so this one is ready now.
Deliberately NOT wired as a blocker of Epic 8: Launch readiness: a public API is arguably launch-readiness work, but Epic 8 is already in_progress and inverting that edge would block shipped-in-flight work on an epic that has not started. The relationship is noted, not wired.
The CLI migration lives HERE rather than in Epic 7's CLI story (MOTIR-809) because it is this API's first consumer and its proof of completeness. Story 7.9 continues on MCP in the meantime and needs no rework: the transport is confined to ONE file, so the swap touches no command and no renderer.