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.

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