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