6.4.3 Service enforcement — `projectAccess` browse/edit gate threaded into project/board/issue reads
Estimate: 34m · Depends on: 6.4.2
The permission policy + its enforcement, at the service layer (the retrofit of the deferred TODO(6.4) seams). A projectAccessService (or a guard in projectsService) computes, for a (user, project): canBrowse — open/limited → any workspace member; private → a ProjectMembership exists (or the user is workspace owner/admin, who always pass); canEdit — limited → false for non-members (view+comment only); else gated by project role (viewer → false, member/admin → true). Thread canBrowse into the existing project read, the board projection (getBoard), and the issue list/detail reads so a non-member gets a typed ProjectAccessDeniedError (→ 403/404 at the route, the no-access state in the UI); thread canEdit into the write paths (create/move/assign/update) so a viewer’s writes are rejected. Workspace owner/admin bypass throughout.
Out of scope: the management API (6.4.4) + UI (6.4.5/6.4.6). This subtask is the policy + wiring it into reads/writes that already exist.
Acceptance criteria
- A single
canBrowse(project, ctx)+canEdit(project, ctx)policy (open/limited/private × role), with workspace owner/admin always passing; a typedProjectAccessDeniedErrormapped to 403 (existing project) / 404 (hidden). canBrowsegates the project read +getBoard+ the issue list/detail reads;canEditgates the issue/board write paths (create/move/assign/update); no read or write path bypasses the gate.- Vitest (real Postgres) covers each access level × role for both browse + edit, and the owner/admin bypass.
Context refs
lib/services/projectsService.ts,boardsService.getBoard,workItemsServicereads/writes — the seams to gate (search theTODO(6.4)notes, e.g. workflowsService 2.2.5, boardsService 3.3.3)- finding #26 (explicit app-layer workspace gate — this adds the project tier beneath it);
motir-core/CLAUDE.md