Skip to content
282 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 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.1/**
2 * Who and where, for the docs service's folio code: the workspace by
3 * slug, its people, agents and teams, how member keys show, and the
4 * spaces with a person's role in each. Cached per request (one instance
5 * per request). Docs' page code (src/index.ts, `Docs`) keeps its own copy
6 * of these until Phase 7 of docs/ARTIFACTS_MODE.md removes it.
7 */
8import {
9 fail,
10 identityClient,
11 newId,
12 ok,
13 parsePrincipalKey,
14 principalKey,
15 workspaceAgentsClient,
16 type DocAgentMode,
17 type DocRole,
18 type DocSpace,
19 type DocSpaceKind,
20 type Member,
21 type MemberProfile,
22 type Principal,
23 type Result,
24 type ServiceBinding,
25 type User,
26 type Viewer,
27 type Workspace,
28 type WorkspaceAgent,
29} from "@g1t/contracts";
30
31import { roleOf, type Person, type SpaceRules } from "./access.ts";
32import { freeSlug } from "./slugs.ts";
33
34export type WhoEnv = { DB: D1Database; IDENTITY: ServiceBinding; AGENTS: ServiceBinding };
35
36export type SpaceRow = {
37 id: string;
38 workspace_id: string;
39 slug: string;
40 name: string;
41 description: string | null;
42 icon: string | null;
43 kind: DocSpaceKind;
44 team: string | null;
45 default_role: DocRole | null;
46 agent_mode: DocAgentMode;
47 is_default: number;
48 created_by: string;
49 created_at: string;
50 archived_at: string | null;
51};
52
53/** A space, with who is in it and the viewer's role (null: they can't read it). */
54export type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
55
56export const now = () => new Date().toISOString();
57
58export function rulesOf(space: Pick<Space, "row" | "members">): SpaceRules {
59 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
60}
61
62export function isMember(viewer: Viewer, workspace: string): boolean {
63 return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
64}
65
66export function userKey(viewer: Pick<User, "id">): string {
67 return principalKey({ kind: "user", id: viewer.id });
68}
69
70export class Who {
71 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
72 private readonly people = new Map<string, Promise<Map<string, Member>>>();
73 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
74 private readonly spaces = new Map<string, Promise<Omit<Space, "role">[]>>();
75 readonly usernames = new Map<string, string>();
76 private readonly agents = new Map<string, WorkspaceAgent | null>();
77
78 constructor(private readonly env: WhoEnv) {}
79
80 workspace(slug: string): Promise<Workspace | null> {
81 const key = String(slug ?? "").toLowerCase();
82 let found = this.workspaces.get(key);
83 if (!found) {
84 found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
85 this.workspaces.set(key, found);
86 }
87 return found;
88 }
89
90 /** The workspace acting for itself: how this service asks identity about its members. */
91 actor(workspace: Workspace): User {
92 return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
93 }
94
95 /** The workspace's people by username (lowercased). */
96 members(workspace: Workspace): Promise<Map<string, Member>> {
97 let found = this.people.get(workspace.id);
98 if (!found) {
99 found = identityClient(this.env.IDENTITY)
100 .listMembers(workspace.slug, this.actor(workspace))
101 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
102 .catch(() => new Map<string, Member>());
103 this.people.set(workspace.id, found);
104 }
105 return found;
106 }
107
108 /** Each member's teams (slugs, lowercased), by username. */
109 teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
110 let found = this.teams.get(workspace.id);
111 if (!found) {
112 found = identityClient(this.env.IDENTITY)
113 .teamMemberships(this.actor(workspace), workspace.slug)
114 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
115 .catch(() => new Map<string, Set<string>>());
116 this.teams.set(workspace.id, found);
117 }
118 return found;
119 }
120
121 async nameUsers(ids: string[]): Promise<void> {
122 const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
123 if (!unnamed.length) return;
124 const named = await identityClient(this.env.IDENTITY)
125 .usernames(unnamed)
126 .catch(() => ({}) as Record<string, string>);
127 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
128 }
129
130 async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
131 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
132 if (wanted.length) {
133 let found: WorkspaceAgent[] = [];
134 try {
135 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
136 } catch (error) {
137 console.error("folios could not resolve agents", error);
138 }
139 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
140 }
141 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
142 }
143
144 /** How member keys show. Anything that isn't a person or agent shows as g1t. */
145 async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
146 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
147 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
148 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
149 const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
150 const out = new Map<string, MemberProfile>();
151 for (const p of principals) {
152 if (p.kind === "user") {
153 const username = this.usernames.get(p.id) ?? null;
154 const person = username ? people.get(username.toLowerCase()) : undefined;
155 out.set(principalKey(p), { ...p, name: username ?? "ghost", display_name: person?.name || username || "Former member", avatar: person?.avatar ?? null, role: null, title: null, avatar_seed: null });
156 } else {
157 const agent = agents.get(p.id) ?? null;
158 out.set(principalKey(p), {
159 ...p,
160 name: agent?.handle ?? p.id,
161 display_name: agent?.display_name ?? "Former agent",
162 avatar: agent?.avatar ?? null,
163 role: agent?.role ?? null,
164 title: agent?.title || null,
165 avatar_seed: agent?.avatar_seed ?? null,
166 });
167 }
168 }
169 for (const key of keys) {
170 if (!out.has(key)) out.set(key, { kind: "user", id: key, name: "g1t", display_name: "g1t", avatar: null, role: null, title: null, avatar_seed: null });
171 }
172 return out;
173 }
174
175 async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
176 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Artifacts.");
177 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Artifacts.");
178 const workspace = await this.workspace(slug);
179 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
180 }
181
182 viewerOwner(viewer: User, slug: string): boolean {
183 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
184 }
185
186 /** A person as access sees them: their teams, and whether they own the workspace. */
187 async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean): Promise<Person> {
188 const teams = (await this.teamsOf(workspace)).get(String(user.username ?? "").toLowerCase()) ?? new Set<string>();
189 return { user_id: user.id, owner, teams };
190 }
191
192 /** The viewer as access sees them. */
193 viewerPerson(workspace: Workspace, viewer: User): Promise<Person> {
194 return this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug));
195 }
196
197 /** People by user id as access sees them: members' teams and ownership; anyone else reads nothing. */
198 async peopleByIds(workspace: Workspace, ids: string[]): Promise<Person[]> {
199 const unique = [...new Set(ids.map(String))].slice(0, 200);
200 await this.nameUsers(unique);
201 const [members, teams] = await Promise.all([this.members(workspace), this.teamsOf(workspace)]);
202 return unique.map((id) => {
203 const username = this.usernames.get(id)?.toLowerCase() ?? "";
204 const member = members.get(username);
205 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: member ? (teams.get(username) ?? new Set()) : new Set() };
206 });
207 }
208
209 /** Every space in the workspace (archived too), with members and projects. */
210 allSpaces(workspace: Workspace): Promise<Omit<Space, "role">[]> {
211 let found = this.spaces.get(workspace.id);
212 if (!found) {
213 found = (async () => {
214 const db = this.env.DB;
215 const [spaces, members, projects] = await Promise.all([
216 db.prepare("SELECT * FROM spaces WHERE workspace_id = ? ORDER BY is_default DESC, name COLLATE NOCASE").bind(workspace.id).all<SpaceRow>(),
217 db
218 .prepare("SELECT m.space_id, m.principal, m.role FROM space_members m JOIN spaces s ON s.id = m.space_id WHERE s.workspace_id = ?")
219 .bind(workspace.id)
220 .all<{ space_id: string; principal: string; role: DocRole }>(),
221 db.prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?").bind(workspace.id).all<{ space_id: string; repo: string }>(),
222 ]);
223 return spaces.results.map((row) => ({
224 row,
225 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
226 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
227 }));
228 })();
229 this.spaces.set(workspace.id, found);
230 }
231 return found;
232 }
233
234 /** Forget cached spaces after one changed. */
235 forgetSpaces(): void {
236 this.spaces.clear();
237 }
238
239 /** The spaces with `person`'s role in each (null: they can't read it). Archived spaces too. */
240 async spacesFor(workspace: Workspace, person: Person): Promise<Space[]> {
241 const spaces = await this.allSpaces(workspace);
242 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) }));
243 }
244
245 /** Makes the workspace's General space, once. */
246 async ensureDefault(workspace: Workspace, viewer: User): Promise<void> {
247 const db = this.env.DB;
248 const found = await db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
249 if (found) return;
250 const taken = new Set((await db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
251 await db
252 .prepare(
253 "INSERT OR IGNORE INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, 'General', 'Everything the whole workspace should know.', '📚', 'workspace', NULL, 'edit', 'suggest', 1, ?, ?)",
254 )
255 .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now())
256 .run();
257 this.forgetSpaces();
258 }
259
260 toSpace(space: Space, pageCount = 0): DocSpace {
261 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
262 return {
263 id: space.row.id,
264 workspace_id: space.row.workspace_id,
265 slug: space.row.slug,
266 name: space.row.name,
267 description: space.row.description,
268 icon: space.row.icon,
269 kind: space.row.kind,
270 team: space.row.team,
271 default_role: space.row.kind === "private" ? null : space.row.default_role,
272 agent_mode: space.row.agent_mode,
273 is_default: !!space.row.is_default,
274 projects: space.projects,
275 created_by: created,
276 created_at: space.row.created_at,
277 archived_at: space.row.archived_at,
278 viewer_role: space.role ?? "view",
279 page_count: pageCount,
280 };
281 }
282}