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-2474Done

The role-definition API — `POST /roles`, `PATCH` and `DELETE /roles/[roleId]`, and the refusal→status map

The HTTP surface the role editor and the delete dialog call. Routes are HTTP-only per the four-layer split: parse, validate the shape, call the service, map the typed refusal to a status. No policy, no Prisma, no transaction.

The routes

Under app/api/projects/[key]/roles/, alongside the shipped members/ routes and resolving the project the same way:

  • POST /api/projects/[key]/roles{ name, basedOn, permissions[] } → the created role definition.
  • PATCH /api/projects/[key]/roles/[roleId] — a partial { name?, permissions? }, so a rename and a re-permission are one round trip when the editor saves both.
  • DELETE /api/projects/[key]/roles/[roleId] — with an optional reassignToRoleId. Called WITHOUT it, a role that people hold is refused with the count, which is exactly how the dialog learns what to say before it asks; called WITH it, the move and the delete happen together.

No v1 public-API route and no MCP tool. The settings UI is the only client, the same way workflow statuses and custom fields are administered — a project's role vocabulary is not part of the client-facing item API.

The refusal map

lib/permissions/errorResponse.ts, following lib/customFields/errorResponse.ts and lib/projects/projectErrorResponse.ts — one module, so no route decides a status itself:

refusalstatuswire code
ProjectNotFoundError (missing, or another workspace)404the shipped no-existence-leak posture
NotProjectAdminError / PermissionDeniedError403
BuiltInRoleImmutableError403the resource exists and may never be written, which is a refusal rather than a conflict
RoleNameTakenError · RoleLimitReachedError · RoleInUseError409the shipped shape for OptionInUseError / FieldKeyConflictError
a malformed body400

RoleInUseError's response carries the member count in its payload — the dialog names how many people are affected, so the number has to survive the boundary rather than be re-fetched.

Scope boundary

In: the three route handlers, the request-shape validation, lib/permissions/errorResponse.ts, and route tests driving each status. Out: every rule the service owns — permission-set validity, the cap, name uniqueness, built-in immutability, the reassign transaction — a route that re-checks one of them is a second policy implementation; the UI that calls these; a GET, because the screens are server components that read through getRoleCatalog and never fetch a role list from the client.

Acceptance criteria

  • The three handlers exist at the paths above, resolve the project by key exactly as app/api/projects/[key]/members/route.ts does, and call only projectRoleDefinitionService — no Prisma import, no assertPermission call of their own, no $transaction.
  • Each row of the map above is asserted by a route test that drives the real service against a real database and reads the status and the wire code off the response, not the thrown error.
  • The 404-before-403 ordering holds: a request from an actor who cannot browse the project gets 404, not 403, so a settings surface cannot be used to confirm a foreign project exists.
  • A DELETE with no reassignToRoleId against a role two people hold returns 409 with a body carrying count: 2; the same call with a valid reassignToRoleId returns success and both memberships have moved — read back through the response DTO, not the database row.
  • A malformed body — a missing name, a non-array permissions, an unknown basedOn — is a 400 before the service is called.
  • The response DTO for a role definition is the service's mapper output verbatim; the route defines no shape of its own.
  • The routes are registered in whatever route inventory / permission guard the repo enforces (tests/permissions/noUngovernedOperation.test.ts and the inventory it reads), so a new route cannot ship ungoverned.

Context refs

  • app/api/projects/[key]/members/route.ts · app/api/projects/[key]/members/[userId]/route.ts — the sibling routes: project resolution, handler shape and test style.
  • app/api/fields/[fieldId]/options/[optionId]/route.ts · lib/customFields/errorResponse.ts — the shipped in-use-refusal route and its status map.
  • lib/projects/projectErrorResponse.ts · lib/projects/memberErrorResponse.ts — the project-scoped error responders and the 404-not-403 rule.
  • tests/permissions/noUngovernedOperation.test.ts · docs/decisions/permission-inventory.md — the guard that a new route must be governed by a catalog key.
  • The service card — the refusals this maps and the only layer allowed to raise them.