| 1 | /** |
| 2 | * Who may see and who may change a workspace's agents. Pure, so it is |
| 3 | * tested on its own. |
| 4 | * |
| 5 | * Any member sees the workspace's agents: agents are members too, and |
| 6 | * people need to know who they can talk to. Only owners create, change or |
| 7 | * archive them, since such an agent spends the workspace's money and acts |
| 8 | * in its name. A workspace's own access token, acting as the workspace, |
| 9 | * counts as an owner, as it does for the workspace's other settings. |
| 10 | * |
| 11 | * Personal agents (docs.g1t.sh/guides/agents/, "Personal agents") are a |
| 12 | * member's own: any member may create one while the workspace lets members |
| 13 | * (`members_create_agents`, on unless an owner turns it off). Only its |
| 14 | * member sees it, changes it and talks to it, in their direct message with |
| 15 | * it; owners see it too, archive it, and promote it to a workspace agent. |
| 16 | */ |
| 17 | import type { WorkspaceAgentScope, User } from "@g1t/contracts"; |
| 18 | |
| 19 | type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> & { id?: string } | null | undefined; |
| 20 | |
| 21 | /** An agent as these rules read it. */ |
| 22 | export type Owned = { scope?: string | null; owner_id?: string | null; builtin?: number | boolean }; |
| 23 | |
| 24 | export function canSee(viewer: Viewer, workspace: string): boolean { |
| 25 | const slug = workspace.toLowerCase(); |
| 26 | if (!viewer) return false; |
| 27 | if (viewer.kind === "workspace") return viewer.username.toLowerCase() === slug; |
| 28 | return !!viewer.workspaces?.some((m) => m.slug.toLowerCase() === slug); |
| 29 | } |
| 30 | |
| 31 | export function canManage(viewer: Viewer, workspace: string): boolean { |
| 32 | const slug = workspace.toLowerCase(); |
| 33 | if (!viewer) return false; |
| 34 | if (viewer.kind === "workspace") return viewer.username.toLowerCase() === slug; |
| 35 | return !!viewer.workspaces?.some((m) => m.slug.toLowerCase() === slug && m.role === "owner"); |
| 36 | } |
| 37 | |
| 38 | export const MANAGE_REFUSAL = "Only the workspace's owners can create, change or archive its agents."; |
| 39 | export const PERSONAL_OFF_REFUSAL = "This workspace's owners have turned off personal agents. Ask an owner to make the agent for the workspace."; |
| 40 | export const NOT_YOURS_REFUSAL = "Only the person whose personal agent this is can change it."; |
| 41 | |
| 42 | const personal = (agent: Owned) => agent.scope === "personal"; |
| 43 | |
| 44 | /** Whether `viewer` is the member a personal agent belongs to. */ |
| 45 | export function isOwnerOf(viewer: Viewer, agent: Owned): boolean { |
| 46 | return !!viewer && (viewer.kind ?? "user") === "user" && personal(agent) && !!agent.owner_id && viewer.id === agent.owner_id; |
| 47 | } |
| 48 | |
| 49 | /** Whether the viewer sees this agent at all: a personal one only its member and the owners. */ |
| 50 | export function canSeeAgent(viewer: Viewer, workspace: string, agent: Owned): boolean { |
| 51 | if (!canSee(viewer, workspace)) return false; |
| 52 | return !personal(agent) || isOwnerOf(viewer, agent) || canManage(viewer, workspace); |
| 53 | } |
| 54 | |
| 55 | /** Whether the viewer may change the agent's definition: owners a workspace agent, its member a personal one. */ |
| 56 | export function canChange(viewer: Viewer, workspace: string, agent: Owned): boolean { |
| 57 | return personal(agent) ? isOwnerOf(viewer, agent) : canManage(viewer, workspace); |
| 58 | } |
| 59 | |
| 60 | /** Whether the viewer may archive it: owners any agent, a member their own personal one. */ |
| 61 | export function canArchive(viewer: Viewer, workspace: string, agent: Owned): boolean { |
| 62 | return canManage(viewer, workspace) || isOwnerOf(viewer, agent); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * The scope a new agent gets, or why the viewer may not create one: |
| 67 | * owners make workspace agents unless they ask for a personal one; anyone |
| 68 | * else makes personal agents, while the workspace lets them. |
| 69 | */ |
| 70 | export function creatableScope( |
| 71 | viewer: Viewer, |
| 72 | workspace: string, |
| 73 | asked: unknown, |
| 74 | membersMayCreate: boolean, |
| 75 | ): { ok: true; scope: WorkspaceAgentScope } | { ok: false; message: string } { |
| 76 | if (!canSee(viewer, workspace)) return { ok: false, message: "There is no such workspace." }; |
| 77 | const owner = canManage(viewer, workspace); |
| 78 | const scope: WorkspaceAgentScope = asked === "personal" || asked === "workspace" ? asked : owner ? "workspace" : "personal"; |
| 79 | if (scope === "workspace") return owner ? { ok: true, scope } : { ok: false, message: MANAGE_REFUSAL }; |
| 80 | // A personal agent belongs to a person: never to a workspace's token or another agent. |
| 81 | if ((viewer!.kind ?? "user") !== "user" || !viewer!.id) return { ok: false, message: "A personal agent belongs to a person: create it signed in as yourself." }; |
| 82 | if (!owner && !membersMayCreate) return { ok: false, message: PERSONAL_OFF_REFUSAL }; |
| 83 | return { ok: true, scope }; |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Whether a personal agent may answer here: only its member, only in the |
| 88 | * direct message of the two of them. `members` is how many people and |
| 89 | * agents are in the conversation, when known. Null: it may; otherwise why not. |
| 90 | */ |
| 91 | export function personalRefusal(agent: Owned, place: { asked_by: string; channel_kind: "channel" | "dm"; members?: number | null }): string | null { |
| 92 | if (!personal(agent)) return null; |
| 93 | if (place.asked_by !== agent.owner_id) return "I'm someone's personal agent, so only they can talk to me."; |
| 94 | if (place.channel_kind !== "dm" || (place.members != null && place.members > 2)) { |
| 95 | return "I'm a personal agent: I answer only in my direct message with the person I belong to."; |
| 96 | } |
| 97 | return null; |
| 98 | } |