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
144
planned
1,362
shipped

Motir · Work items

MOTIR-2256Done

Per-domain administrative permissions — split `project:administer` so a role can hold one settings domain without the rest

One boolean decides every administrative act in a project: adding a member, editing a workflow, remapping a board column, defining a custom field, writing an automation rule, connecting a repository, changing the AI-planning settings, granting code access. The permission MODEL made that boolean a membership test — canManageProject is now hasPermission(i, 'project:administer') — but it is still ONE key over ten domains. Any role that may do one of those may do all of them.

This story splits the umbrella into the twelve per-domain administrative permissions the catalog already names, and re-points every gate at the specific key it guards. It is the story that gives a custom role something worth choosing: without it, the grid the Roles & permissions page renders has exactly one administrative row on it, and "create a role with the permissions you want" collapses back into the three roles we already had.

⚠️ Re-scoped 2026-08-06 against origin/main — read this before the shape below

This story was written before the inventory and the grown catalog landed, and four things it assumed are no longer true:

  1. The key names are the catalog's, not this card's. They are already in lib/permissions/catalog.ts carrying enforcement: 'planned'project:manage_access (not access:manage), board:configure (not board:manage), repository:manage_access (not code_access:manage), ai:configure (not ai_planning:manage), plus label:manage, which this card never named.
  2. The page left. Rendering the expanded catalog is MOTIR-2282's, which is blocked_by this story. Nothing here draws a pixel.
  3. The catalog has 21 planned keys, not 11 — and this story takes 12. The other eight are member-facing operations governed by nothing at all today, so wiring them REMOVES capability from real actors; that is a different kind of change and it is MOTIR-2291. The comments in catalog.ts and tests/permissions/noUngovernedOperation.test.ts naming this story as the owner of the whole planned list predate that split, and the first card below corrects them.
  4. repository:connect is not a project permission, and is retired here. All six operations the inventory maps to it (/api/github/oauth/*, /api/github/setup, /api/github/organizations, /api/gitlab/oauth/*) bind a provider INSTALLATION to a WORKSPACE, land on /settings/workspace/github|gitlab, and carry no project at all — app/api/gitlab/oauth/start/route.ts says so in its own header comment. A key with no operation behind it is exactly the lie the catalog's opening rule forbids.

And main is RED, for a reason that also changes the size of this story. On 9aed45b0, tests/permissions/inventoryCoverage.test.ts and tests/permissions/noUngovernedOperation.test.ts fail three assertions (verified by running them, 2026-08-06). One cause is a missing inventory row. The other is a false-negative in the guard's own walk: it extracts a service method's body by taking the first { after the method name, so a parameter carrying an inline object type — options: { repoKeys?: string[] } — captures it and the "body" becomes the parameter, hiding the assertCan* inside. Correcting that extractor locally moves the guard's counts from 95 ungoverned → 81 and 38 unconfirmed → 33: a quarter of the gap the inventory reported was the measuring instrument, not the product. Every card here edits those two files, so this comes first.

The twelve keys

DomainKeysWhat it gatesUngated ops today
membermember:manage · project:manage_accessthe members list and role changes; the project's access level0
boardboard:configureboards, columns, swimlanes, WIP limits4
workflowworkflow:manage · automation:managethe statuses a column projects; automation rules and the status-derivation switches6
fieldfield:manage · component:manage · label:managethe three project vocabularies3
estimationestimation:managethe estimation scheme1
repositoryrepository:manage · repository:manage_accessthe project's repository SET and its takeover / move; who may clone5
aiai:configurethe AI-planning cadence and planner model0

project:administer SURVIVES as the umbrella the built-in Admin role holds and the guard on the project-level acts that belong to no domain — rename, key change, archive, alias release.

The right-hand column is the CORRECTED pending count (measured on 9aed45b0 with the extractor fixed) and is context, not a specification. Each card re-derives its own list by running the guard on its branch — the numbers move as siblings land.

The shape

  • One gate, one key. projectAccessService.assertPermission(projectId, ctx, key) replaces the per-domain assert method the codebase would otherwise grow twelve of. assertCanManage survives as assertPermission(…, 'project:administer'), so every site that uses it keeps compiling and moves one domain at a time.
  • The call sites are ENUMERATED by grep, not by this card. git grep -n "assertCanManage\|canManageProject" -- lib app is the enumeration; each hit resolves to exactly one domain key, or stays on the umbrella because it guards a project-level act. A hit that resolves to neither is the tell that a domain is missing from the table above, not a reason to leave it.
  • The built-in roles do not change what they can do. Admin holds all twelve, so wherever a gate reads project:administer today the split is behaviour-neutral by construction.
  • But it also CLOSES holes, and that is not neutral. 19 operations in these domains reach the database with no project gate at all — a board column, a custom-field option, a repository row, the estimation scheme can each be changed today by any signed-in workspace member. They become admin-only. That is the intent (Jira team-managed and Plane both put board and field configuration behind project administration), and it is the one place a built-in role loses something.
  • It ends at the server. lib/settings/projectSettingsNav.ts is NOT touched: ten of its eleven entries are browse-gated on purpose so a member SEES the configuration read-only, and changing what is shown is the permission-gated UI story's job. No design asset is needed and none is drawn.

Scope boundary

In: the assertPermission seam and its typed refusal; the twelve keys entering BUILTIN_ROLE_PERMISSIONS.admin and ROLE_GATED_PERMISSIONS; every assertCanManage site and every ungated operation in the seven domains above; the retirement of repository:connect; the inventory rows and the guards' pinned counts for those operations; the story's two test subtasks.

Ends at: the server, and at these twelve keys. The eight member-facing keys and all 33 unconfirmed-gate rows are MOTIR-2291's. Rendering the catalog is MOTIR-2282's. Choosing a subset of these keys is what custom roles does. Hiding what an actor cannot reach is MOTIR-2258's.

Verification recipe

  • Pull the story branch, pnpm install, pnpm dev.
  • As a project admin, open each project-settings page — Members, Access, Workflow, Board, Estimation, Fields, Components, Automation, Repositories, Code access, AI planning — and save a change on each. Every one still saves.
  • As a project member, open the same pages: each still renders read-only and each save is still refused. Then, as that same member, add a board column and edit a custom-field option — both are now refused where they succeeded before.
  • pnpm test tests/permissions/ — the 64-row parity truth table is still green (no actor's browse / edit / comment / moderate capability moved), PLANNED_PERMISSIONS holds only MOTIR-2291's eight keys, and the guard's pending count has fallen by the operations this story gated.

This story has no user-visible surface of its own — it adds no page, panel or control — so it is exempt from the acceptance-video rule and accepts on its tests plus the walk above.