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