Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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