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 | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 3 | * service. |
| 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. | 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. | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 8 | * - Listed members (`user:<id>`, `team:<slug>`) add to that: |
| 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. | 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 | */ | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 13 | import type { DocRole, DocSpaceKind } from "@g1t/contracts"; |
| 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. | 14 | |
| 15 | export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 }; | |
| 16 | ||
| 17 | export function isRole(value: unknown): value is DocRole { | |
| 18 | return value === "view" || value === "comment" || value === "edit" || value === "manage"; | |
| 19 | } | |
| 20 | ||
| 21 | export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean { | |
| 22 | return !!role && RANK[role] >= RANK[need]; | |
| 23 | } | |
| 24 | ||
| 25 | export 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 | ||
| 31 | export 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. */ | |
| 37 | export 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. */ | |
| 45 | export 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. */ | |
| 54 | export 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 | /** | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 67 | * Whether a space is readable by "everyone in the workspace". Only a |
| 68 | * workspace space with a base role is. | |
| 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. | 69 | */ |
| 70 | export 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. */ | |
| 75 | export 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 | */ | |
| 83 | export 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. */ | |
| 91 | export 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 | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 114 | // above it, its space and its parent's general access stop there. |
| 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. | 115 | |
| 116 | /** One folio as access needs it. */ | |
| 117 | export 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. */ | |
| 131 | export type FolioGrant = { principal: string; role: DocRole; granted_at?: string }; | |
| 132 | ||
| 133 | /** Grants by folio id. */ | |
| 134 | export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>; | |
| 135 | ||
| 136 | /** The deepest a chain is followed: the tree's depth cap, with room. */ | |
| 137 | const MAX_CHAIN = 32; | |
| 138 | ||
| 139 | /** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */ | |
| 140 | export 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. */ | |
| 145 | export 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>/`. */ | |
| 150 | export 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 | */ | |
| 159 | export 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. */ | |
| 173 | export 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`. */ | |
| 179 | export 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. */ | |
| 185 | export 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 | */ | |
| 192 | export 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 | */ | |
| 211 | export 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). */ | |
| 224 | export 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 | /** | |
| Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents. | 234 | * Whether "everyone in the workspace" can read a folio. Only through an open space it inherits (with a base |
| 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. | 235 | * role) or general access `workspace`; never a link, grants or Private. |
| 236 | */ | |
| 237 | export 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 | */ | |
| 249 | export 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 | ||
| 260 | export 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. */ | |
| 263 | export 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. */ | |
| 274 | export function canShare(role: DocRole | null, editorsCanShare = false): boolean { | |
| 275 | return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit")); | |
| 276 | } | |
| 277 |