Skip to content
323 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.

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 */
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
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.89export 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. */
99export 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 */
107export 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. */
115export 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. */
143export 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. */
157export type FolioGrant = { principal: string; role: DocRole; granted_at?: string };
158
159/** Grants by folio id. */
160export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>;
161
162/** The deepest a chain is followed: the tree's depth cap, with room. */
163const MAX_CHAIN = 32;
164
165/** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */
166export 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. */
171export 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>/`. */
176export 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 */
185export 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. */
199export 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`. */
205export 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. */
211export 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 */
218export 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 */
237export 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). */
250export 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 */
264export 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. */
272export 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 */
287export 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
298export 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. */
301export 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. */
312export 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 */
321export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null {
322 return audienceCanRead ? askerRole : null;
323}