| 1 | /** |
| 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 | */ |
| 16 | import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts"; |
| 17 | |
| 18 | export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 }; |
| 19 | |
| 20 | export function isRole(value: unknown): value is DocRole { |
| 21 | return value === "view" || value === "comment" || value === "edit" || value === "manage"; |
| 22 | } |
| 23 | |
| 24 | export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean { |
| 25 | return !!role && RANK[role] >= RANK[need]; |
| 26 | } |
| 27 | |
| 28 | export 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 | |
| 34 | export 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. */ |
| 40 | export 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. */ |
| 48 | export 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. */ |
| 57 | export 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. */ |
| 70 | export 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 | */ |
| 78 | export 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`. */ |
| 83 | export 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. */ |
| 92 | export 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 | */ |
| 100 | export 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. */ |
| 108 | export 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 | } |
| 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. */ |
| 136 | export 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. */ |
| 150 | export type FolioGrant = { principal: string; role: DocRole; granted_at?: string }; |
| 151 | |
| 152 | /** Grants by folio id. */ |
| 153 | export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>; |
| 154 | |
| 155 | /** The deepest a chain is followed: the tree's depth cap, with room. */ |
| 156 | const MAX_CHAIN = 32; |
| 157 | |
| 158 | /** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */ |
| 159 | export 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. */ |
| 164 | export 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>/`. */ |
| 169 | export 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 | */ |
| 178 | export 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. */ |
| 192 | export 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`. */ |
| 198 | export 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. */ |
| 204 | export 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 | */ |
| 211 | export 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 | */ |
| 230 | export 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). */ |
| 243 | export 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 | */ |
| 257 | export 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. */ |
| 265 | export 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 | */ |
| 280 | export 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 | |
| 291 | export type FolioAccessRecord = { 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. */ |
| 294 | export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRecord[] { |
| 295 | const out: FolioAccessRecord[] = []; |
| 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. */ |
| 305 | export 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 | */ |
| 314 | export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null { |
| 315 | return audienceCanRead ? askerRole : null; |
| 316 | } |