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

`member:manage` + `project:manage_access` — and the second admin check `projectMembersService` re-implements privately

Two keys, one service, and one thing that turns out not to be a re-pointing at all.

lib/services/projectMembersService.ts does not call projectAccessService.assertCanManage. It declares its own module-private assertCanManage (top of the file) that re-derives the admin answer from scratch: workspace-manager rail, then projectMembership?.role === 'admin'. Four write methods call it — addMember, setRole, removeMember, setAccessLevel. So the members surface has been running on a second implementation of the access policy, which happens to agree with the first today and has no mechanism keeping it that way. The model story moved the policy into one place precisely so this cannot happen; this card is where that pays off.

  • addMember · setRole · removeMembermember:manage
  • setAccessLevelproject:manage_access. It is its own key on purpose: who is IN the project and how open the project is to the workspace are different decisions, and Jira separates them the same way.

The read half is not a re-pointing. listMembers and getAccess are documented as deliberately un-gated reads ("available to any workspace member who can resolve the project key"). Confirm what resolveProjectInTx actually enforces before touching them: if it applies the browse gate, leave them alone; if it does not, a members list is readable by a workspace member who cannot browse the project, and project:browse is the right gate. Either way, do not put a manage key on a read — the Members page renders read-only for non-admins by design, and that is MOTIR-2258's surface to change, not this card's.

Acceptance criteria

  • The module-private assertCanManage in projectMembersService is DELETED, and its four callers use projectAccessService.assertPermission(project.id, ctx, <key>, tx) — threading the enclosing withWorkspaceContext transaction, which the existing calls already do and which the RLS binding requires.
  • addMember, setRole and removeMember gate on member:manage; setAccessLevel gates on project:manage_access.
  • The two reads keep their current posture unless resolveProjectInTx is found not to apply a browse gate, in which case they gate on project:browse. Whichever is true, the finding is written into the service's header comment where today's "no gate" note sits.
  • member:manage and project:manage_access flip to enforcement: 'enforced' in lib/permissions/catalog.ts.
  • The three rows in the inventory's member section have their gate-today cell corrected to name the real gate and their decision moved from new to existing.
  • Tests, per method: a project admin passes; a project member is refused 403; a workspace owner still passes on every access level (the always-pass rail); a non-browser gets 404, not 403. The 403 body still carries whatever code string git grep NOT_PROJECT_ADMIN showed consumers reading.
  • The guard's pending pin is re-derived by running the suite on the branch, never copied from a sibling card.
  • pnpm test green; pnpm lint and the prettier check pass repo-wide.

Context refs

  • lib/services/projectMembersService.ts — the private assertCanManage, listMembers, getAccess, addMember, setRole, removeMember, setAccessLevel, and resolveProjectInTx.
  • app/api/projects/[key]/members/route.ts · app/api/projects/[key]/members/[userId]/route.ts · app/api/projects/[key]/access/route.ts — the three routes.
  • lib/services/projectAccessService.tsassertPermission and its tx parameter.
  • docs/decisions/permission-inventory.md — the member section (reasons R27, R18).
  • The seam this calls · the model story.