Skip to content
316 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}
The docs service has tables for artifacts beside Docs' pages, and one tested rule for who can read and change an artifact: its owner, shares on it or above it, its space, and everyone in the workspace or with the link.116
117// ── Folios (Artifacts mode, docs/ARTIFACTS_MODE.md section 2.1) ─────────
118//
119// A person's role on a folio is the highest of:
120// 1. its owner → `manage` (and the owner of every ancestor it inherits
121// from, as if that owner held a `manage` grant there);
122// 2. grants on it or on an ancestor it inherits from, to their `user:` key
123// or one of their `team:` keys;
124// 3. their space role, when the chain reaches the top of a space without a
125// restriction (`inheritsSpace`): workspace owners manage those, as for
126// pages, through `roleOf`;
127// 4. the access root's general access: `workspace` gives every member its
128// role, `link` gives it to members who opened the link (a visit).
129//
130// A restricted folio (`inherit` false) is its own access root: grants
131// above it, its space and its parent's general access stop there. An
132// agent never has more than the person it acts for, narrowed to what
133// everyone it is talking to can read.
134
135/** One folio as access needs it. */
136export type FolioAclNode = {
137 id: string;
138 /** `user:<id>`. */
139 owner: string;
140 parent_id: string | null;
141 space_id: string | null;
142 /** False: "Only people invited", its own access root. */
143 inherit: boolean;
144 general_access: "none" | "workspace" | "link";
145 general_role: DocRole | null;
146 created_at?: string;
147};
148
149/** An explicit share, as set. */
150export type FolioGrant = { principal: string; role: DocRole; granted_at?: string };
151
152/** Grants by folio id. */
153export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>;
154
155/** The deepest a chain is followed: the tree's depth cap, with room. */
156const MAX_CHAIN = 32;
157
158/** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */
159export function personKeys(person: Person): string[] {
160 return [`user:${person.user_id}`, ...[...person.teams].map((t) => `team:${t.toLowerCase()}`)];
161}
162
163/** Where a folio's access comes from: itself when restricted or at the top, else its parent's access root. */
164export function aclRootOf(node: { id: string; inherit: boolean; parent_id: string | null }, parentAclRoot: string | null): string {
165 return !node.inherit || !node.parent_id || !parentAclRoot ? node.id : parentAclRoot;
166}
167
168/** A folio's path: `/<top id>/…/<id>/`. */
169export function folioPathOf(id: string, parentPath: string | null): string {
170 return parentPath ? `${parentPath}${id}/` : `/${id}/`;
171}
172
173/**
174 * The chain access is read along: the folio, then each ancestor it
175 * inherits from, up to and including its access root. A missing parent
176 * ends the chain there.
177 */
178export function aclChain(id: string, byId: ReadonlyMap<string, FolioAclNode>): FolioAclNode[] {
179 const out: FolioAclNode[] = [];
180 let at = byId.get(id);
181 const seen = new Set<string>();
182 while (at && !seen.has(at.id) && out.length < MAX_CHAIN) {
183 out.push(at);
184 seen.add(at.id);
185 if (!at.inherit || !at.parent_id) break;
186 at = byId.get(at.parent_id);
187 }
188 return out;
189}
190
191/** Whether a chain's access root takes its space's access: at the top of a space, not restricted. */
192export function inheritsSpace(chain: readonly FolioAclNode[]): boolean {
193 const root = chain[chain.length - 1];
194 return !!root && !!root.space_id && root.inherit && !root.parent_id;
195}
196
197/** General access never gives `manage`. */
198export function generalRoleCap(role: DocRole | null | undefined): DocRole {
199 if (!role) return "view";
200 return role === "manage" ? "edit" : role;
201}
202
203/** One principal's explicit access to a folio: its role, whose grant or ownership it is, and since when. */
204export type FolioAccessEntry = { role: DocRole; via: string; since: string };
205
206/**
207 * Explicit access along a chain: the folio's owner (`via` "owner"), the
208 * owners of the ancestors it inherits from, and every grant up to the
209 * access root; the highest role per principal, the nearest on a tie.
210 */
211export function explicitAccess(chain: readonly FolioAclNode[], grants: FolioGrants): Map<string, FolioAccessEntry> {
212 const out = new Map<string, FolioAccessEntry>();
213 const put = (principal: string, role: DocRole, via: string, since: string) => {
214 const was = out.get(principal);
215 if (!was || RANK[role] > RANK[was.role]) out.set(principal, { role, via, since });
216 };
217 chain.forEach((node, i) => {
218 put(node.owner, "manage", i === 0 ? "owner" : node.id, node.created_at ?? "");
219 for (const g of grants.get(node.id) ?? []) put(g.principal, g.role, node.id, g.granted_at ?? "");
220 });
221 return out;
222}
223
224/**
225 * A person's role on a folio, or null when they can't read it. `chain` is
226 * `aclChain`'s; `spaceRole` is their role in the folio's space (`roleOf`),
227 * used only when the chain inherits it; `visited` says they opened the
228 * folio's link (or its access root's).
229 */
230export function effectiveRole(chain: readonly FolioAclNode[], grants: FolioGrants, spaceRole: DocRole | null, person: Person, options: { visited?: boolean } = {}): DocRole | null {
231 const root = chain[chain.length - 1];
232 if (!root) return null;
233 const keys = new Set(personKeys(person));
234 let role: DocRole | null = null;
235 for (const [principal, entry] of explicitAccess(chain, grants)) if (keys.has(principal)) role = higher(role, entry.role);
236 if (inheritsSpace(chain)) role = higher(role, spaceRole);
237 if (root.general_access === "workspace") role = higher(role, generalRoleCap(root.general_role));
238 if (root.general_access === "link" && options.visited) role = higher(role, generalRoleCap(root.general_role));
239 return role;
240}
241
242/** The lock: only the folio's owner can read it (no other owners or grants, no general access, no space it inherits). */
243export function isPrivateFolio(chain: readonly FolioAclNode[], grants: FolioGrants): boolean {
244 const root = chain[chain.length - 1];
245 const self = chain[0];
246 if (!root || !self) return true;
247 if (root.general_access !== "none" || inheritsSpace(chain)) return false;
248 for (const principal of explicitAccess(chain, grants).keys()) if (principal !== self.owner) return false;
249 return true;
250}
251
252/**
253 * Whether "everyone in the workspace" can read a folio: a public
254 * channel's audience. Only through an open space it inherits (with a base
255 * role) or general access `workspace`; never a link, grants or Private.
256 */
257export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean {
258 const root = chain[chain.length - 1];
259 if (!root) return false;
260 if (root.general_access === "workspace") return true;
261 return inheritsSpace(chain) && !!space && readableByWorkspace(space);
262}
263
264/** Whether every one of `people` can read a folio. `visited` says whether a person opened its link. */
265export function folioReadableByAll(
266 chain: readonly FolioAclNode[],
267 grants: FolioGrants,
268 space: SpaceRules | null,
269 people: readonly Person[],
270 visited: (person: Person) => boolean = () => false,
271): boolean {
272 return people.every((person) => atLeast(effectiveRole(chain, grants, space ? roleOf(space, person) : null, person, { visited: visited(person) }), "view"));
273}
274
275/**
276 * The index scope a folio's passages are filed under: its space's when
277 * its access is exactly the space's (inherits to the top, nothing shared
278 * beyond its owners, no general access); otherwise its access root's.
279 */
280export function folioScope(chain: readonly FolioAclNode[], grants: FolioGrants): string {
281 const root = chain[chain.length - 1];
282 if (!root) return "folio:unknown";
283 if (inheritsSpace(chain) && root.general_access === "none") {
284 const owners = new Set(chain.map((n) => n.owner));
285 const shared = [...explicitAccess(chain, grants).keys()].some((p) => !owners.has(p));
286 if (!shared) return `space:${root.space_id}`;
287 }
288 return `folio:${root.id}`;
289}
290
291export type FolioAccessRow = { folio_id: string; principal: string; role: DocRole; via: string; since: string };
292
293/** The rows of `folio_access` for these folios: everything `explicitAccess` finds along each one's chain. */
294export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRow[] {
295 const out: FolioAccessRow[] = [];
296 for (const id of ids) {
297 const chain = aclChain(id, byId);
298 if (!chain.length) continue;
299 for (const [principal, entry] of explicitAccess(chain, grants)) out.push({ folio_id: id, principal, role: entry.role, via: entry.via, since: entry.since });
300 }
301 return out;
302}
303
304/** Whether `role` may change who a folio is shared with: `manage`, or `edit` where editors may share. */
305export function canShare(role: DocRole | null, editorsCanShare = false): boolean {
306 return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit"));
307}
308
309/**
310 * An agent's role on a folio: never more than its asker's, and nothing
311 * when someone it is talking to can't read the folio. The agent's own
312 * grants never widen this; they make it a participant, nothing more.
313 */
314export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null {
315 return audienceCanRead ? askerRole : null;
316}