Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

1040 lines · 67.8 KBRaw History
1import type { Env } from "../env.js";
2import type { RepoMeta, Role } from "../repo/types.js";
3import { ROLE_LEVEL } from "../repo/types.js";
4import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js";
5import { mailForAudit, tokenCreated } from "../mail/notify.js";
6import { enqueueMail } from "../mail/send.js";
7import { archivePrefix, deletePrefixes } from "./storage-keys.js";
8import { inviteProof } from "../auth/invite-proof.js";
9import { checkRepositoryQuota } from "./quotas.js";
10
11export interface Account {
12 id: string;
13 login: string;
14 kind: "user" | "org" | "agent";
15 name: string | null;
16 email: string | null;
17 status: "active" | "pending" | "disabled";
18 site_admin: number;
19 owner_id: string | null;
20 agent_policy: string | null;
21 /** Profile picture version (R2 avatars/<id>/<version>/<size>.webp), null for initials. */
22 avatar_version: number | null;
23 /** "light", "dark" or null (follow the system). */
24 appearance: string | null;
25 /** Organizations: the role every member gets on its repositories ("none", "read", "write" or "admin"). */
26 base_role: string | null;
27 created_at: string;
28 /** When the account was deleted (accounts/lifecycle.ts); the row stays under a placeholder login. */
29 deleted_at?: string | null;
30}
31
32export interface Repo {
33 id: string;
34 owner_id: string;
35 owner_login: string;
36 owner_kind: "user" | "org" | "agent";
37 name: string;
38 description: string;
39 visibility: "private" | "public";
40 default_branch: string;
41 archived: number;
42 created_at: string;
43 updated_at: string;
44 pushed_at: string | null;
45 /** JSON array of topic names (GitHub's repository topics). */
46 topics?: string;
47 /** Project website (GitHub's `homepage`). */
48 homepage?: string;
49}
50
51export type TokenScope = "read" | "write" | "admin";
52
53export type BaseRole = "none" | "read" | "write" | "admin";
54export const BASE_ROLES: readonly BaseRole[] = ["none", "read", "write", "admin"];
55
56/** An organization's base permission; unset means read, the default for new organizations. */
57export function baseRole(org: Pick<Account, "base_role">): BaseRole {
58 return BASE_ROLES.find((r) => r === org.base_role) ?? "read";
59}
60
61/** Repositories are public or private; GitHub's "internal" is refused like any other value. */
62export function repoVisibility(v: unknown): Repo["visibility"] | undefined {
63 if (v === undefined || v === null || v === "") return undefined;
64 if (v === "public" || v === "private") return v;
65 throw new HttpError(422, "visibility must be public or private", { errors: [{ resource: "Repository", code: "invalid", field: "visibility" }] });
66}
67
68/** Data access for the global directory in D1. */
69/** Invitations one person (not a site admin) may send in 24 hours. */
70export const MAX_INVITES_PER_DAY = 50;
71
72/** What adding someone did: added them at once, invited them by username, or sent an invitation link to an email address. */
73export type AddResult = { added: Account } | { invitedUser: Account; id: string; renewed: boolean } | { invited: { email: string; url: string } };
74
75/** A person's own pending invitations (binds: now, account id, account email). */
76const MINE = "i.accepted_at IS NULL AND i.expires_at > ? AND (i.org_id IS NOT NULL OR i.repo_id IS NOT NULL) AND (i.account_id = ? OR (i.account_id IS NULL AND i.email = ?))";
77
78export class Directory {
79 constructor(private env: Env) {}
80
81 private get db() {
82 return this.env.DB;
83 }
84
85 // ---------------------------------------------------------------- accounts
86
87 async account(id: string): Promise<Account | null> {
88 return this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(id).first<Account>();
89 }
90
91 async accountByLogin(login: string): Promise<Account | null> {
92 return this.db.prepare("SELECT * FROM accounts WHERE login = ?").bind(login).first<Account>();
93 }
94
95 async accountByEmail(email: string): Promise<Account | null> {
96 return this.db.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").bind(email).first<Account>();
97 }
98
99 /**
100 * Only a verified email address makes a bootstrap administrator. Logins on the list are
101 * ignored: whoever creates an account picks its login, so a login proves nothing.
102 */
103 private isBootstrapAdmin(email: string): boolean {
104 const list = (this.env.BOOTSTRAP_ADMINS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
105 return list.includes(email.toLowerCase());
106 }
107
108 private async uniqueLogin(base: string): Promise<string> {
109 let candidate = base.replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user";
110 for (let i = 0; i < 50; i++) {
111 const tryLogin = i === 0 ? candidate : `${candidate}-${i + 1}`;
112 if (!RESERVED_LOGINS.has(tryLogin.toLowerCase()) && !(await this.loginTaken(tryLogin))) return tryLogin;
113 }
114 candidate = `${candidate}-${randomToken(3)}`;
115 return candidate;
116 }
117
118 /**
119 * When a deleted account's username becomes free again (accounts/lifecycle.ts LOGIN_COOLDOWN_DAYS), or null
120 * when it isn't cooling down.
121 */
122 async loginCooldown(login: string): Promise<string | null> {
123 const r = await this.db.prepare("SELECT available_at FROM released_logins WHERE login = ? AND available_at > ?").bind(login, now()).first<{ available_at: string }>();
124 return r?.available_at ?? null;
125 }
126
127 /** Whether an account has this login, or a deleted account had it too recently for anyone to take it. */
128 async loginTaken(login: string): Promise<boolean> {
129 return !!(await this.accountByLogin(login)) || !!(await this.loginCooldown(login));
130 }
131
132 /** Refuse a login that a deleted account gave up less than 90 days ago. */
133 private async checkCooldown(login: string, what = "This username"): Promise<void> {
134 const until = await this.loginCooldown(login);
135 if (until) throw new HttpError(422, `${what} belonged to an account that was deleted recently; it can be used again from ${until.slice(0, 10)}`);
136 }
137
138 /**
139 * Find or create the user behind a verified email (Access recovery, invite links, open sign-up,
140 * dev sign-in). A new account waits for an invite unless it has one, is a bootstrap
141 * administrator or `hints.active` says so (open sign-up).
142 */
143 async userForEmail(email: string, hints: { name?: string; login?: string; id?: string; active?: boolean } = {}): Promise<Account> {
144 const existing = await this.accountByEmail(email);
145 if (existing) {
146 if (existing.status === "pending" && this.isBootstrapAdmin(email)) {
147 await this.db.prepare("UPDATE accounts SET status = 'active', site_admin = 1 WHERE id = ?").bind(existing.id).run();
148 return { ...existing, status: "active", site_admin: 1 };
149 }
150 return existing;
151 }
152 const admin = this.isBootstrapAdmin(email);
153 const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!);
154 const id = hints.id ?? newId();
155 const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first();
156 await this.db
157 .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)")
158 .bind(id, login, hints.name ?? null, email, admin || hasInvite || hints.active ? "active" : "pending", admin ? 1 : 0, now())
159 .run();
160 if (hasInvite) await this.acceptInvitesForEmail(id, email);
161 return (await this.account(id))!;
162 }
163
164 /** Rename an account and change its display name (and an organization's base permission) in one write, after checking every field. */
165 async updateAccount(id: string, patch: { login?: string; name?: unknown; baseRole?: unknown }): Promise<void> {
166 if (patch.name !== undefined && patch.name !== null && typeof patch.name !== "string") throw new HttpError(422, "name must be a string", { errors: [{ resource: "Account", code: "invalid", field: "name" }] });
167 if (patch.baseRole !== undefined && !BASE_ROLES.includes(patch.baseRole as BaseRole)) throw new HttpError(422, "base permission must be none, read, write or admin", { errors: [{ resource: "Organization", code: "invalid", field: "default_repository_permission" }] });
168 if (patch.login && !isValidLogin(patch.login)) throw new HttpError(422, "invalid or reserved login");
169 if (patch.login) {
170 const clash = await this.accountByLogin(patch.login);
171 if (clash && clash.id !== id) throw new HttpError(422, "login already taken");
172 await this.checkCooldown(patch.login, "This name");
173 }
174 // A blank display name clears it, so pages fall back to the login.
175 const name = patch.name === undefined ? undefined : (patch.name as string | null)?.trim() || null;
176 const before = patch.login ? await this.account(id) : null;
177 await this.db
178 .prepare("UPDATE accounts SET login = COALESCE(?, login), name = CASE WHEN ? THEN ? ELSE name END, base_role = CASE WHEN kind = 'org' THEN COALESCE(?, base_role) ELSE base_role END WHERE id = ?")
179 .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, (patch.baseRole as string | undefined) ?? null, id)
180 .run();
181 if (patch.login && before && before.login.toLowerCase() !== patch.login.toLowerCase()) {
182 // The old login keeps leading here (repository URLs, the profile) until another account takes it.
183 await this.db.batch([
184 this.db.prepare("DELETE FROM login_redirects WHERE login = ?").bind(patch.login),
185 this.db.prepare("INSERT OR REPLACE INTO login_redirects (login, account_id, created_at) VALUES (?, ?, ?)").bind(before.login, id, now()),
186 ]);
187 }
188 if (patch.login) {
189 // Repos keep their ids; their Durable Objects cache the owner login, so refresh them.
190 const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(id).all<{ id: string }>();
191 for (const r of repos.results) {
192 await this.env.REPO.get(this.env.REPO.idFromName(r.id)).updateMeta({ owner: patch.login });
193 await this.forgetArchives(r.id);
194 }
195 }
196 }
197
198 async setAppearance(id: string, appearance: "light" | "dark" | null): Promise<void> {
199 await this.db.prepare("UPDATE accounts SET appearance = ? WHERE id = ?").bind(appearance, id).run();
200 }
201
202 /** Point an account at a new profile picture version (null: back to initials). */
203 async setAvatarVersion(id: string, version: number | null): Promise<void> {
204 await this.db.prepare("UPDATE accounts SET avatar_version = ? WHERE id = ?").bind(version, id).run();
205 }
206
207 async createOrg(login: string, name: string, creator: Account): Promise<Account> {
208 if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name");
209 if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
210 await this.checkCooldown(login, "This name");
211 const id = newId();
212 const t = now();
213 await this.db.batch([
214 this.db.prepare("INSERT INTO accounts (id, login, kind, name, status, base_role, created_at) VALUES (?, ?, 'org', ?, 'active', 'read', ?)").bind(id, login, name || login, t),
215 this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)").bind(id, creator.id, t),
216 ]);
217 return (await this.account(id))!;
218 }
219
220 /**
221 * Delete an organization. Refused while it still owns repositories (transfer or delete them first),
222 * container images or an import in progress. Its agents are disabled (their tokens revoked), its
223 * members, pending invitations, secrets and variables removed, and its login freed. The account row
224 * stays, disabled under a placeholder login, because the audit log, import history and its agents
225 * still refer to it. Repositories transferred out of it keep redirecting from their old URLs.
226 */
227 async deleteOrg(org: Account): Promise<void> {
228 if (org.kind !== "org") throw new HttpError(404, "not an organization");
229 const count = async (q: string) => (await this.db.prepare(q).bind(org.id).first<{ n: number }>())?.n ?? 0;
230 const repos = await count("SELECT count(*) AS n FROM repos WHERE owner_id = ?");
231 if (repos) throw new HttpError(422, `${org.login} still owns ${repos} ${repos === 1 ? "repository" : "repositories"}; transfer or delete ${repos === 1 ? "it" : "them"} before deleting the organization`);
232 const images = await count("SELECT count(*) AS n FROM packages WHERE owner_id = ?");
233 if (images) throw new HttpError(422, `${org.login} still owns ${images} container ${images === 1 ? "image" : "images"}; delete ${images === 1 ? "it" : "them"} before deleting the organization`);
234 const imports = await count("SELECT count(*) AS n FROM imports WHERE target_owner_id = ? AND status IN ('draft', 'queued', 'running')");
235 if (imports) throw new HttpError(422, `an import into ${org.login} is still in progress; wait for it to finish or cancel it first`);
236 const agents = await this.db.prepare("SELECT id FROM accounts WHERE kind = 'agent' AND owner_id = ? AND status != 'disabled'").bind(org.id).all<{ id: string }>();
237 for (const a of agents.results) await this.setStatus(a.id, "disabled");
238 await this.db.batch([
239 this.db.prepare("DELETE FROM org_members WHERE org_id = ?").bind(org.id),
240 this.db.prepare("DELETE FROM invites WHERE org_id = ? AND accepted_at IS NULL").bind(org.id),
241 this.db.prepare("DELETE FROM org_secrets WHERE org_id = ?").bind(org.id),
242 this.db.prepare("DELETE FROM org_secret_repos WHERE org_id = ?").bind(org.id),
243 this.db.prepare("DELETE FROM org_variables WHERE org_id = ?").bind(org.id),
244 this.db.prepare("DELETE FROM login_redirects WHERE account_id = ?").bind(org.id),
245 this.db.prepare("UPDATE accounts SET login = ?, status = 'disabled' WHERE id = ?").bind(`deleted-${org.id}`, org.id),
246 ]);
247 }
248
249 /** Whether an account sees an organization's settings such as its base permission: members, the
250 * organization's own agents, agents of members, and site admins. */
251 async inOrg(orgId: string, account: Account): Promise<boolean> {
252 if (account.site_admin) return true;
253 if (account.kind === "agent") return account.owner_id === orgId || (!!account.owner_id && !!(await this.orgRole(orgId, account.owner_id)));
254 return !!(await this.orgRole(orgId, account.id));
255 }
256
257 async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> {
258 const r = await this.db.prepare("SELECT role FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).first<{ role: "owner" | "admin" | "member" }>();
259 return r?.role ?? null;
260 }
261
262 async orgsFor(userId: string): Promise<Array<Account & { role: string }>> {
263 const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? ORDER BY a.login").bind(userId).all<Account & { role: string }>();
264 return r.results;
265 }
266
267 async orgMembers(orgId: string): Promise<Array<Account & { role: string }>> {
268 const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.user_id WHERE m.org_id = ? ORDER BY a.login").bind(orgId).all<Account & { role: string }>();
269 return r.results;
270 }
271
272 async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null, by?: Account): Promise<void> {
273 const previous = await this.orgRole(orgId, userId);
274 await this.writeOrgMember(orgId, userId, role);
275 if (previous !== role) {
276 const org = await this.account(orgId);
277 if (!org) return;
278 await enqueueMail(this.env, [{ kind: "org_membership", data: { org: org.login, role, previous, by: by && by.id !== userId ? by.login : undefined }, to: { accountId: userId }, reason: "account", at: now() }]);
279 // A heads-up on Diffinery too when someone else adds you.
280 if (role && !previous && by && by.id !== userId) await this.notify([userId], { kind: "org_membership", title: `${by.login} added you to ${org.login} as ${role === "admin" ? "an admin" : `a${role === "owner" ? "n" : ""} ${role}`}`, url: `/${org.login}` });
281 }
282 }
283
284 /**
285 * Add someone to an organization by username or email, like GitHub's "Add people". Either way they
286 * get an invitation and join when they accept it: a username's invitation waits in their
287 * Settings → Invitations (with an email and a notification), an email address's invitation is a
288 * link that also creates the account if there is none. Only an admin adding themselves joins at once.
289 */
290 async addOrgPerson(org: Account, by: Account, who: string, role: "owner" | "admin" | "member"): Promise<AddResult> {
291 const q = who.trim().replace(/^@/, "");
292 if (!q) throw new HttpError(422, "enter a username or an email address");
293 const email = q.includes("@");
294 if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q)) throw new HttpError(422, "enter a valid email address");
295 // An email address always gets an invitation, even when it belongs to an account: adding the
296 // account directly would tell the admin its username, and the person hasn't agreed to join.
297 if (email) {
298 const inv = await this.createInvite(by, { email: q, orgId: org.id, role });
299 return { invited: { email: q, url: inv.url } };
300 }
301 const user = await this.accountByLogin(q);
302 if (user) {
303 if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; agents belong to an organization through Settings → Agents`);
304 if (user.status === "disabled") throw new HttpError(422, `${user.login}'s account is disabled`);
305 if (await this.orgRole(org.id, user.id)) throw new HttpError(422, `${user.login} is already a member of ${org.login}`);
306 if (user.id === by.id) {
307 await this.setOrgMember(org.id, user.id, role, by);
308 return { added: user };
309 }
310 return { invitedUser: user, ...(await this.inviteAccount(by, user, { orgId: org.id, role })) };
311 }
312 throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`);
313 }
314
315 /** Add a collaborator by username (a person or an agent; see grantRepoAccess), or invite an email address. */
316 async addRepoPerson(repo: Repo, by: Account, who: string, role: Role): Promise<AddResult> {
317 const q = who.trim().replace(/^@/, "");
318 if (!q) throw new HttpError(422, "enter a username or an email address");
319 const email = q.includes("@");
320 if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q)) throw new HttpError(422, "enter a valid email address");
321 // As for organizations, an email address always gets an invitation.
322 if (email) {
323 const inv = await this.createInvite(by, { email: q, repoId: repo.id, role });
324 return { invited: { email: q, url: inv.url } };
325 }
326 const account = await this.accountByLogin(q);
327 if (account) return this.grantRepoAccess(repo, by, account, role);
328 throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`);
329 }
330
331 /**
332 * Give an account a role on a repository, as GitHub does: agents, people who already have a direct
333 * grant (a role change), members of the organization that owns the repository and the admin
334 * themselves get it at once; anyone else gets an invitation and has access only once they accept it.
335 */
336 async grantRepoAccess(repo: Repo, by: Account, account: Account, role: Role): Promise<{ added: Account } | { invitedUser: Account; id: string; renewed: boolean }> {
337 if (account.kind === "org") throw new HttpError(422, `${account.login} is an organization; add its people instead`);
338 if (account.status === "disabled") throw new HttpError(422, `${account.login}'s account is disabled`);
339 const direct =
340 account.kind === "agent" ||
341 account.id === by.id ||
342 !!(await this.db.prepare("SELECT 1 FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first()) ||
343 (repo.owner_kind === "org" && !!(await this.orgRole(repo.owner_id, account.id)));
344 if (!direct) return { invitedUser: account, ...(await this.inviteAccount(by, account, { repoId: repo.id, role })) };
345 await this.setCollaborator(repo.id, account.id, role);
346 if (account.id !== by.id) await this.notify([account.id], { repoId: repo.id, kind: "repo_access", title: `${by.login} gave you ${role} access to ${repo.owner_login}/${repo.name}`, url: `/${repo.owner_login}/${repo.name}` });
347 return { added: account };
348 }
349
350 /** Revoke a pending invitation to an organization, a repository, or (neither) the site. False if there was none. */
351 async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<boolean> {
352 const r = await this.db
353 .prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL AND org_id IS ? AND repo_id IS ?")
354 .bind(id, scope.orgId ?? null, scope.repoId ?? null)
355 .run();
356 return (r.meta?.changes ?? 0) > 0;
357 }
358
359 private async writeOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> {
360 // Removing or demoting the last owner would leave nobody able to manage the organization.
361 if (role !== "owner" && (await this.orgRole(orgId, userId)) === "owner") {
362 const owners = await this.db.prepare("SELECT count(*) AS n FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?").bind(orgId, userId).first<{ n: number }>();
363 if (!owners?.n) throw new HttpError(422, "an organization needs at least one owner; make someone else an owner first");
364 }
365 if (role === null) {
366 // Their grants on the organization's repositories (including the admin grant for repositories they
367 // created) go too, for them and their agents. Roles are computed per request, so tokens lose access at once.
368 await this.db.batch([
369 this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId),
370 this.db
371 .prepare("DELETE FROM repo_access WHERE repo_id IN (SELECT id FROM repos WHERE owner_id = ?) AND account_id IN (SELECT ? UNION SELECT id FROM accounts WHERE kind = 'agent' AND owner_id = ?)")
372 .bind(orgId, userId, userId),
373 ]);
374 return;
375 }
376 await this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role").bind(orgId, userId, role, now()).run();
377 }
378
379 async users(): Promise<Account[]> {
380 return (await this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' AND deleted_at IS NULL ORDER BY login").all<Account>()).results;
381 }
382
383 async setStatus(id: string, status: Account["status"]): Promise<void> {
384 const before = await this.account(id);
385 await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run();
386 if (status === "disabled") await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").bind(now(), id).run();
387 // A disabled account's browser sessions end too.
388 if (status === "disabled") await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(id).run();
389 // People hear when they are disabled or enabled again (not when an invite activates them).
390 if (before?.kind === "user" && before.status !== status && (status === "disabled" || before.status === "disabled")) {
391 await enqueueMail(this.env, [{ kind: "account_status", data: { status: status === "disabled" ? "disabled" : "active" }, to: { accountId: id }, reason: "account", at: now() }]);
392 }
393 }
394
395 // ---------------------------------------------------------------- agents
396
397 async createAgent(owner: Account, input: { login: string; name?: string; orgId?: string; policy?: Record<string, unknown> }): Promise<Account> {
398 if (!isValidLogin(input.login)) throw new HttpError(422, "invalid or reserved agent name");
399 const login = input.login.endsWith("[bot]") ? input.login : `${input.login}`;
400 if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
401 await this.checkCooldown(login, "This name");
402 const id = newId();
403 const ownerId = input.orgId ?? owner.id;
404 if (input.orgId) {
405 const role = await this.orgRole(input.orgId, owner.id);
406 if (role !== "owner" && role !== "admin") throw new HttpError(403, "only organization admins can create organization agents");
407 }
408 const policy = { maxRole: "write", canMergeProtected: false, budgetUsdMonthly: null, ...input.policy };
409 await this.db
410 .prepare("INSERT INTO accounts (id, login, kind, name, status, owner_id, agent_policy, created_at) VALUES (?, ?, 'agent', ?, 'active', ?, ?, ?)")
411 .bind(id, login, input.name?.trim() || login, ownerId, JSON.stringify(policy), now())
412 .run();
413 return (await this.account(id))!;
414 }
415
416 async agentsOwnedBy(ownerIds: string[]): Promise<Account[]> {
417 if (!ownerIds.length) return [];
418 const q = `SELECT * FROM accounts WHERE kind = 'agent' AND owner_id IN (${ownerIds.map(() => "?").join(",")}) ORDER BY login`;
419 return (await this.db.prepare(q).bind(...ownerIds).all<Account>()).results;
420 }
421
422 // ---------------------------------------------------------------- tokens
423
424 /** `importId`: a GitHub import's short-lived push token (see imports/service.ts). */
425 async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number; importId?: string }): Promise<{ token: string; id: string; expiresAt: string }> {
426 const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366);
427 const token = `dfy_${randomToken(30)}`;
428 const id = newId();
429 const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
430 await this.db
431 .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at, import_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
432 .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now(), input.importId ?? null)
433 .run();
434 // An import's own push token is server-managed and short-lived: no "new token" email.
435 if (!input.importId) await tokenCreated(this.env, this, account, { name: input.name || "token", scopes: input.scopes?.length ? input.scopes : ["write"] }).catch((e) => console.error("token email failed", e));
436 return { token, id, expiresAt };
437 }
438
439 async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string }>> {
440 // Import push tokens (import_id set) are server-managed and hidden from the personal token list.
441 const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL AND import_id IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>();
442 return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at! }));
443 }
444
445 async revokeToken(accountId: string, id: string): Promise<void> {
446 await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ?").bind(now(), id, accountId).run();
447 }
448
449 async verifyToken(token: string): Promise<{ id: string; account: Account; scopes: TokenScope[]; repoIds: string[] | null; importId: string | null } | null> {
450 const row = await this.db.prepare("SELECT * FROM tokens WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>();
451 if (!row || row.revoked_at || row.expires_at! < now()) return null;
452 const account = await this.account(row.account_id!);
453 if (!account || account.status !== "active") return null;
454 if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) {
455 await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run();
456 }
457 return { id: row.id!, account, scopes: JSON.parse(row.scopes!), repoIds: row.repo_ids ? JSON.parse(row.repo_ids) : null, importId: row.import_id ?? null };
458 }
459
460 /** Revoke a token by id whoever owns it (server-managed tokens such as an import's push token). */
461 async revokeTokenById(id: string): Promise<void> {
462 await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").bind(now(), id).run();
463 }
464
465 /** Revoke every live token an import ever minted (not only the current one), so a stale token can never push. */
466 async revokeTokensByImport(importId: string): Promise<void> {
467 await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE import_id = ? AND revoked_at IS NULL").bind(now(), importId).run();
468 }
469
470 // ---------------------------------------------------------------- invites
471
472 /** The role is one an organization invitation can have and the inviter may give, and (for a new invitation) the inviter is under the daily limit. */
473 private async checkInvite(inviter: Account, input: { orgId?: string; repoId?: string; role: string }, isNew = true): Promise<void> {
474 if (input.orgId) {
475 if (input.role !== "member" && input.role !== "admin" && input.role !== "owner") throw new HttpError(422, "role must be member, admin or owner");
476 if (input.role === "owner" && !inviter.site_admin && (await this.orgRole(input.orgId, inviter.id)) !== "owner") throw new HttpError(403, "only owners can invite owners");
477 }
478 if (input.repoId && !(input.role in ROLE_LEVEL)) throw new HttpError(422, "permission must be read, triage, write, maintain or admin");
479 // Invitations are email to any address: a daily limit per inviter keeps an account from being used to spam.
480 if (isNew && !inviter.site_admin) {
481 const sent = (await this.db.prepare("SELECT count(*) AS n FROM invites WHERE invited_by = ? AND created_at > ?").bind(inviter.id, new Date(Date.now() - 86400_000).toISOString()).first<{ n: number }>())?.n ?? 0;
482 if (sent >= MAX_INVITES_PER_DAY) throw new HttpError(429, `you can send at most ${MAX_INVITES_PER_DAY} invitations in 24 hours; try again later`);
483 }
484 }
485
486 async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> {
487 await this.checkInvite(inviter, input);
488 // One open invitation per address and organization or repository. The email column is NOCASE,
489 // so Bob@x.com and bob@x.com count as the same person.
490 const pending = await this.db
491 .prepare("SELECT 1 FROM invites WHERE email = ? AND account_id IS NULL AND org_id IS ? AND repo_id IS ? AND accepted_at IS NULL AND expires_at > ?")
492 .bind(input.email, input.orgId ?? null, input.repoId ?? null, now())
493 .first();
494 if (pending) throw new HttpError(422, `${input.email} already has a pending invitation; revoke it first to send a new one`);
495 // An address that already belongs to a member still gets an invitation (accepting it changes
496 // nothing): refusing would tell the inviter whose address it is.
497 const existing = await this.accountByEmail(input.email);
498 const token = randomToken(24);
499 const id = newId();
500 await this.db
501 .prepare("INSERT INTO invites (id, org_id, repo_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
502 .bind(id, input.orgId ?? null, input.repoId ?? null, input.email, input.role, await sha256(token), inviter.id, now(), new Date(Date.now() + 14 * 86400_000).toISOString())
503 .run();
504 // If the person already exists, activate them now so the invite also works from the dashboard.
505 if (existing?.status === "pending") await this.setStatus(existing.id, "active");
506 const url = `${this.env.PUBLIC_URL}/invite/${token}`;
507 await this.mailInvite({ id, email: input.email, org_id: input.orgId ?? null, repo_id: input.repoId ?? null, role: input.role, invited_by: inviter.id, expires_at: new Date(Date.now() + 14 * 86400_000).toISOString() }, token);
508 return { id, url, token };
509 }
510
511 /**
512 * Invite an existing person by username to an organization or a repository. They join when they
513 * accept it in Settings → Invitations or through the API; the email and notification only point
514 * there, so the invitation carries no link that works for anyone else. Inviting someone who already
515 * has a pending invitation changes its role instead of sending another.
516 */
517 async inviteAccount(inviter: Account, user: Account, input: { orgId?: string; repoId?: string; role: string }): Promise<{ id: string; renewed: boolean }> {
518 const pending = await this.db
519 .prepare("SELECT id FROM invites WHERE account_id = ? AND org_id IS ? AND repo_id IS ? AND accepted_at IS NULL AND expires_at > ?")
520 .bind(user.id, input.orgId ?? null, input.repoId ?? null, now())
521 .first<{ id: string }>();
522 if (pending) {
523 await this.checkInvite(inviter, input, false);
524 await this.db.prepare("UPDATE invites SET role = ? WHERE id = ?").bind(input.role, pending.id).run();
525 return { id: pending.id, renewed: true };
526 }
527 await this.checkInvite(inviter, input);
528 const id = newId();
529 const expiresAt = new Date(Date.now() + 14 * 86400_000).toISOString();
530 // The token column is required and unique; nobody is given this one; the invitation is accepted by id.
531 await this.db
532 .prepare("INSERT INTO invites (id, org_id, repo_id, email, account_id, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
533 .bind(id, input.orgId ?? null, input.repoId ?? null, user.email ?? null, user.id, input.role, await sha256(randomToken(24)), inviter.id, now(), expiresAt)
534 .run();
535 const org = input.orgId ? await this.account(input.orgId) : null;
536 const repo = input.repoId ? await this.repoById(input.repoId) : null;
537 const where = org ? org.login : repo ? `${repo.owner_login}/${repo.name}` : "Diffinery";
538 await this.notify([user.id], { repoId: input.repoId, kind: "invitation", title: `${inviter.login} invited you to ${where} as ${input.role}`, url: "/settings/invitations" });
539 await enqueueMail(this.env, [
540 {
541 kind: "invite",
542 data: { inviter: inviter.login, url: `${this.env.PUBLIC_URL}/settings/invitations`, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: input.role, expiresAt, existing: true },
543 to: { accountId: user.id },
544 reason: "invitation",
545 at: now(),
546 },
547 ]);
548 return { id, renewed: false };
549 }
550
551 /**
552 * Email an invitation to its address. Only this emailed link carries the proof that lets
553 * someone create an account for the address (auth/invite-proof.ts); the plain link shown to
554 * the inviter leads to a page that sends this email.
555 */
556 async mailInvite(inv: { id: string; email: string; org_id: string | null; repo_id: string | null; role: string; invited_by: string | null; expires_at: string }, token: string): Promise<void> {
557 const proof = await inviteProof(this.env, await sha256(token));
558 const url = `${this.env.PUBLIC_URL}/invite/${token}?proof=${proof}`;
559 const inviter = inv.invited_by ? await this.account(inv.invited_by) : null;
560 const org = inv.org_id ? await this.account(inv.org_id) : null;
561 const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null;
562 const existing = await this.accountByEmail(inv.email);
563 await enqueueMail(this.env, [
564 {
565 kind: "invite",
566 data: { inviter: inviter?.login ?? "diffinery", url, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: inv.role, expiresAt: inv.expires_at },
567 to: existing ? { accountId: existing.id } : { email: inv.email },
568 reason: "invitation",
569 at: now(),
570 },
571 ]);
572 }
573
574 /** Invitations sent from an organization, a repository or the site. One sent to a username shows the username, never the person's email address. */
575 async invites(filter: { orgId?: string; repoId?: string; site?: boolean; pending?: boolean }): Promise<Array<{ id: string; email: string | null; login: string | null; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> {
576 const r = await this.db
577 .prepare(
578 `SELECT i.*, a.login AS inviter, u.login AS invitee FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by LEFT JOIN accounts u ON u.id = i.account_id WHERE (? IS NULL OR i.org_id = ?) AND (? IS NULL OR i.repo_id = ?)${filter.site ? " AND i.org_id IS NULL AND i.repo_id IS NULL" : ""}${filter.pending ? " AND i.accepted_at IS NULL AND i.expires_at > ?" : ""} ORDER BY i.created_at DESC LIMIT 200`,
579 )
580 .bind(filter.orgId ?? null, filter.orgId ?? null, filter.repoId ?? null, filter.repoId ?? null, ...(filter.pending ? [now()] : []))
581 .all<Record<string, string | null>>();
582 return r.results.map((i) => ({ id: i.id!, email: i.account_id ? null : i.email!, login: i.account_id ? i.invitee ?? null : null, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null, invitedBy: i.inviter ?? null }));
583 }
584
585 /**
586 * Pending invitations to an organization or a repository for this person: those sent to their
587 * username, and those sent to their email address.
588 */
589 async invitesFor(user: Account): Promise<Array<{ id: string; orgId: string | null; repoId: string | null; org: string | null; repo: string | null; role: string; invitedBy: string | null; createdAt: string; expiresAt: string }>> {
590 const r = await this.db
591 .prepare(
592 `SELECT i.*, a.login AS inviter, o.login AS org_login, ro.login || '/' || r.name AS repo_full FROM invites i
593 LEFT JOIN accounts a ON a.id = i.invited_by LEFT JOIN accounts o ON o.id = i.org_id LEFT JOIN repos r ON r.id = i.repo_id LEFT JOIN accounts ro ON ro.id = r.owner_id
594 WHERE ${MINE} ORDER BY i.created_at DESC LIMIT 100`,
595 )
596 .bind(now(), user.id, user.email ?? null)
597 .all<Record<string, string | null>>();
598 return r.results.map((i) => ({ id: i.id!, orgId: i.org_id ?? null, repoId: i.repo_id ?? null, org: i.org_login ?? null, repo: i.repo_full ?? null, role: i.role!, invitedBy: i.inviter ?? null, createdAt: i.created_at!, expiresAt: i.expires_at! }));
599 }
600
601 /** Accept or decline one of this person's pending invitations (see invitesFor). */
602 async answerInvite(id: string, user: Account, accept: boolean): Promise<{ orgId: string | null; repoId: string | null }> {
603 const inv = await this.db.prepare(`SELECT i.* FROM invites i WHERE ${MINE} AND i.id = ?`).bind(now(), user.id, user.email ?? null, id).first<Record<string, string | null>>();
604 if (!inv) throw new HttpError(404, "no pending invitation with this id");
605 if (accept) await this.applyInvite(inv, user);
606 else await this.db.prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL").bind(id).run();
607 return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null };
608 }
609
610 async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> {
611 const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>();
612 if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired");
613 if (inv.account_id) {
614 if (inv.account_id !== user.id) throw new HttpError(403, "this invitation is for someone else");
615 } else if (inv.email && inv.email.toLowerCase() !== (user.email ?? "").toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`);
616 await this.applyInvite(inv, user);
617 return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null };
618 }
619
620 private async acceptInvitesForEmail(userId: string, email: string): Promise<void> {
621 const user = (await this.account(userId))!;
622 const r = await this.db.prepare("SELECT * FROM invites WHERE email = ? AND account_id IS NULL AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).all<Record<string, string | null>>();
623 for (const inv of r.results) await this.applyInvite(inv, user);
624 }
625
626 private async applyInvite(inv: Record<string, string | null>, user: Account): Promise<void> {
627 const stmts = [this.db.prepare("UPDATE invites SET accepted_at = ?, accepted_by = ? WHERE id = ?").bind(now(), user.id, inv.id!), this.db.prepare("UPDATE accounts SET status = 'active' WHERE id = ? AND status = 'pending'").bind(user.id)];
628 if (inv.org_id) stmts.push(this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO NOTHING").bind(inv.org_id, user.id, inv.role === "admin" || inv.role === "owner" ? inv.role : "member", now()));
629 if (inv.repo_id) stmts.push(this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(inv.repo_id, user.id, inv.role));
630 await this.db.batch(stmts);
631 const org = inv.org_id ? await this.account(inv.org_id) : null;
632 const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null;
633 if (inv.invited_by && inv.invited_by !== user.id) {
634 await enqueueMail(this.env, [
635 { kind: "invite_accepted", data: { login: user.login, email: inv.account_id ? "" : (user.email ?? inv.email ?? ""), org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined }, to: { accountId: inv.invited_by }, reason: "account", at: now() },
636 ]);
637 }
638 }
639
640 async requestAccess(accountId: string, note: string): Promise<void> {
641 await this.db.prepare("INSERT OR REPLACE INTO access_requests (account_id, note, created_at) VALUES (?, ?, ?)").bind(accountId, note.slice(0, 500), now()).run();
642 const who = await this.account(accountId);
643 const admins = await this.db.prepare("SELECT id FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' LIMIT 20").all<{ id: string }>();
644 if (who) await enqueueMail(this.env, admins.results.map((a) => ({ kind: "access_request" as const, data: { email: who.email ?? "", login: who.login, note: note.slice(0, 500) }, to: { accountId: a.id }, reason: "admin" as const, at: now() })));
645 }
646
647 async accessRequests(): Promise<Array<{ account: Account; note: string; createdAt: string }>> {
648 const r = await this.db.prepare("SELECT a.*, r.note AS req_note, r.created_at AS req_at FROM access_requests r JOIN accounts a ON a.id = r.account_id WHERE a.status = 'pending' ORDER BY r.created_at").all<Account & { req_note: string; req_at: string }>();
649 return r.results.map((x) => ({ account: x, note: x.req_note, createdAt: x.req_at }));
650 }
651
652 // ---------------------------------------------------------------- repositories
653
654 async repo(owner: string, name: string): Promise<Repo | null> {
655 return this.db
656 .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.login = ? AND r.name = ?")
657 .bind(owner, name.replace(/\.git$/, ""))
658 .first<Repo>();
659 }
660
661 async repoById(id: string): Promise<Repo | null> {
662 return this.db.prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?").bind(id).first<Repo>();
663 }
664
665 /** Repositories an account can see. */
666 async reposVisibleTo(account: Account, ownerId?: string): Promise<Repo[]> {
667 if (account.status !== "active") return [];
668 if (account.kind === "agent") {
669 const owner = account.owner_id ? await this.account(account.owner_id) : null;
670 if (!owner || owner.status !== "active") return [];
671 }
672 const params: unknown[] = [];
673 let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id";
674 const subjectId = account.kind === "agent" ? account.owner_id! : account.id;
675 if (!account.site_admin) {
676 q += ` WHERE (r.visibility = 'public' OR r.owner_id = ? OR r.owner_id IN (SELECT m.org_id FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.user_id = ? AND (m.role != 'member' OR COALESCE(o.base_role, 'read') != 'none')) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`;
677 params.push(subjectId, subjectId, account.id, subjectId);
678 } else q += " WHERE 1 = 1";
679 if (ownerId) {
680 q += " AND r.owner_id = ?";
681 params.push(ownerId);
682 }
683 q += " ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT 500";
684 return (await this.db.prepare(q).bind(...params).all<Repo>()).results;
685 }
686
687 /** Public repositories, most recently pushed first (for the sitemap). */
688 async publicRepos(limit: number): Promise<Repo[]> {
689 return (
690 await this.db
691 .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.visibility = 'public' ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT ?")
692 .bind(limit)
693 .all<Repo>()
694 ).results;
695 }
696
697 /** `o.authorized`: the caller already checked that `creator` may create repositories for `owner` (GitHub imports). */
698 async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: string; defaultBranch?: string }, o: { authorized?: boolean } = {}): Promise<Repo> {
699 if (!isValidName(input.name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'");
700 // Like GitHub, a member who creates an organization repository administers it, whatever the base permission.
701 let creatorGrant: string | null = null;
702 if (o.authorized) {
703 // checked by the caller
704 } else if (owner.kind === "org") {
705 const subject = creator.kind === "agent" ? creator.owner_id! : creator.id;
706 const role = await this.orgRole(owner.id, subject);
707 if (!role && !creator.site_admin) throw new HttpError(403, "you are not a member of this organization");
708 if (role === "member") creatorGrant = subject;
709 } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations");
710 if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists");
711 await checkRepositoryQuota(this.env, owner.id, owner.login);
712 const id = newId();
713 const t = now();
714 const branch = input.defaultBranch || "main";
715 await this.db.batch([
716 this.db
717 .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
718 .bind(id, owner.id, input.name, input.description ?? "", repoVisibility(input.visibility) ?? "private", branch, t, t),
719 ...(creatorGrant ? [this.db.prepare("INSERT INTO repo_access (repo_id, account_id, role) VALUES (?, ?, 'admin')").bind(id, creatorGrant)] : []),
720 // A new repository at a moved one's old name takes it over for good, as on GitHub.
721 ...this.claimName(owner.login, input.name),
722 ]);
723 const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user", archived: false };
724 const stub = this.env.REPO.get(this.env.REPO.idFromName(id));
725 await stub.init(meta);
726 if (owner.kind === "org") {
727 const vars = await this.orgVariables(owner.id);
728 if (vars.length) await stub.setOrgVariables(Object.fromEntries(vars.map((v) => [v.name, v.value])));
729 }
730 return (await this.repoById(id))!;
731 }
732
733 async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: string; defaultBranch?: string; archived?: boolean; homepage?: string; topics?: string[] }): Promise<Repo> {
734 if (patch.name && !isValidName(patch.name)) throw new HttpError(422, "invalid repository name");
735 const renamed = !!patch.name && patch.name !== repo.name;
736 if (renamed && patch.name!.toLowerCase() !== repo.name.toLowerCase() && (await this.repo(repo.owner_login, patch.name!))) {
737 throw new HttpError(422, "a repository with this name already exists", { errors: [{ resource: "Repository", code: "custom", field: "name", message: "name already exists on this account" }] });
738 }
739 await this.db.batch([
740 this.db
741 .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?")
742 .bind(patch.name ?? null, patch.description ?? null, repoVisibility(patch.visibility) ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id),
743 // A rename leaves a redirect from the old name, like a transfer.
744 ...(renamed ? [this.leaveRedirect(repo), ...this.claimName(repo.owner_login, patch.name!)] : []),
745 ]);
746 if (patch.homepage !== undefined || patch.topics !== undefined) {
747 await this.db
748 .prepare("UPDATE repos SET homepage = COALESCE(?, homepage), topics = COALESCE(?, topics) WHERE id = ?")
749 .bind(patch.homepage ?? null, patch.topics ? JSON.stringify(patch.topics.slice(0, 20)) : null, repo.id)
750 .run();
751 }
752 if (patch.name || patch.defaultBranch) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).updateMeta({ name: patch.name ?? repo.name, defaultBranch: patch.defaultBranch ?? repo.default_branch });
753 // The repository's object lands pull requests by itself (auto-merge, the merge queue): it must know, and stop.
754 if (patch.archived !== undefined) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).setArchived(patch.archived);
755 if (renamed) await this.forgetArchives(repo.id);
756 return (await this.repoById(repo.id))!;
757 }
758
759 async deleteRepo(repo: Repo): Promise<void> {
760 await this.db.batch(this.repoRowDeletes({ repoId: repo.id }));
761 // Wipes the Durable Object's storage, then the repository's R2 objects.
762 await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy();
763 }
764
765 /**
766 * The directory's part of deleting one repository, or every repository an account owns (account deletion,
767 * which then runs each Durable Object's `destroy()` in the background).
768 */
769 repoRowDeletes(which: { repoId: string } | { ownerId: string }): D1PreparedStatement[] {
770 const [ids, key] = "repoId" in which ? ["?", which.repoId] : ["SELECT id FROM repos WHERE owner_id = ?", which.ownerId];
771 return [
772 this.db.prepare(`DELETE FROM repo_access WHERE repo_id IN (${ids})`).bind(key),
773 // Invitations reference the repository without ON DELETE CASCADE, so they'd block the delete.
774 this.db.prepare(`DELETE FROM invites WHERE repo_id IN (${ids})`).bind(key),
775 this.db.prepare(`DELETE FROM schedules WHERE repo_id IN (${ids})`).bind(key),
776 // A deleted repository's old names stop redirecting.
777 this.db.prepare(`DELETE FROM repo_redirects WHERE repo_id IN (${ids})`).bind(key),
778 this.db.prepare(`DELETE FROM repos WHERE ${"repoId" in which ? "id" : "owner_id"} = ?`).bind(key),
779 ];
780 }
781
782 /**
783 * Move a repository to another owner, like GitHub's transfer, optionally renaming it. The repository
784 * keeps its id, so its Durable Object (git data, issues, pull requests, runs, secrets, variables,
785 * environments, webhooks, deploy keys, caches) and its R2 objects stay where they are; its packages
786 * move with it, and the old owner/name redirects to the new one. The caller has checked that `by`
787 * administers the repository; this checks the rest: only people transfer, a personal repository only
788 * by its owner, and only to `by`'s own account or an organization `by` owns or administers.
789 */
790 async transferRepo(repo: Repo, by: Account, target: Account, newName?: string): Promise<Repo> {
791 if (by.kind !== "user" || by.status !== "active") throw new HttpError(403, "only people can transfer repositories");
792 if (repo.owner_kind !== "org" && repo.owner_id !== by.id && !by.site_admin) throw new HttpError(403, "only the owner can transfer a personal repository");
793 if (target.kind === "agent" || target.status !== "active") throw new HttpError(422, "repositories can only be transferred to a person or an organization", { errors: [{ resource: "Repository", code: "invalid", field: "new_owner" }] });
794 const targetRole = target.kind === "org" ? await this.orgRole(target.id, by.id) : null;
795 if (target.kind === "user" ? target.id !== by.id : targetRole !== "owner" && targetRole !== "admin") {
796 throw new HttpError(403, "you can transfer repositories only to your own account or to an organization you own or administer");
797 }
798 if (target.id === repo.owner_id) throw new HttpError(422, `${repo.owner_login}/${repo.name} already belongs to ${target.login}`, { errors: [{ resource: "Repository", code: "invalid", field: "new_owner" }] });
799 const name = newName?.trim() || repo.name;
800 if (!isValidName(name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'", { errors: [{ resource: "Repository", code: "invalid", field: "new_name" }] });
801 if (await this.repo(target.login, name)) throw new HttpError(422, `${target.login} already has a repository named ${name}`, { errors: [{ resource: "Repository", code: "custom", field: "name", message: "name already exists on this account" }] });
802 await checkRepositoryQuota(this.env, target.id, target.login);
803 // Packages linked to the repository move with it; an image of the same name under the target would collide.
804 const clash = await this.db
805 .prepare("SELECT p.name FROM packages p WHERE p.owner_id = ? AND p.name IN (SELECT name FROM packages WHERE repo_id = ?) LIMIT 1")
806 .bind(target.id, repo.id)
807 .first<{ name: string }>();
808 if (clash) throw new HttpError(422, `${target.login} already has a container image named ${clash.name}; this repository's image of that name would replace it`);
809 // An import filling the repository works by its owner and name; let it finish first.
810 const stub = this.env.REPO.get(this.env.REPO.idFromName(repo.id));
811 if (await stub.importLockHolder()) throw new HttpError(423, "repository is locked by an import in progress; transfer it once the import finishes");
812 const t = now();
813 await this.db.batch([
814 this.db.prepare("UPDATE repos SET owner_id = ?, name = ?, updated_at = ? WHERE id = ?").bind(target.id, name, t, repo.id),
815 this.leaveRedirect(repo),
816 ...this.claimName(target.login, name),
817 this.db.prepare("INSERT OR REPLACE INTO package_redirects (owner_login, name, package_id, created_at) SELECT ?, name, id, ? FROM packages WHERE repo_id = ?").bind(repo.owner_login, t, repo.id),
818 this.db.prepare("DELETE FROM package_redirects WHERE owner_login = ? AND name IN (SELECT name FROM packages WHERE repo_id = ?)").bind(target.login, repo.id),
819 this.db.prepare("UPDATE packages SET owner_id = ?, updated_at = ? WHERE repo_id = ?").bind(target.id, t, repo.id),
820 // Direct collaborators stay, and a new personal owner needs no grant of its own. Grants that came
821 // with the old organization go, as when someone leaves it: its members' (including the admin grant
822 // of whoever created the repository), their agents' and the organization's own agents'.
823 this.db.prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, target.id),
824 ...(repo.owner_kind === "org"
825 ? [
826 this.db
827 .prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id IN (SELECT user_id FROM org_members WHERE org_id = ? UNION SELECT id FROM accounts WHERE kind = 'agent' AND (owner_id = ? OR owner_id IN (SELECT user_id FROM org_members WHERE org_id = ?)))")
828 .bind(repo.id, repo.owner_id, repo.owner_id, repo.owner_id),
829 ]
830 : []),
831 ]);
832 // The Durable Object keeps its id; refresh what it knows about its owner, and swap in the new owner's organization variables.
833 await stub.updateMeta({ owner: target.login, ownerId: target.id, ownerKind: target.kind === "org" ? "org" : "user", name });
834 const vars = target.kind === "org" ? await this.orgVariables(target.id) : [];
835 await stub.setOrgVariables(Object.fromEntries(vars.map((v) => [v.name, v.value])));
836 await this.forgetArchives(repo.id);
837 return (await this.repoById(repo.id))!;
838 }
839
840 /** Cached zipballs and tarballs are named after the owner and repository; drop them when either changes (they are rebuilt on the next download). */
841 private async forgetArchives(repoId: string): Promise<void> {
842 await deletePrefixes(this.env.BUCKET, [archivePrefix(repoId)]).catch((e) => console.error("could not drop cached archives", repoId, e));
843 }
844
845 // ---------------------------------------------------------------- redirects (transfers and renames)
846
847 /** Remember a repository's current owner/name before it moves, so the old URLs lead to it. */
848 private leaveRedirect(repo: Repo): D1PreparedStatement {
849 return this.db.prepare("INSERT OR REPLACE INTO repo_redirects (owner_login, name, repo_id, created_at) VALUES (?, ?, ?, ?)").bind(repo.owner_login, repo.name, repo.id, now());
850 }
851
852 /**
853 * A repository now lives at owner/name: an older redirect from there stops applying, and so do
854 * redirects of images named after it (<owner>/<name> and <owner>/<name>/...), which its own
855 * workflows push to.
856 */
857 private claimName(ownerLogin: string, name: string): D1PreparedStatement[] {
858 return [
859 this.db.prepare("DELETE FROM repo_redirects WHERE owner_login = ? AND name = ?").bind(ownerLogin, name),
860 this.db.prepare("DELETE FROM package_redirects WHERE owner_login = ? AND (name = ? OR substr(name, 1, length(?) + 1) = lower(?) || '/')").bind(ownerLogin, name.toLowerCase(), name, name),
861 ];
862 }
863
864 /**
865 * Where an owner/name that holds no repository now leads: a transferred or renamed repository, or one
866 * under a renamed owner. Callers must apply the same visibility check as for a direct load, so a
867 * redirect never shows that a private repository exists.
868 */
869 async movedRepo(owner: string, name: string): Promise<Repo | null> {
870 const bare = name.replace(/\.git$/, "");
871 const direct = await this.db.prepare("SELECT repo_id FROM repo_redirects WHERE owner_login = ? AND name = ?").bind(owner, bare).first<{ repo_id: string }>();
872 if (direct) return this.repoById(direct.repo_id);
873 const account = await this.renamedAccount(owner);
874 if (!account) return null;
875 const moved = await this.repo(account.login, bare);
876 if (moved) return moved;
877 const again = await this.db.prepare("SELECT repo_id FROM repo_redirects WHERE owner_login = ? AND name = ?").bind(account.login, bare).first<{ repo_id: string }>();
878 return again ? this.repoById(again.repo_id) : null;
879 }
880
881 /** The account that used to have this login, while no account has it now. */
882 async renamedAccount(login: string): Promise<Account | null> {
883 if (await this.accountByLogin(login)) return null;
884 return this.db.prepare("SELECT a.* FROM login_redirects l JOIN accounts a ON a.id = l.account_id WHERE l.login = ? AND a.status = 'active'").bind(login).first<Account>();
885 }
886
887 async setTopics(repoId: string, topics: string[]): Promise<void> {
888 await this.db.prepare("UPDATE repos SET topics = ?, updated_at = ? WHERE id = ?").bind(JSON.stringify(topics), now(), repoId).run();
889 }
890
891 // ---------------------------------------------------------------- organization variables
892
893 async orgVariables(orgId: string): Promise<Array<{ name: string; value: string; updated_at: string }>> {
894 return (await this.db.prepare("SELECT name, value, updated_at FROM org_variables WHERE org_id = ? ORDER BY name").bind(orgId).all<{ name: string; value: string; updated_at: string }>()).results;
895 }
896
897 /** Set (or, with null, delete) organization variables, then copy the set into each of the organization's repositories. */
898 async setOrgVariables(orgId: string, changes: Record<string, string | null>): Promise<void> {
899 const stmts = Object.entries(changes).map(([name, value]) =>
900 value === null
901 ? this.db.prepare("DELETE FROM org_variables WHERE org_id = ? AND name = ?").bind(orgId, name.toUpperCase())
902 : this.db.prepare("INSERT OR REPLACE INTO org_variables (org_id, name, value, updated_at) VALUES (?, ?, ?, ?)").bind(orgId, name.toUpperCase(), value, now()),
903 );
904 if (stmts.length) await this.db.batch(stmts);
905 await this.syncOrgVariables(orgId);
906 }
907
908 /**
909 * Add organization variables only where none exists (ON CONFLICT DO NOTHING), used by imports so
910 * an import never overwrites a value an org admin already set. Returns which names were added and
911 * which were skipped because a variable of that name already existed.
912 */
913 async addOrgVariablesIfAbsent(orgId: string, vars: Record<string, string>): Promise<{ added: string[]; skipped: string[] }> {
914 const before = new Set((await this.orgVariables(orgId)).map((v) => v.name));
915 const added: string[] = [];
916 const skipped: string[] = [];
917 const stmts: D1PreparedStatement[] = [];
918 for (const [rawName, value] of Object.entries(vars)) {
919 const name = rawName.toUpperCase();
920 if (before.has(name)) {
921 skipped.push(name);
922 continue;
923 }
924 added.push(name);
925 stmts.push(this.db.prepare("INSERT INTO org_variables (org_id, name, value, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, name) DO NOTHING").bind(orgId, name, value, now()));
926 }
927 if (stmts.length) {
928 await this.db.batch(stmts);
929 await this.syncOrgVariables(orgId);
930 }
931 return { added, skipped };
932 }
933
934 /**
935 * Copy the organization's current variable set into each of its repositories' Durable Objects.
936 * Every repository is tried even when one fails, and failures get a second try. Any still failing
937 * make this throw, so the caller can repeat the (idempotent) change: the variables themselves are
938 * already saved, and each copy is the whole set, so a later sync brings a stale repository current.
939 */
940 private async syncOrgVariables(orgId: string): Promise<void> {
941 const vars = Object.fromEntries((await this.orgVariables(orgId)).map((v) => [v.name, v.value]));
942 let ids = (await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(orgId).all<{ id: string }>()).results.map((r) => r.id);
943 for (let attempt = 0; attempt < 2 && ids.length; attempt++) {
944 const results = await Promise.allSettled(ids.map((id) => this.env.REPO.get(this.env.REPO.idFromName(id)).setOrgVariables(vars)));
945 ids = ids.filter((_, i) => results[i]!.status === "rejected");
946 }
947 if (ids.length) throw new HttpError(503, `the variables were saved, but ${ids.length} ${ids.length === 1 ? "repository wasn't" : "repositories weren't"} updated; repeat the request to finish`);
948 }
949
950 /** The highest role an agent may ever hold (its policy's maxRole, default write). */
951 agentMaxRole(account: Account): Role {
952 const policy = account.agent_policy ? (JSON.parse(account.agent_policy) as { maxRole?: Role }) : {};
953 return policy.maxRole ?? "write";
954 }
955
956 async touchPushed(repoId: string): Promise<void> {
957 await this.db.prepare("UPDATE repos SET pushed_at = ?, updated_at = ? WHERE id = ?").bind(now(), now(), repoId).run();
958 }
959
960 async collaborators(repoId: string): Promise<Array<{ account: Account; role: Role }>> {
961 const r = await this.db.prepare("SELECT a.*, x.role AS access_role FROM repo_access x JOIN accounts a ON a.id = x.account_id WHERE x.repo_id = ? ORDER BY a.login").bind(repoId).all<Account & { access_role: Role }>();
962 return r.results.map((x) => ({ account: x, role: x.access_role }));
963 }
964
965 async setCollaborator(repoId: string, accountId: string, role: Role | null): Promise<void> {
966 const previous = (await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repoId, accountId).first<{ role: Role }>())?.role ?? null;
967 if (role) await this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(repoId, accountId, role).run();
968 else await this.db.prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repoId, accountId).run();
969 const repo = previous !== role ? await this.repoById(repoId) : null;
970 if (repo) await enqueueMail(this.env, [{ kind: "repo_access", data: { repo: `${repo.owner_login}/${repo.name}`, role, previous }, to: { accountId }, reason: "account", at: now() }]);
971 }
972
973 /** Effective role of an account on a repository (null = no access). */
974 async roleFor(account: Account, repo: Repo): Promise<Role | null> {
975 if (account.status !== "active") return null;
976 if (account.kind === "agent") {
977 if (!account.owner_id) return null;
978 const owner = await this.account(account.owner_id);
979 if (!owner || owner.status !== "active") return null;
980 const ownerRole = owner.kind === "org" ? (repo.owner_id === owner.id ? "admin" : null) : await this.roleFor(owner, repo);
981 if (!ownerRole) return null;
982 const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>();
983 const policy = account.agent_policy ? (JSON.parse(account.agent_policy) as { maxRole?: Role }) : {};
984 const cap: Role = explicit?.role ?? policy.maxRole ?? "write";
985 return ROLE_LEVEL[ownerRole] < ROLE_LEVEL[cap] ? ownerRole : cap;
986 }
987 if (account.site_admin) return "admin";
988 let best: Role | null = null;
989 const bump = (r: Role | null) => {
990 if (r && (!best || ROLE_LEVEL[r] > ROLE_LEVEL[best])) best = r;
991 };
992 if (repo.owner_id === account.id) bump("admin");
993 if (repo.owner_kind === "org") {
994 // Owners and admins administer every repository; members get the organization's base permission.
995 const m = await this.db
996 .prepare("SELECT m.role, o.base_role FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.org_id = ? AND m.user_id = ?")
997 .bind(repo.owner_id, account.id)
998 .first<{ role: string; base_role: string | null }>();
999 if (m?.role === "owner" || m?.role === "admin") bump("admin");
1000 else if (m?.role === "member") {
1001 const base = baseRole(m);
1002 if (base !== "none") bump(base);
1003 }
1004 }
1005 const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>();
1006 bump(explicit?.role ?? null);
1007 if (repo.visibility !== "private") bump("read");
1008 return best;
1009 }
1010
1011 // ---------------------------------------------------------------- notifications and audit
1012
1013 async notify(accountIds: string[], n: { repoId?: string; kind: string; title: string; url: string }): Promise<void> {
1014 const unique = [...new Set(accountIds)];
1015 if (!unique.length) return;
1016 await this.db.batch(unique.map((id) => this.db.prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").bind(newId(), id, n.repoId ?? null, n.kind, n.title, n.url, now())));
1017 }
1018
1019 async notifications(accountId: string, unreadOnly = false): Promise<Array<{ id: string; kind: string; title: string; url: string; createdAt: string; readAt: string | null }>> {
1020 const r = await this.db.prepare(`SELECT * FROM notifications WHERE account_id = ? ${unreadOnly ? "AND read_at IS NULL" : ""} ORDER BY created_at DESC LIMIT 100`).bind(accountId).all<Record<string, string | null>>();
1021 return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at ?? null }));
1022 }
1023
1024 async markRead(accountId: string, id?: string): Promise<void> {
1025 await this.db.prepare(`UPDATE notifications SET read_at = ? WHERE account_id = ? ${id ? "AND id = ?" : ""} AND read_at IS NULL`).bind(...(id ? [now(), accountId, id] : [now(), accountId])).run();
1026 }
1027
1028 async audit(entry: { actorId?: string; actorLogin?: string; onBehalfOf?: string; action: string; target?: string; detail?: unknown; at?: string }): Promise<void> {
1029 await this.db
1030 .prepare("INSERT INTO audit_log (id, at, actor_id, actor_login, on_behalf_of, action, target, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
1031 .bind(newId(), entry.at ?? now(), entry.actorId ?? null, entry.actorLogin ?? null, entry.onBehalfOf ?? null, entry.action, entry.target ?? null, entry.detail ? JSON.stringify(entry.detail) : null)
1032 .run();
1033 // Security emails (sign-ins, passkeys, tokens, push protection…) hang off the audit log.
1034 await mailForAudit(this.env, this, entry).catch((e) => console.error("security email failed", entry.action, e));
1035 }
1036
1037 async auditLog(limit = 200): Promise<Array<Record<string, string | null>>> {
1038 return (await this.db.prepare("SELECT * FROM audit_log ORDER BY at DESC LIMIT ?").bind(limit).all<Record<string, string | null>>()).results;
1039 }
1040}