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