Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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