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 | ||
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 291 | export type FolioAccessRecord = { folio_id: string; principal: string; role: DocRole; via: string; since: string }; |
| 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. | 292 | |
| 293 | /** The rows of `folio_access` for these folios: everything `explicitAccess` finds along each one's chain. */ | |
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 294 | export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRecord[] { |
| 295 | const out: FolioAccessRecord[] = []; | |
| 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. | 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 | } |