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 optionalreassignToRoleId. 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:
| refusal | status | wire code |
|---|---|---|
ProjectNotFoundError (missing, or another workspace) | 404 | the shipped no-existence-leak posture |
NotProjectAdminError / PermissionDeniedError | 403 | |
BuiltInRoleImmutableError | 403 | the resource exists and may never be written, which is a refusal rather than a conflict |
RoleNameTakenError · RoleLimitReachedError · RoleInUseError | 409 | the shipped shape for OptionInUseError / FieldKeyConflictError |
| a malformed body | 400 |
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.tsdoes, and call onlyprojectRoleDefinitionService— no Prisma import, noassertPermissioncall 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
DELETEwith noreassignToRoleIdagainst a role two people hold returns 409 with a body carryingcount: 2; the same call with a validreassignToRoleIdreturns 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 unknownbasedOn— 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.tsand 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.