Skip to content
277 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
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.13import 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
15export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
16
17export function isRole(value: unknown): value is DocRole {
18 return value === "view" || value === "comment" || value === "edit" || value === "manage";
19}
20
21export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean {
22 return !!role && RANK[role] >= RANK[need];
23}
24
25export 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
31export 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. */
37export 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. */
45export 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. */
54export 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 */
70export 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. */
75export 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 */
83export 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. */
91export 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. */
117export 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. */
131export type FolioGrant = { principal: string; role: DocRole; granted_at?: string };
132
133/** Grants by folio id. */
134export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>;
135
136/** The deepest a chain is followed: the tree's depth cap, with room. */
137const MAX_CHAIN = 32;
138
139/** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */
140export 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. */
145export 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>/`. */
150export 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 */
159export 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. */
173export 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`. */
179export 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. */
185export 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 */
192export 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 */
211export 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). */
224export 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 */
237export 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 */
249export 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
260export 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. */
263export 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. */
274export function canShare(role: DocRole | null, editorsCanShare = false): boolean {
275 return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit"));
276}
277