Skip to content
98 linesCodeBlameRaw
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 */
17import type { WorkspaceAgentScope, User } from "@g1t/contracts";
18
19type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> & { id?: string } | null | undefined;
20
21/** An agent as these rules read it. */
22export type Owned = { scope?: string | null; owner_id?: string | null; builtin?: number | boolean };
23
24export 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
31export 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
38export const MANAGE_REFUSAL = "Only the workspace's owners can create, change or archive its agents.";
39export const PERSONAL_OFF_REFUSAL = "This workspace's owners have turned off personal agents. Ask an owner to make the agent for the workspace.";
40export const NOT_YOURS_REFUSAL = "Only the person whose personal agent this is can change it.";
41
42const personal = (agent: Owned) => agent.scope === "personal";
43
44/** Whether `viewer` is the member a personal agent belongs to. */
45export 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. */
50export 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. */
56export 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. */
61export 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 */
70export 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 */
91export 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}