`assertPermission` — one gate per catalog key, its typed refusal, and the twelve administrative keys entering the built-in role sets
The seam every other card in this story calls. projectAccessService today has one administrative gate — assertCanManage, which asks project:administer — and the alternative to this card is growing eleven more of it (assertCanManageBoard, assertCanManageWorkflow, …), each a copy of the same three lines. One method that takes the KEY is the durable shape, and it is what custom roles needs too: a role is a set, so a gate should be a membership test the caller names.
This card also puts the twelve administrative keys into the built-in role sets, and that is where the story's neutrality claim is actually established. BUILTIN_ROLE_PERMISSIONS.admin gains all twelve; nothing else gains any. Because the resolution's levelGrants gives every key that is not work_item:edit / comment:add / attachment:create the same per-level treatment project:administer already gets, each new key resolves to exactly the actors project:administer resolves to, on every access level and both rails. That equivalence is provable over the whole input space and this card proves it.
Nothing is enforced here. Every key keeps enforcement: 'planned'; a key flips to enforced in the card that wires its gates. So this card is a no-op for every actor: it adds an unused method and widens a set nobody consults yet.
Acceptance criteria
projectAccessService.assertPermission(projectId, ctx, key, tx?)exists, takes aPermissionKey, and resolves the inputs ONCE. A non-browser is rejected asProjectNotFoundError(404) FIRST — the no-existence-leak postureassertCanManagealready keeps for settings surfaces — and a browser who does not hold the key is rejected with a typed error that carries the key and maps to 403.- The shipped wire contract does not change. Before choosing the error shape,
git grep -n "NOT_PROJECT_ADMIN" -- app lib components tests e2eand keep every consumer of that string receiving it. The default shape is: a newPermissionDeniedError(carryingpermission) inlib/projects/errors.ts, added toprojectErrorResponse's 403 arm, thrown for every key EXCEPTproject:administer, which keeps throwingNotProjectAdminError— a compatibility branch, documented as one, removable when nothing reads the old code. assertCanManageis re-expressed asassertPermission(projectId, ctx, 'project:administer', tx), keeping its signature, its errors and its ordering. Every existing call site compiles and behaves identically, untouched by this PR.ROLE_GATED_PERMISSIONSandBUILTIN_ROLE_PERMISSIONS.admingain the twelve:member:manage,project:manage_access,board:configure,workflow:manage,automation:manage,field:manage,component:manage,label:manage,estimation:manage,repository:manage,repository:manage_access,ai:configure.member,viewerandIMPLICIT_WORKSPACE_MEMBER_PERMISSIONSgain none.lib/permissions/resolve.ts'slevelGrantsis left behaving identically for the new keys, and the reason is written into the file: they take the same default branchproject:administertakes, soprivate/limitedsubtract from them the same way.- The neutrality proof, in
tests/permissions/accessParity.test.ts: across all 64 combinations of access level × workspace role × project role,hasPermission(i, k)equalshasPermission(i, 'project:administer')for each of the twelve. One table-driven assertion; a divergence anywhere fails it. assertPermissioncarries its own unit tests: 404-before-403 ordering, the key present on the thrown error, an admin passing, a member refused, thetxthreaded to the reads (the RLS binding the enclosing transaction holds).- No non-throwing
hasPermissionOn/ capability read is added. The fivegetXCapabilitiesmethods stay exactly as they are — the surfaces that need a resolved set already havegetPermissions, and what a client is TOLD is MOTIR-2258's. pnpm testgreen;pnpm lintand the prettier check pass repo-wide.
Context refs
lib/services/projectAccessService.ts—resolveInputs,assertCanManage(the 404-then-403 shape to reproduce),getPermissions, and thetx-optional convention every method here follows.lib/permissions/builtinRoles.ts—ROLE_GATED_PERMISSIONS,BUILTIN_ROLE_PERMISSIONS,IMPLICIT_WORKSPACE_MEMBER_PERMISSIONS; its header records why the threepublic_request:*keys are in no role set.lib/permissions/resolve.ts—resolvePermissions, both rails, andlevelGrants.lib/permissions/catalog.ts—PermissionKey,PERMISSION_META,PLANNED_PERMISSIONS.lib/projects/errors.tsandlib/projects/projectErrorResponse.ts— the typed-error → status mapping, including the 403 arm.tests/permissions/accessParity.test.ts— the 64-row table this card extends.- The model story · the roles-as-sets card · the catalog card.