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