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.
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 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 | */ | |
| 17 | import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts"; | |
| 18 | ||
| 19 | export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 }; | |
| 20 | ||
| 21 | export function isRole(value: unknown): value is DocRole { | |
| 22 | return value === "view" || value === "comment" || value === "edit" || value === "manage"; | |
| 23 | } | |
| 24 | ||
| 25 | export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean { | |
| 26 | return !!role && RANK[role] >= RANK[need]; | |
| 27 | } | |
| 28 | ||
| 29 | export 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 | ||
| 35 | export 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. */ | |
| 41 | export 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. */ | |
| 49 | export 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. */ | |
| 58 | export 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. */ | |
| 71 | export 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 | */ | |
| 79 | export function readableByWorkspace(space: SpaceRules): boolean { | |
| 80 | return space.kind === "workspace" && atLeast(space.default_role, "view"); | |
| 81 | } | |
| 82 | ||
| Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between. | 83 | /** |
| 84 | * What an agent may do for a person whose role is `asker`, in a space whose | |
| 85 | * agents `mode`. Editing the body follows the mode; attaching a file it | |
| 86 | * made only needs the person to be able to edit, since the body stays as | |
| 87 | * it is. | |
| 88 | */ | |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 89 | export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities { |
| 90 | return { | |
| 91 | read: atLeast(asker, "view"), | |
| 92 | suggest: atLeast(asker, "comment"), | |
| 93 | edit: atLeast(asker, "edit") && mode === "edit", | |
| Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between. | 94 | attach: atLeast(asker, "edit"), |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 95 | }; |
| 96 | } | |
| 97 | ||
| 98 | /** Whether `role` holders may change who is in a space and its settings. */ | |
| 99 | export function mayManage(role: DocRole | null): boolean { | |
| 100 | return atLeast(role, "manage"); | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Whether removing or lowering `member` would leave a private space with | |
| 105 | * no one to manage it. Workspace and team spaces always have the owners. | |
| 106 | */ | |
| 107 | export function leavesNoManager(kind: DocSpaceKind, members: { principal: string; role: DocRole }[], member: string, role: DocRole | null): boolean { | |
| 108 | if (kind !== "private") return false; | |
| 109 | const after = members.filter((m) => m.principal !== member).map((m) => m.role); | |
| 110 | if (role) after.push(role); | |
| 111 | return !after.includes("manage"); | |
| 112 | } | |
| 113 | ||
| 114 | /** A member key's parts, or null when it is not one. */ | |
| 115 | export function memberKey(key: string): { kind: "user" | "agent" | "team"; id: string } | null { | |
| 116 | const at = key.indexOf(":"); | |
| 117 | if (at < 0) return null; | |
| 118 | const kind = key.slice(0, at); | |
| 119 | const id = key.slice(at + 1); | |
| 120 | if (!id || (kind !== "user" && kind !== "agent" && kind !== "team")) return null; | |
| 121 | return { kind, id }; | |
| 122 | } | |
| 123 | ||
| 124 | // ── Folios (Artifacts mode) ────────────────────────────────────────────── | |
| 125 | // | |
| 126 | // A person's role on a folio is the highest of: | |
| 127 | // 1. its owner → `manage` (and the owner of every ancestor it inherits | |
| 128 | // from, as if that owner held a `manage` grant there); | |
| 129 | // 2. grants on it or on an ancestor it inherits from, to their `user:` key | |
| 130 | // or one of their `team:` keys; | |
| 131 | // 3. their space role, when the chain reaches the top of a space without a | |
| 132 | // restriction (`inheritsSpace`): workspace owners manage those, as for | |
| 133 | // pages, through `roleOf`; | |
| 134 | // 4. the access root's general access: `workspace` gives every member its | |
| 135 | // role, `link` gives it to members who opened the link (a visit). | |
| 136 | // | |
| 137 | // A restricted folio (`inherit` false) is its own access root: grants | |
| 138 | // above it, its space and its parent's general access stop there. An | |
| 139 | // agent never has more than the person it acts for, narrowed to what | |
| 140 | // everyone it is talking to can read. | |
| 141 | ||
| 142 | /** One folio as access needs it. */ | |
| 143 | export type FolioAclNode = { | |
| 144 | id: string; | |
| 145 | /** `user:<id>`. */ | |
| 146 | owner: string; | |
| 147 | parent_id: string | null; | |
| 148 | space_id: string | null; | |
| 149 | /** False: "Only people invited", its own access root. */ | |
| 150 | inherit: boolean; | |
| 151 | general_access: "none" | "workspace" | "link"; | |
| 152 | general_role: DocRole | null; | |
| 153 | created_at?: string; | |
| 154 | }; | |
| 155 | ||
| 156 | /** An explicit share, as set. */ | |
| 157 | export type FolioGrant = { principal: string; role: DocRole; granted_at?: string }; | |
| 158 | ||
| 159 | /** Grants by folio id. */ | |
| 160 | export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>; | |
| 161 | ||
| 162 | /** The deepest a chain is followed: the tree's depth cap, with room. */ | |
| 163 | const MAX_CHAIN = 32; | |
| 164 | ||
| 165 | /** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */ | |
| 166 | export function personKeys(person: Person): string[] { | |
| 167 | return [`user:${person.user_id}`, ...[...person.teams].map((t) => `team:${t.toLowerCase()}`)]; | |
| 168 | } | |
| 169 | ||
| 170 | /** Where a folio's access comes from: itself when restricted or at the top, else its parent's access root. */ | |
| 171 | export function aclRootOf(node: { id: string; inherit: boolean; parent_id: string | null }, parentAclRoot: string | null): string { | |
| 172 | return !node.inherit || !node.parent_id || !parentAclRoot ? node.id : parentAclRoot; | |
| 173 | } | |
| 174 | ||
| 175 | /** A folio's path: `/<top id>/…/<id>/`. */ | |
| 176 | export function folioPathOf(id: string, parentPath: string | null): string { | |
| 177 | return parentPath ? `${parentPath}${id}/` : `/${id}/`; | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * The chain access is read along: the folio, then each ancestor it | |
| 182 | * inherits from, up to and including its access root. A missing parent | |
| 183 | * ends the chain there. | |
| 184 | */ | |
| 185 | export function aclChain(id: string, byId: ReadonlyMap<string, FolioAclNode>): FolioAclNode[] { | |
| 186 | const out: FolioAclNode[] = []; | |
| 187 | let at = byId.get(id); | |
| 188 | const seen = new Set<string>(); | |
| 189 | while (at && !seen.has(at.id) && out.length < MAX_CHAIN) { | |
| 190 | out.push(at); | |
| 191 | seen.add(at.id); | |
| 192 | if (!at.inherit || !at.parent_id) break; | |
| 193 | at = byId.get(at.parent_id); | |
| 194 | } | |
| 195 | return out; | |
| 196 | } | |
| 197 | ||
| 198 | /** Whether a chain's access root takes its space's access: at the top of a space, not restricted. */ | |
| 199 | export function inheritsSpace(chain: readonly FolioAclNode[]): boolean { | |
| 200 | const root = chain[chain.length - 1]; | |
| 201 | return !!root && !!root.space_id && root.inherit && !root.parent_id; | |
| 202 | } | |
| 203 | ||
| 204 | /** General access never gives `manage`. */ | |
| 205 | export function generalRoleCap(role: DocRole | null | undefined): DocRole { | |
| 206 | if (!role) return "view"; | |
| 207 | return role === "manage" ? "edit" : role; | |
| 208 | } | |
| 209 | ||
| 210 | /** One principal's explicit access to a folio: its role, whose grant or ownership it is, and since when. */ | |
| 211 | export type FolioAccessEntry = { role: DocRole; via: string; since: string }; | |
| 212 | ||
| 213 | /** | |
| 214 | * Explicit access along a chain: the folio's owner (`via` "owner"), the | |
| 215 | * owners of the ancestors it inherits from, and every grant up to the | |
| 216 | * access root; the highest role per principal, the nearest on a tie. | |
| 217 | */ | |
| 218 | export function explicitAccess(chain: readonly FolioAclNode[], grants: FolioGrants): Map<string, FolioAccessEntry> { | |
| 219 | const out = new Map<string, FolioAccessEntry>(); | |
| 220 | const put = (principal: string, role: DocRole, via: string, since: string) => { | |
| 221 | const was = out.get(principal); | |
| 222 | if (!was || RANK[role] > RANK[was.role]) out.set(principal, { role, via, since }); | |
| 223 | }; | |
| 224 | chain.forEach((node, i) => { | |
| 225 | put(node.owner, "manage", i === 0 ? "owner" : node.id, node.created_at ?? ""); | |
| 226 | for (const g of grants.get(node.id) ?? []) put(g.principal, g.role, node.id, g.granted_at ?? ""); | |
| 227 | }); | |
| 228 | return out; | |
| 229 | } | |
| 230 | ||
| 231 | /** | |
| 232 | * A person's role on a folio, or null when they can't read it. `chain` is | |
| 233 | * `aclChain`'s; `spaceRole` is their role in the folio's space (`roleOf`), | |
| 234 | * used only when the chain inherits it; `visited` says they opened the | |
| 235 | * folio's link (or its access root's). | |
| 236 | */ | |
| 237 | export function effectiveRole(chain: readonly FolioAclNode[], grants: FolioGrants, spaceRole: DocRole | null, person: Person, options: { visited?: boolean } = {}): DocRole | null { | |
| 238 | const root = chain[chain.length - 1]; | |
| 239 | if (!root) return null; | |
| 240 | const keys = new Set(personKeys(person)); | |
| 241 | let role: DocRole | null = null; | |
| 242 | for (const [principal, entry] of explicitAccess(chain, grants)) if (keys.has(principal)) role = higher(role, entry.role); | |
| 243 | if (inheritsSpace(chain)) role = higher(role, spaceRole); | |
| 244 | if (root.general_access === "workspace") role = higher(role, generalRoleCap(root.general_role)); | |
| 245 | if (root.general_access === "link" && options.visited) role = higher(role, generalRoleCap(root.general_role)); | |
| 246 | return role; | |
| 247 | } | |
| 248 | ||
| 249 | /** The lock: only the folio's owner can read it (no other owners or grants, no general access, no space it inherits). */ | |
| 250 | export function isPrivateFolio(chain: readonly FolioAclNode[], grants: FolioGrants): boolean { | |
| 251 | const root = chain[chain.length - 1]; | |
| 252 | const self = chain[0]; | |
| 253 | if (!root || !self) return true; | |
| 254 | if (root.general_access !== "none" || inheritsSpace(chain)) return false; | |
| 255 | for (const principal of explicitAccess(chain, grants).keys()) if (principal !== self.owner) return false; | |
| 256 | return true; | |
| 257 | } | |
| 258 | ||
| 259 | /** | |
| 260 | * Whether "everyone in the workspace" can read a folio: a public | |
| 261 | * channel's audience. Only through an open space it inherits (with a base | |
| 262 | * role) or general access `workspace`; never a link, grants or Private. | |
| 263 | */ | |
| 264 | export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean { | |
| 265 | const root = chain[chain.length - 1]; | |
| 266 | if (!root) return false; | |
| 267 | if (root.general_access === "workspace") return true; | |
| 268 | return inheritsSpace(chain) && !!space && readableByWorkspace(space); | |
| 269 | } | |
| 270 | ||
| 271 | /** Whether every one of `people` can read a folio. `visited` says whether a person opened its link. */ | |
| 272 | export function folioReadableByAll( | |
| 273 | chain: readonly FolioAclNode[], | |
| 274 | grants: FolioGrants, | |
| 275 | space: SpaceRules | null, | |
| 276 | people: readonly Person[], | |
| 277 | visited: (person: Person) => boolean = () => false, | |
| 278 | ): boolean { | |
| 279 | return people.every((person) => atLeast(effectiveRole(chain, grants, space ? roleOf(space, person) : null, person, { visited: visited(person) }), "view")); | |
| 280 | } | |
| 281 | ||
| 282 | /** | |
| 283 | * The index scope a folio's passages are filed under: its space's when | |
| 284 | * its access is exactly the space's (inherits to the top, nothing shared | |
| 285 | * beyond its owners, no general access); otherwise its access root's. | |
| 286 | */ | |
| 287 | export function folioScope(chain: readonly FolioAclNode[], grants: FolioGrants): string { | |
| 288 | const root = chain[chain.length - 1]; | |
| 289 | if (!root) return "folio:unknown"; | |
| 290 | if (inheritsSpace(chain) && root.general_access === "none") { | |
| 291 | const owners = new Set(chain.map((n) => n.owner)); | |
| 292 | const shared = [...explicitAccess(chain, grants).keys()].some((p) => !owners.has(p)); | |
| 293 | if (!shared) return `space:${root.space_id}`; | |
| 294 | } | |
| 295 | return `folio:${root.id}`; | |
| 296 | } | |
| 297 | ||
| 298 | export type FolioAccessRecord = { folio_id: string; principal: string; role: DocRole; via: string; since: string }; | |
| 299 | ||
| 300 | /** The rows of `folio_access` for these folios: everything `explicitAccess` finds along each one's chain. */ | |
| 301 | export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRecord[] { | |
| 302 | const out: FolioAccessRecord[] = []; | |
| 303 | for (const id of ids) { | |
| 304 | const chain = aclChain(id, byId); | |
| 305 | if (!chain.length) continue; | |
| 306 | for (const [principal, entry] of explicitAccess(chain, grants)) out.push({ folio_id: id, principal, role: entry.role, via: entry.via, since: entry.since }); | |
| 307 | } | |
| 308 | return out; | |
| 309 | } | |
| 310 | ||
| 311 | /** Whether `role` may change who a folio is shared with: `manage`, or `edit` where editors may share. */ | |
| 312 | export function canShare(role: DocRole | null, editorsCanShare = false): boolean { | |
| 313 | return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit")); | |
| 314 | } | |
| 315 | ||
| 316 | /** | |
| 317 | * An agent's role on a folio: never more than its asker's, and nothing | |
| 318 | * when someone it is talking to can't read the folio. The agent's own | |
| 319 | * grants never widen this; they make it a participant, nothing more. | |
| 320 | */ | |
| 321 | export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null { | |
| 322 | return audienceCanRead ? askerRole : null; | |
| 323 | } |