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