Skip to content
317 linesCodeBlameRaw

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 together1/**
2 * Who may do what in a space: pure, so the rules are tested apart from the
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.3 * service (docs.g1t.sh/guides/agent-access/, "Rule one: the asker's access
4 * caps the agent").
Docs: a workspace knowledge base people and agents write together5 *
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 */
17import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts";
18
19export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
20
21export function isRole(value: unknown): value is DocRole {
22 return value === "view" || value === "comment" || value === "edit" || value === "manage";
23}
24
25export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean {
26 return !!role && RANK[role] >= RANK[need];
27}
28
29export 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
35export 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. */
41export 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. */
49export 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. */
58export 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. */
71export 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 */
79export function readableByWorkspace(space: SpaceRules): boolean {
80 return space.kind === "workspace" && atLeast(space.default_role, "view");
81}
82
83/** What an agent may do for a person whose role is `asker`, in a space whose agents `mode`. */
84export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities {
85 return {
86 read: atLeast(asker, "view"),
87 suggest: atLeast(asker, "comment"),
88 edit: atLeast(asker, "edit") && mode === "edit",
89 };
90}
91
92/** Whether `role` holders may change who is in a space and its settings. */
93export function mayManage(role: DocRole | null): boolean {
94 return atLeast(role, "manage");
95}
96
97/**
98 * Whether removing or lowering `member` would leave a private space with
99 * no one to manage it. Workspace and team spaces always have the owners.
100 */
101export function leavesNoManager(kind: DocSpaceKind, members: { principal: string; role: DocRole }[], member: string, role: DocRole | null): boolean {
102 if (kind !== "private") return false;
103 const after = members.filter((m) => m.principal !== member).map((m) => m.role);
104 if (role) after.push(role);
105 return !after.includes("manage");
106}
107
108/** A member key's parts, or null when it is not one. */
109export function memberKey(key: string): { kind: "user" | "agent" | "team"; id: string } | null {
110 const at = key.indexOf(":");
111 if (at < 0) return null;
112 const kind = key.slice(0, at);
113 const id = key.slice(at + 1);
114 if (!id || (kind !== "user" && kind !== "agent" && kind !== "team")) return null;
115 return { kind, id };
116}
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.117
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.118// ── Folios (Artifacts mode) ──────────────────────────────────────────────
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.119//
120// A person's role on a folio is the highest of:
121// 1. its owner → `manage` (and the owner of every ancestor it inherits
122// from, as if that owner held a `manage` grant there);
123// 2. grants on it or on an ancestor it inherits from, to their `user:` key
124// or one of their `team:` keys;
125// 3. their space role, when the chain reaches the top of a space without a
126// restriction (`inheritsSpace`): workspace owners manage those, as for
127// pages, through `roleOf`;
128// 4. the access root's general access: `workspace` gives every member its
129// role, `link` gives it to members who opened the link (a visit).
130//
131// A restricted folio (`inherit` false) is its own access root: grants
132// above it, its space and its parent's general access stop there. An
133// agent never has more than the person it acts for, narrowed to what
134// everyone it is talking to can read.
135
136/** One folio as access needs it. */
137export type FolioAclNode = {
138 id: string;
139 /** `user:<id>`. */
140 owner: string;
141 parent_id: string | null;
142 space_id: string | null;
143 /** False: "Only people invited", its own access root. */
144 inherit: boolean;
145 general_access: "none" | "workspace" | "link";
146 general_role: DocRole | null;
147 created_at?: string;
148};
149
150/** An explicit share, as set. */
151export type FolioGrant = { principal: string; role: DocRole; granted_at?: string };
152
153/** Grants by folio id. */
154export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>;
155
156/** The deepest a chain is followed: the tree's depth cap, with room. */
157const MAX_CHAIN = 32;
158
159/** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */
160export function personKeys(person: Person): string[] {
161 return [`user:${person.user_id}`, ...[...person.teams].map((t) => `team:${t.toLowerCase()}`)];
162}
163
164/** Where a folio's access comes from: itself when restricted or at the top, else its parent's access root. */
165export function aclRootOf(node: { id: string; inherit: boolean; parent_id: string | null }, parentAclRoot: string | null): string {
166 return !node.inherit || !node.parent_id || !parentAclRoot ? node.id : parentAclRoot;
167}
168
169/** A folio's path: `/<top id>/…/<id>/`. */
170export function folioPathOf(id: string, parentPath: string | null): string {
171 return parentPath ? `${parentPath}${id}/` : `/${id}/`;
172}
173
174/**
175 * The chain access is read along: the folio, then each ancestor it
176 * inherits from, up to and including its access root. A missing parent
177 * ends the chain there.
178 */
179export function aclChain(id: string, byId: ReadonlyMap<string, FolioAclNode>): FolioAclNode[] {
180 const out: FolioAclNode[] = [];
181 let at = byId.get(id);
182 const seen = new Set<string>();
183 while (at && !seen.has(at.id) && out.length < MAX_CHAIN) {
184 out.push(at);
185 seen.add(at.id);
186 if (!at.inherit || !at.parent_id) break;
187 at = byId.get(at.parent_id);
188 }
189 return out;
190}
191
192/** Whether a chain's access root takes its space's access: at the top of a space, not restricted. */
193export function inheritsSpace(chain: readonly FolioAclNode[]): boolean {
194 const root = chain[chain.length - 1];
195 return !!root && !!root.space_id && root.inherit && !root.parent_id;
196}
197
198/** General access never gives `manage`. */
199export function generalRoleCap(role: DocRole | null | undefined): DocRole {
200 if (!role) return "view";
201 return role === "manage" ? "edit" : role;
202}
203
204/** One principal's explicit access to a folio: its role, whose grant or ownership it is, and since when. */
205export type FolioAccessEntry = { role: DocRole; via: string; since: string };
206
207/**
208 * Explicit access along a chain: the folio's owner (`via` "owner"), the
209 * owners of the ancestors it inherits from, and every grant up to the
210 * access root; the highest role per principal, the nearest on a tie.
211 */
212export function explicitAccess(chain: readonly FolioAclNode[], grants: FolioGrants): Map<string, FolioAccessEntry> {
213 const out = new Map<string, FolioAccessEntry>();
214 const put = (principal: string, role: DocRole, via: string, since: string) => {
215 const was = out.get(principal);
216 if (!was || RANK[role] > RANK[was.role]) out.set(principal, { role, via, since });
217 };
218 chain.forEach((node, i) => {
219 put(node.owner, "manage", i === 0 ? "owner" : node.id, node.created_at ?? "");
220 for (const g of grants.get(node.id) ?? []) put(g.principal, g.role, node.id, g.granted_at ?? "");
221 });
222 return out;
223}
224
225/**
226 * A person's role on a folio, or null when they can't read it. `chain` is
227 * `aclChain`'s; `spaceRole` is their role in the folio's space (`roleOf`),
228 * used only when the chain inherits it; `visited` says they opened the
229 * folio's link (or its access root's).
230 */
231export function effectiveRole(chain: readonly FolioAclNode[], grants: FolioGrants, spaceRole: DocRole | null, person: Person, options: { visited?: boolean } = {}): DocRole | null {
232 const root = chain[chain.length - 1];
233 if (!root) return null;
234 const keys = new Set(personKeys(person));
235 let role: DocRole | null = null;
236 for (const [principal, entry] of explicitAccess(chain, grants)) if (keys.has(principal)) role = higher(role, entry.role);
237 if (inheritsSpace(chain)) role = higher(role, spaceRole);
238 if (root.general_access === "workspace") role = higher(role, generalRoleCap(root.general_role));
239 if (root.general_access === "link" && options.visited) role = higher(role, generalRoleCap(root.general_role));
240 return role;
241}
242
243/** The lock: only the folio's owner can read it (no other owners or grants, no general access, no space it inherits). */
244export function isPrivateFolio(chain: readonly FolioAclNode[], grants: FolioGrants): boolean {
245 const root = chain[chain.length - 1];
246 const self = chain[0];
247 if (!root || !self) return true;
248 if (root.general_access !== "none" || inheritsSpace(chain)) return false;
249 for (const principal of explicitAccess(chain, grants).keys()) if (principal !== self.owner) return false;
250 return true;
251}
252
253/**
254 * Whether "everyone in the workspace" can read a folio: a public
255 * channel's audience. Only through an open space it inherits (with a base
256 * role) or general access `workspace`; never a link, grants or Private.
257 */
258export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean {
259 const root = chain[chain.length - 1];
260 if (!root) return false;
261 if (root.general_access === "workspace") return true;
262 return inheritsSpace(chain) && !!space && readableByWorkspace(space);
263}
264
265/** Whether every one of `people` can read a folio. `visited` says whether a person opened its link. */
266export function folioReadableByAll(
267 chain: readonly FolioAclNode[],
268 grants: FolioGrants,
269 space: SpaceRules | null,
270 people: readonly Person[],
271 visited: (person: Person) => boolean = () => false,
272): boolean {
273 return people.every((person) => atLeast(effectiveRole(chain, grants, space ? roleOf(space, person) : null, person, { visited: visited(person) }), "view"));
274}
275
276/**
277 * The index scope a folio's passages are filed under: its space's when
278 * its access is exactly the space's (inherits to the top, nothing shared
279 * beyond its owners, no general access); otherwise its access root's.
280 */
281export function folioScope(chain: readonly FolioAclNode[], grants: FolioGrants): string {
282 const root = chain[chain.length - 1];
283 if (!root) return "folio:unknown";
284 if (inheritsSpace(chain) && root.general_access === "none") {
285 const owners = new Set(chain.map((n) => n.owner));
286 const shared = [...explicitAccess(chain, grants).keys()].some((p) => !owners.has(p));
287 if (!shared) return `space:${root.space_id}`;
288 }
289 return `folio:${root.id}`;
290}
291
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.292export 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.293
294/** 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.295export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRecord[] {
296 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.297 for (const id of ids) {
298 const chain = aclChain(id, byId);
299 if (!chain.length) continue;
300 for (const [principal, entry] of explicitAccess(chain, grants)) out.push({ folio_id: id, principal, role: entry.role, via: entry.via, since: entry.since });
301 }
302 return out;
303}
304
305/** Whether `role` may change who a folio is shared with: `manage`, or `edit` where editors may share. */
306export function canShare(role: DocRole | null, editorsCanShare = false): boolean {
307 return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit"));
308}
309
310/**
311 * An agent's role on a folio: never more than its asker's, and nothing
312 * when someone it is talking to can't read the folio. The agent's own
313 * grants never widen this; they make it a participant, nothing more.
314 */
315export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null {
316 return audienceCanRead ? askerRole : null;
317}