/** * @factory/permissions — static role rules, extracted from the decision * engine so they are unit-testable and reusable by the API layer. * * The workspace role matrix answers management questions ("can this member * invite people?") that are orthogonal to per-resource access levels — those * are answered by {@link resolvePermission} in `resolve.ts`. */ import { ROLES, type PermissionLevel, type WorkspaceRole } from "./levels.js"; /** * A permission share (mirror of the prisma `Share` model, DTO form). * * `scope` is `snake_case` (prisma: PRIVATE/WORKSPACE/TEAM/USERS/PUBLIC): * * - `"private"` — creator-only; never grants anything to other users. * - `"workspace"` — every authenticated workspace member. * - `"team"` — members of `teamId` (`userIds` are the resolved member list). * - `"users"` — the users listed in `userIds`. * - `"public"` — anyone, including unauthenticated visitors (for a logged-in * user it is simply one more floor in the resolution). */ export interface Share { scope: "private" | "workspace" | "team" | "users" | "public"; level: PermissionLevel; /** Share is visible to members of this team (`scope: "team"`). */ teamId?: string; /** Share is visible to these users (`scope: "users"`). */ userIds?: string[]; /** Presence flag only — the pure engine never verifies passwords (API layer). */ password?: string; /** Public link slug (`scope: "public"`); unused by the engine. */ slug?: string; } /** Minimal requester context a visibility check needs. */ export interface ShareVisibilityUser { /** The requesting user's id (required to match `"users"` scopes). */ userId: string; /** Ids of all teams the user belongs to. */ userTeams: string[]; } /** * Whether a share is visible to `user` (i.e. contributes its level during * resolution). Extracted from the engine as a separate, testable helper. * * Rules: * - `"public"` → always visible (the floor even for unauthenticated access). * - `"workspace"`→ visible to every authenticated workspace member. * - `"team"` → visible iff `user.userTeams` contains `share.teamId`. * - `"users"` → visible iff `user.userId ∈ share.userIds`. * - `"private"` → never visible to anyone but the creator (the engine has no * creator context; the API must not pass private shares to a foreign user). * * `password` and `slug` never affect visibility. */ export function shareVisibility(share: Share, user: ShareVisibilityUser): boolean { switch (share.scope) { case "public": return true; case "workspace": return true; case "team": return share.teamId !== undefined && user.userTeams.includes(share.teamId); case "users": return (share.userIds ?? []).includes(user.userId); case "private": return false; } } /** Workspace management actions (prisma/roadmap: workspace settings module). */ export const ROLE_ACTIONS = [ "manage_members", "invite", "delete_workspace", "manage_permissions", "manage_settings", "export_data", ] as const; export type RoleAction = (typeof ROLE_ACTIONS)[number]; /** * Static role → action matrix: * * | action | owner | admin | member | guest | * | ------------------ | :---: | :---: | :----: | :---: | * | manage_members | ✓ | ✓ | | | * | invite | ✓ | ✓ | | | * | delete_workspace | ✓ | | | | * | manage_permissions | ✓ | ✓ | | | * | manage_settings | ✓ | ✓ | | | * | export_data | ✓ | ✓ | | | * * Owner: all actions. Admin: everything except `delete_workspace`. * Member/guest: none — per-role data export is a *user-level* feature the * API handles outside this matrix (see README, "Recorded decisions"). */ export function canRole(role: WorkspaceRole, action: RoleAction): boolean { if (!(ROLES as readonly string[]).includes(role)) { throw new Error(`unknown workspace role: ${String(role)}`); } switch (action) { case "manage_members": case "invite": case "manage_permissions": case "manage_settings": case "export_data": return role === "owner" || role === "admin"; case "delete_workspace": return role === "owner"; } }