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 together | 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 | } | |
| 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. */ | |
| 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 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. */ | |
| 294 | export 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. */ | |
| 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 | } |