Skip to content
323 linesCodeBlameRaw
1/**
2 * Who may do what in a space: pure, so the rules are tested apart from the
3 * service (docs.g1t.sh/guides/agent-access/, "Rule one: the asker's access
4 * caps the agent").
5 *
6 * - `workspace` spaces give every member `default_role`; `team` spaces give
7 * the team's members `default_role`; `private` spaces give nobody
8 * anything by default.
9 * - Listed members (`user:<id>`, `agent:<id>`, `team:<slug>`) add to that:
10 * a person's role is the highest that applies to them.
11 * - Workspace owners manage every workspace and team space. A private
12 * space is its members' alone, owners included.
13 * - An agent acts for a person: it reads what they read, suggests where
14 * they can comment, and edits directly only where they can edit and the
15 * space lets agents edit.
16 */
17import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts";
18
19export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
20
21export function isRole(value: unknown): value is DocRole {
22 return value === "view" || value === "comment" || value === "edit" || value === "manage";
23}
24
25export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean {
26 return !!role && RANK[role] >= RANK[need];
27}
28
29export function higher(a: DocRole | null, b: DocRole | null): DocRole | null {
30 if (!a) return b;
31 if (!b) return a;
32 return RANK[a] >= RANK[b] ? a : b;
33}
34
35export function lower(a: DocRole | null, b: DocRole | null): DocRole | null {
36 if (!a || !b) return null;
37 return RANK[a] <= RANK[b] ? a : b;
38}
39
40/** The parts of a space access depends on. */
41export type SpaceRules = {
42 kind: DocSpaceKind;
43 team: string | null;
44 default_role: DocRole | null;
45 members: { principal: string; role: DocRole }[];
46};
47
48/** A person, as access needs them. */
49export type Person = {
50 user_id: string;
51 /** Owner of the workspace. */
52 owner: boolean;
53 /** The slugs of their teams in the workspace, lowercased. */
54 teams: ReadonlySet<string>;
55};
56
57/** A person's role in a space, or null when they can't read it. */
58export function roleOf(space: SpaceRules, person: Person): DocRole | null {
59 let role: DocRole | null = null;
60 if (space.kind === "workspace") role = space.default_role;
61 if (space.kind === "team" && space.team && person.teams.has(space.team.toLowerCase())) role = space.default_role;
62 for (const m of space.members) {
63 if (m.principal === `user:${person.user_id}`) role = higher(role, m.role);
64 else if (m.principal.startsWith("team:") && person.teams.has(m.principal.slice(5).toLowerCase())) role = higher(role, m.role);
65 }
66 if (person.owner && space.kind !== "private") role = "manage";
67 return role;
68}
69
70/** Whether every one of `people` can read the space; for an agent answering to an audience. */
71export function readableByAll(space: SpaceRules, people: Person[]): boolean {
72 return people.every((person) => atLeast(roleOf(space, person), "view"));
73}
74
75/**
76 * Whether a space is readable by "everyone in the workspace": a public
77 * channel's audience. Only a workspace space with a base role is.
78 */
79export function readableByWorkspace(space: SpaceRules): boolean {
80 return space.kind === "workspace" && atLeast(space.default_role, "view");
81}
82
83/**
84 * What an agent may do for a person whose role is `asker`, in a space whose
85 * agents `mode`. Editing the body follows the mode; attaching a file it
86 * made only needs the person to be able to edit, since the body stays as
87 * it is.
88 */
89export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities {
90 return {
91 read: atLeast(asker, "view"),
92 suggest: atLeast(asker, "comment"),
93 edit: atLeast(asker, "edit") && mode === "edit",
94 attach: atLeast(asker, "edit"),
95 };
96}
97
98/** Whether `role` holders may change who is in a space and its settings. */
99export function mayManage(role: DocRole | null): boolean {
100 return atLeast(role, "manage");
101}
102
103/**
104 * Whether removing or lowering `member` would leave a private space with
105 * no one to manage it. Workspace and team spaces always have the owners.
106 */
107export function leavesNoManager(kind: DocSpaceKind, members: { principal: string; role: DocRole }[], member: string, role: DocRole | null): boolean {
108 if (kind !== "private") return false;
109 const after = members.filter((m) => m.principal !== member).map((m) => m.role);
110 if (role) after.push(role);
111 return !after.includes("manage");
112}
113
114/** A member key's parts, or null when it is not one. */
115export function memberKey(key: string): { kind: "user" | "agent" | "team"; id: string } | null {
116 const at = key.indexOf(":");
117 if (at < 0) return null;
118 const kind = key.slice(0, at);
119 const id = key.slice(at + 1);
120 if (!id || (kind !== "user" && kind !== "agent" && kind !== "team")) return null;
121 return { kind, id };
122}
123
124// ── Folios (Artifacts mode) ──────────────────────────────────────────────
125//
126// A person's role on a folio is the highest of:
127// 1. its owner → `manage` (and the owner of every ancestor it inherits
128// from, as if that owner held a `manage` grant there);
129// 2. grants on it or on an ancestor it inherits from, to their `user:` key
130// or one of their `team:` keys;
131// 3. their space role, when the chain reaches the top of a space without a
132// restriction (`inheritsSpace`): workspace owners manage those, as for
133// pages, through `roleOf`;
134// 4. the access root's general access: `workspace` gives every member its
135// role, `link` gives it to members who opened the link (a visit).
136//
137// A restricted folio (`inherit` false) is its own access root: grants
138// above it, its space and its parent's general access stop there. An
139// agent never has more than the person it acts for, narrowed to what
140// everyone it is talking to can read.
141
142/** One folio as access needs it. */
143export type FolioAclNode = {
144 id: string;
145 /** `user:<id>`. */
146 owner: string;
147 parent_id: string | null;
148 space_id: string | null;
149 /** False: "Only people invited", its own access root. */
150 inherit: boolean;
151 general_access: "none" | "workspace" | "link";
152 general_role: DocRole | null;
153 created_at?: string;
154};
155
156/** An explicit share, as set. */
157export type FolioGrant = { principal: string; role: DocRole; granted_at?: string };
158
159/** Grants by folio id. */
160export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>;
161
162/** The deepest a chain is followed: the tree's depth cap, with room. */
163const MAX_CHAIN = 32;
164
165/** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */
166export function personKeys(person: Person): string[] {
167 return [`user:${person.user_id}`, ...[...person.teams].map((t) => `team:${t.toLowerCase()}`)];
168}
169
170/** Where a folio's access comes from: itself when restricted or at the top, else its parent's access root. */
171export function aclRootOf(node: { id: string; inherit: boolean; parent_id: string | null }, parentAclRoot: string | null): string {
172 return !node.inherit || !node.parent_id || !parentAclRoot ? node.id : parentAclRoot;
173}
174
175/** A folio's path: `/<top id>/…/<id>/`. */
176export function folioPathOf(id: string, parentPath: string | null): string {
177 return parentPath ? `${parentPath}${id}/` : `/${id}/`;
178}
179
180/**
181 * The chain access is read along: the folio, then each ancestor it
182 * inherits from, up to and including its access root. A missing parent
183 * ends the chain there.
184 */
185export function aclChain(id: string, byId: ReadonlyMap<string, FolioAclNode>): FolioAclNode[] {
186 const out: FolioAclNode[] = [];
187 let at = byId.get(id);
188 const seen = new Set<string>();
189 while (at && !seen.has(at.id) && out.length < MAX_CHAIN) {
190 out.push(at);
191 seen.add(at.id);
192 if (!at.inherit || !at.parent_id) break;
193 at = byId.get(at.parent_id);
194 }
195 return out;
196}
197
198/** Whether a chain's access root takes its space's access: at the top of a space, not restricted. */
199export function inheritsSpace(chain: readonly FolioAclNode[]): boolean {
200 const root = chain[chain.length - 1];
201 return !!root && !!root.space_id && root.inherit && !root.parent_id;
202}
203
204/** General access never gives `manage`. */
205export function generalRoleCap(role: DocRole | null | undefined): DocRole {
206 if (!role) return "view";
207 return role === "manage" ? "edit" : role;
208}
209
210/** One principal's explicit access to a folio: its role, whose grant or ownership it is, and since when. */
211export type FolioAccessEntry = { role: DocRole; via: string; since: string };
212
213/**
214 * Explicit access along a chain: the folio's owner (`via` "owner"), the
215 * owners of the ancestors it inherits from, and every grant up to the
216 * access root; the highest role per principal, the nearest on a tie.
217 */
218export function explicitAccess(chain: readonly FolioAclNode[], grants: FolioGrants): Map<string, FolioAccessEntry> {
219 const out = new Map<string, FolioAccessEntry>();
220 const put = (principal: string, role: DocRole, via: string, since: string) => {
221 const was = out.get(principal);
222 if (!was || RANK[role] > RANK[was.role]) out.set(principal, { role, via, since });
223 };
224 chain.forEach((node, i) => {
225 put(node.owner, "manage", i === 0 ? "owner" : node.id, node.created_at ?? "");
226 for (const g of grants.get(node.id) ?? []) put(g.principal, g.role, node.id, g.granted_at ?? "");
227 });
228 return out;
229}
230
231/**
232 * A person's role on a folio, or null when they can't read it. `chain` is
233 * `aclChain`'s; `spaceRole` is their role in the folio's space (`roleOf`),
234 * used only when the chain inherits it; `visited` says they opened the
235 * folio's link (or its access root's).
236 */
237export function effectiveRole(chain: readonly FolioAclNode[], grants: FolioGrants, spaceRole: DocRole | null, person: Person, options: { visited?: boolean } = {}): DocRole | null {
238 const root = chain[chain.length - 1];
239 if (!root) return null;
240 const keys = new Set(personKeys(person));
241 let role: DocRole | null = null;
242 for (const [principal, entry] of explicitAccess(chain, grants)) if (keys.has(principal)) role = higher(role, entry.role);
243 if (inheritsSpace(chain)) role = higher(role, spaceRole);
244 if (root.general_access === "workspace") role = higher(role, generalRoleCap(root.general_role));
245 if (root.general_access === "link" && options.visited) role = higher(role, generalRoleCap(root.general_role));
246 return role;
247}
248
249/** The lock: only the folio's owner can read it (no other owners or grants, no general access, no space it inherits). */
250export function isPrivateFolio(chain: readonly FolioAclNode[], grants: FolioGrants): boolean {
251 const root = chain[chain.length - 1];
252 const self = chain[0];
253 if (!root || !self) return true;
254 if (root.general_access !== "none" || inheritsSpace(chain)) return false;
255 for (const principal of explicitAccess(chain, grants).keys()) if (principal !== self.owner) return false;
256 return true;
257}
258
259/**
260 * Whether "everyone in the workspace" can read a folio: a public
261 * channel's audience. Only through an open space it inherits (with a base
262 * role) or general access `workspace`; never a link, grants or Private.
263 */
264export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean {
265 const root = chain[chain.length - 1];
266 if (!root) return false;
267 if (root.general_access === "workspace") return true;
268 return inheritsSpace(chain) && !!space && readableByWorkspace(space);
269}
270
271/** Whether every one of `people` can read a folio. `visited` says whether a person opened its link. */
272export function folioReadableByAll(
273 chain: readonly FolioAclNode[],
274 grants: FolioGrants,
275 space: SpaceRules | null,
276 people: readonly Person[],
277 visited: (person: Person) => boolean = () => false,
278): boolean {
279 return people.every((person) => atLeast(effectiveRole(chain, grants, space ? roleOf(space, person) : null, person, { visited: visited(person) }), "view"));
280}
281
282/**
283 * The index scope a folio's passages are filed under: its space's when
284 * its access is exactly the space's (inherits to the top, nothing shared
285 * beyond its owners, no general access); otherwise its access root's.
286 */
287export function folioScope(chain: readonly FolioAclNode[], grants: FolioGrants): string {
288 const root = chain[chain.length - 1];
289 if (!root) return "folio:unknown";
290 if (inheritsSpace(chain) && root.general_access === "none") {
291 const owners = new Set(chain.map((n) => n.owner));
292 const shared = [...explicitAccess(chain, grants).keys()].some((p) => !owners.has(p));
293 if (!shared) return `space:${root.space_id}`;
294 }
295 return `folio:${root.id}`;
296}
297
298export type FolioAccessRecord = { folio_id: string; principal: string; role: DocRole; via: string; since: string };
299
300/** The rows of `folio_access` for these folios: everything `explicitAccess` finds along each one's chain. */
301export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRecord[] {
302 const out: FolioAccessRecord[] = [];
303 for (const id of ids) {
304 const chain = aclChain(id, byId);
305 if (!chain.length) continue;
306 for (const [principal, entry] of explicitAccess(chain, grants)) out.push({ folio_id: id, principal, role: entry.role, via: entry.via, since: entry.since });
307 }
308 return out;
309}
310
311/** Whether `role` may change who a folio is shared with: `manage`, or `edit` where editors may share. */
312export function canShare(role: DocRole | null, editorsCanShare = false): boolean {
313 return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit"));
314}
315
316/**
317 * An agent's role on a folio: never more than its asker's, and nothing
318 * when someone it is talking to can't read the folio. The agent's own
319 * grants never widen this; they make it a participant, nothing more.
320 */
321export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null {
322 return audienceCanRead ? askerRole : null;
323}