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

`projectRoleDefinitionService` — create, rename, re-permission and delete-with-reassign, its caps, its locks and its typed refusals

Every rule about what a custom role may be lives here, in one service, so no route and no component re-implements a policy. It owns the transactions and the DTO mapping; the repositories underneath it are single-op leaves and the routes above it are HTTP-only, per the four-layer split.

The operations

  • create — name and permission set. No base: the editor's Start from pick seeds the grid in the browser and is never sent (Yue, 2026-08-09). Gated on project:manage_access via projectAccessService.assertPermission (the key projectMembersService already gates add-member and set-role on — a role definition IS project access, and this story adds no catalog key).
  • rename / setPermissions — the same validation on the same fields, amending in place.
  • delete — with the reassignment, below.

The rules each operation enforces

  • A permission must be in the ROLE-GATED, enforced set. Derived from ROLE_GATED_PERMISSIONS and isEnforced, never a literal list — the whole point of the enforcement marker is that a key no gate consults must never become a switch that controls nothing. Today PLANNED_PERMISSIONS is empty, so this refuses nothing in practice; it is written from the constants so that the next planned key is refused with no code change. A request naming a level-gated public_request:* key is refused for the same reason: no role can hold one.
  • A name is trimmed, non-empty and bounded, and unique within the project. The unique index raises P2002; the service catches it and rethrows a typed RoleNameTakenError, so a raw database error never escapes.
  • The cap is a COUNT-THEN-CREATE, so it must lock. A plain read-then-write races — two concurrent creates both read n and both insert, and the project ends up over its cap. Count inside the transaction with the project row locked SELECT … FOR UPDATE and re-read before inserting, and the test must drive genuine concurrency rather than two sequential calls. lib/permissions/limits.ts holds MAX_CUSTOM_ROLES_PER_PROJECT and the name bound as pure constants — no Prisma import — so the editor can render the cap state from the same source of truth the service enforces, exactly as lib/customFields/limits.ts does for fields.
  • A built-in is not a row and cannot be edited. admin / member / viewer live in lib/permissions/builtinRoles.ts; any operation naming one is refused with a typed BuiltInRoleImmutableError rather than a not-found, because the caller asked for something meaningful and impossible.
  • Delete refuses to strip anybody. countByRoleDefinition first: if it is zero, delete. If it is not and no destination was given, throw RoleInUseError carrying the count — the cue the dialog reads to name how many people are affected. If a destination was given, reassign then delete inside one $transaction, so the two can never half-happen; the destination may be another custom role or a built-in, must belong to the same project, and may not be the role being deleted. The database's Restrict foreign key is the backstop underneath this, not a substitute for it.
  • No external side effect runs inside a transaction — there are none in this card, and none may be added inside one.

Scope boundary

In: the service, lib/permissions/errors.ts, lib/permissions/limits.ts, the DTO mapping for a single role definition, and their tests including the real-concurrency cap test. Out: the HTTP routes and their status-code mapping, which are their own card; getRoleCatalog, which is the read the screens use and its own card; the resolution, which reads these rows but is written by the resolution card; assigning a role to a MEMBER, which is projectMembersService's path and the Members card's; any direct Prisma access — every write goes through the repository leaves.

Acceptance criteria

  • lib/services/projectRoleDefinitionService.ts exposes create, rename, setPermissions, delete and findById, each taking the actor context and each calling projectAccessService.assertPermission(projectId, ctx, 'project:manage_access') before doing anything else; a non-admin project member is refused and a cross-workspace project id raises ProjectNotFoundError, never a 403-shaped error.
  • A create or update naming a key outside the role-gated, enforced set is refused with a typed error naming the offending key — and the check reads ROLE_GATED_PERMISSIONS + isEnforced, asserted by a test that adds a synthetic non-enforced key rather than by a hardcoded list.
  • A duplicate name within a project raises RoleNameTakenError, not P2002; the same name in a DIFFERENT project succeeds.
  • The cap holds under real concurrency: a test firing MAX_CUSTOM_ROLES_PER_PROJECT + 1 creates simultaneously against a warm pool ends with exactly the cap stored, the surplus refused with the typed limit error, and accepts either legitimate interleaving.
  • rename, setPermissions and delete on admin / member / viewer are refused with BuiltInRoleImmutableError — including when the identifier arrives from an untrusted string.
  • delete with members and no destination throws RoleInUseError carrying the member count and writes nothing. delete with a destination moves every membership and removes the role in one transaction, and a test asserts that a failure injected after the reassign leaves both the memberships and the role unchanged.
  • A destination that is the role being deleted, or belongs to another project, is refused before any write.
  • Every membership moved by a delete lands with its role_definition_id and role consistent, through projectMembershipRepository.reassignRoleDefinition — this service writes that column through no other path.
  • lib/permissions/limits.ts is a pure-constant module with no Prisma import, importable from a client component.

Context refs

  • lib/services/workflowsService.tsdeleteStatus, reassignToStatusId and StatusInUseError: the shipped delete-with-reassign this mirrors, including the no-target-throws-with-a-count cue.
  • lib/services/customFieldsService.ts · lib/customFields/limits.ts · lib/customFields/errors.ts — the caps posture (FieldLimitReachedError), the P2002 translation, and the pure-constants module shape.
  • lib/services/projectMembersService.ts — its assertPermission(…, 'project:manage_access') call, the gate this reuses.
  • lib/services/projectAccessService.tsassertPermission and its 404-not-403 ordering.
  • lib/permissions/catalog.tsisEnforced, ENFORCED_PERMISSIONS, isPermissionKey; lib/permissions/builtinRoles.tsROLE_GATED_PERMISSIONS and the three immutable sets.
  • motir-core/CLAUDE.md — the four-layer split, the lock-before-a-read-derived-write rule, and the side-effects-outside-the-transaction rule.
  • The schema card — the repository leaves and the Restrict FK backing the delete rule.