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.

311 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), { ...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 out.set(principalKey(p), { ...p, name: p.id, display_name: "A deleted agent", avatar: null, role: null, title: null, avatar_seed: null, deleted: true });
186 }
187 }
188 for (const key of keys) {
189 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 });
190 }
191 return out;
192 }
193
194 async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
195 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Artifacts.");
196 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Artifacts.");
197 const workspace = await this.workspace(slug);
198 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
199 }
200
201 viewerOwner(viewer: User, slug: string): boolean {
202 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
203 }
204
205 /** A person as access sees them: their teams, and whether they own the workspace. */
206 async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean): Promise<Person> {
207 const teams = (await this.teamsOf(workspace)).get(String(user.username ?? "").toLowerCase()) ?? new Set<string>();
208 return { user_id: user.id, owner, teams };
209 }
210
211 /** The viewer as access sees them. */
212 viewerPerson(workspace: Workspace, viewer: User): Promise<Person> {
213 return this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug));
214 }
215
216 /** People by user id as access sees them: members' teams and ownership; anyone else reads nothing. */
217 async peopleByIds(workspace: Workspace, ids: string[]): Promise<Person[]> {
218 const unique = [...new Set(ids.map(String))].slice(0, 200);
219 await this.nameUsers(unique);
220 const [members, teams] = await Promise.all([this.members(workspace), this.teamsOf(workspace)]);
221 return unique.map((id) => {
222 const username = this.usernames.get(id)?.toLowerCase() ?? "";
223 const member = members.get(username);
224 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: member ? (teams.get(username) ?? new Set()) : new Set() };
225 });
226 }
227
228 /** Every space in the workspace (archived too), with members and projects. */
229 allSpaces(workspace: Workspace): Promise<Omit<Space, "role">[]> {
230 let found = this.spaces.get(workspace.id);
231 if (!found) {
232 found = (async () => {
233 const db = this.env.DB;
234 const [spaces, members, projects] = await Promise.all([
235 db.prepare("SELECT * FROM spaces WHERE workspace_id = ? ORDER BY is_default DESC, name COLLATE NOCASE").bind(workspace.id).all<SpaceRow>(),
236 db
237 .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 = ?")
238 .bind(workspace.id)
239 .all<{ space_id: string; principal: string; role: DocRole }>(),
240 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 }>(),
241 ]);
242 return spaces.results.map((row) => ({
243 row,
244 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
245 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
246 }));
247 })();
248 this.spaces.set(workspace.id, found);
249 }
250 return found;
251 }
252
253 /** Forget cached spaces after one changed. */
254 forgetSpaces(): void {
255 this.spaces.clear();
256 }
257
258 /** The spaces with `person`'s role in each (null: they can't read it). Archived spaces too. */
259 async spacesFor(workspace: Workspace, person: Person): Promise<Space[]> {
260 const spaces = await this.allSpaces(workspace);
261 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) }));
262 }
263
264 /**
265 * Makes the workspace's General space, once. A deleted General still
266 * has its row (`deleted_at` set), so it is never made again: a
267 * workspace may have no spaces.
268 */
269 async ensureDefault(workspace: Workspace, viewer: User): Promise<void> {
270 if (defaultsMade.has(workspace.id)) return;
271 const db = this.env.DB;
272 const found = await db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
273 if (found) {
274 defaultsMade.add(workspace.id);
275 return;
276 }
277 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));
278 await db
279 .prepare(
280 "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, ?, ?)",
281 )
282 .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now())
283 .run();
284 defaultsMade.add(workspace.id);
285 this.forgetSpaces();
286 }
287
288 toSpace(space: Space, pageCount = 0): DocSpace {
289 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
290 return {
291 id: space.row.id,
292 workspace_id: space.row.workspace_id,
293 slug: space.row.slug,
294 name: space.row.name,
295 description: space.row.description,
296 icon: space.row.icon,
297 kind: space.row.kind,
298 team: space.row.team,
299 default_role: space.row.kind === "private" ? null : space.row.default_role,
300 agent_mode: space.row.agent_mode,
301 editors_can_share: !!space.row.editors_can_share,
302 is_default: !!space.row.is_default,
303 projects: space.projects,
304 created_by: created,
305 created_at: space.row.created_at,
306 archived_at: space.row.archived_at,
307 viewer_role: space.role ?? "view",
308 page_count: pageCount,
309 };
310 }
311}