1/** 2 * @factory/permissions — static role rules, extracted from the decision 3 * engine so they are unit-testable and reusable by the API layer. 4 * 5 * The workspace role matrix answers management questions ("can this member 6 * invite people?") that are orthogonal to per-resource access levels — those 7 * are answered by {@link resolvePermission} in `resolve.ts`. 8 */ 9 10import { ROLES, type PermissionLevel, type WorkspaceRole } from "./levels.js"; 11 12/** 13 * A permission share (mirror of the prisma `Share` model, DTO form). 14 * 15 * `scope` is `snake_case` (prisma: PRIVATE/WORKSPACE/TEAM/USERS/PUBLIC): 16 * 17 * - `"private"` — creator-only; never grants anything to other users. 18 * - `"workspace"` — every authenticated workspace member. 19 * - `"team"` — members of `teamId` (`userIds` are the resolved member list). 20 * - `"users"` — the users listed in `userIds`. 21 * - `"public"` — anyone, including unauthenticated visitors (for a logged-in 22 * user it is simply one more floor in the resolution). 23 */ 24export interface Share { 25 scope: "private" | "workspace" | "team" | "users" | "public"; 26 level: PermissionLevel; 27 /** Share is visible to members of this team (`scope: "team"`). */ 28 teamId?: string; 29 /** Share is visible to these users (`scope: "users"`). */ 30 userIds?: string[]; 31 /** Presence flag only — the pure engine never verifies passwords (API layer). */ 32 password?: string; 33 /** Public link slug (`scope: "public"`); unused by the engine. */ 34 slug?: string; 35} 36 37/** Minimal requester context a visibility check needs. */ 38export interface ShareVisibilityUser { 39 /** The requesting user's id (required to match `"users"` scopes). */ 40 userId: string; 41 /** Ids of all teams the user belongs to. */ 42 userTeams: string[]; 43} 44 45/** 46 * Whether a share is visible to `user` (i.e. contributes its level during 47 * resolution). Extracted from the engine as a separate, testable helper. 48 * 49 * Rules: 50 * - `"public"` → always visible (the floor even for unauthenticated access). 51 * - `"workspace"`→ visible to every authenticated workspace member. 52 * - `"team"` → visible iff `user.userTeams` contains `share.teamId`. 53 * - `"users"` → visible iff `user.userId ∈ share.userIds`. 54 * - `"private"` → never visible to anyone but the creator (the engine has no 55 * creator context; the API must not pass private shares to a foreign user). 56 * 57 * `password` and `slug` never affect visibility. 58 */ 59export function shareVisibility(share: Share, user: ShareVisibilityUser): boolean { 60 switch (share.scope) { 61 case "public": 62 return true; 63 case "workspace": 64 return true; 65 case "team": 66 return share.teamId !== undefined && user.userTeams.includes(share.teamId); 67 case "users": 68 return (share.userIds ?? []).includes(user.userId); 69 case "private": 70 return false; 71 } 72} 73 74/** Workspace management actions (prisma/roadmap: workspace settings module). */ 75export const ROLE_ACTIONS = [ 76 "manage_members", 77 "invite", 78 "delete_workspace", 79 "manage_permissions", 80 "manage_settings", 81 "export_data", 82] as const; 83 84export type RoleAction = (typeof ROLE_ACTIONS)[number]; 85 86/** 87 * Static role → action matrix: 88 * 89 * | action | owner | admin | member | guest | 90 * | ------------------ | :---: | :---: | :----: | :---: | 91 * | manage_members | ✓ | ✓ | | | 92 * | invite | ✓ | ✓ | | | 93 * | delete_workspace | ✓ | | | | 94 * | manage_permissions | ✓ | ✓ | | | 95 * | manage_settings | ✓ | ✓ | | | 96 * | export_data | ✓ | ✓ | | | 97 * 98 * Owner: all actions. Admin: everything except `delete_workspace`. 99 * Member/guest: none — per-role data export is a *user-level* feature the 100 * API handles outside this matrix (see README, "Recorded decisions"). 101 */ 102export function canRole(role: WorkspaceRole, action: RoleAction): boolean { 103 if (!(ROLES as readonly string[]).includes(role)) { 104 throw new Error(`unknown workspace role: ${String(role)}`); 105 } 106 switch (action) { 107 case "manage_members": 108 case "invite": 109 case "manage_permissions": 110 case "manage_settings": 111 case "export_data": 112 return role === "owner" || role === "admin"; 113 case "delete_workspace": 114 return role === "owner"; 115 } 116} 117