Skip to content
115 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Docs: a workspace knowledge base people and agents write together1/**
2 * Who may do what in a space: pure, so the rules are tested apart from the
3 * service (docs/WORKSPACE.md, "The asker's access caps the agent").
4 *
5 * - `workspace` spaces give every member `default_role`; `team` spaces give
6 * the team's members `default_role`; `private` spaces give nobody
7 * anything by default.
8 * - Listed members (`user:<id>`, `agent:<id>`, `team:<slug>`) add to that:
9 * a person's role is the highest that applies to them.
10 * - Workspace owners manage every workspace and team space. A private
11 * space is its members' alone, owners included.
12 * - An agent acts for a person: it reads what they read, suggests where
13 * they can comment, and edits directly only where they can edit and the
14 * space lets agents edit.
15 */
16import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts";
17
18export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
19
20export function isRole(value: unknown): value is DocRole {
21 return value === "view" || value === "comment" || value === "edit" || value === "manage";
22}
23
24export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean {
25 return !!role && RANK[role] >= RANK[need];
26}
27
28export function higher(a: DocRole | null, b: DocRole | null): DocRole | null {
29 if (!a) return b;
30 if (!b) return a;
31 return RANK[a] >= RANK[b] ? a : b;
32}
33
34export function lower(a: DocRole | null, b: DocRole | null): DocRole | null {
35 if (!a || !b) return null;
36 return RANK[a] <= RANK[b] ? a : b;
37}
38
39/** The parts of a space access depends on. */
40export type SpaceRules = {
41 kind: DocSpaceKind;
42 team: string | null;
43 default_role: DocRole | null;
44 members: { principal: string; role: DocRole }[];
45};
46
47/** A person, as access needs them. */
48export type Person = {
49 user_id: string;
50 /** Owner of the workspace. */
51 owner: boolean;
52 /** The slugs of their teams in the workspace, lowercased. */
53 teams: ReadonlySet<string>;
54};
55
56/** A person's role in a space, or null when they can't read it. */
57export function roleOf(space: SpaceRules, person: Person): DocRole | null {
58 let role: DocRole | null = null;
59 if (space.kind === "workspace") role = space.default_role;
60 if (space.kind === "team" && space.team && person.teams.has(space.team.toLowerCase())) role = space.default_role;
61 for (const m of space.members) {
62 if (m.principal === `user:${person.user_id}`) role = higher(role, m.role);
63 else if (m.principal.startsWith("team:") && person.teams.has(m.principal.slice(5).toLowerCase())) role = higher(role, m.role);
64 }
65 if (person.owner && space.kind !== "private") role = "manage";
66 return role;
67}
68
69/** Whether every one of `people` can read the space; for an agent answering to an audience. */
70export function readableByAll(space: SpaceRules, people: Person[]): boolean {
71 return people.every((person) => atLeast(roleOf(space, person), "view"));
72}
73
74/**
75 * Whether a space is readable by "everyone in the workspace": a public
76 * channel's audience. Only a workspace space with a base role is.
77 */
78export function readableByWorkspace(space: SpaceRules): boolean {
79 return space.kind === "workspace" && atLeast(space.default_role, "view");
80}
81
82/** What an agent may do for a person whose role is `asker`, in a space whose agents `mode`. */
83export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities {
84 return {
85 read: atLeast(asker, "view"),
86 suggest: atLeast(asker, "comment"),
87 edit: atLeast(asker, "edit") && mode === "edit",
88 };
89}
90
91/** Whether `role` holders may change who is in a space and its settings. */
92export function mayManage(role: DocRole | null): boolean {
93 return atLeast(role, "manage");
94}
95
96/**
97 * Whether removing or lowering `member` would leave a private space with
98 * no one to manage it. Workspace and team spaces always have the owners.
99 */
100export function leavesNoManager(kind: DocSpaceKind, members: { principal: string; role: DocRole }[], member: string, role: DocRole | null): boolean {
101 if (kind !== "private") return false;
102 const after = members.filter((m) => m.principal !== member).map((m) => m.role);
103 if (role) after.push(role);
104 return !after.includes("manage");
105}
106
107/** A member key's parts, or null when it is not one. */
108export function memberKey(key: string): { kind: "user" | "agent" | "team"; id: string } | null {
109 const at = key.indexOf(":");
110 if (at < 0) return null;
111 const kind = key.slice(0, at);
112 const id = key.slice(at + 1);
113 if (!id || (kind !== "user" && kind !== "agent" && kind !== "team")) return null;
114 return { kind, id };
115}