import { first, run } from "./db"; import { notFoundError } from "./errors"; import { assertBelowCap, CAPS } from "./limits"; import { scheduleRevocationSweep, sweepRevokedRepoAccess } from "./pull-guard"; import { hasPermission, loadRepoById } from "./repos"; import { dropMcpAccess, purgeFollowerIfRevoked, revokeDeployTokensCreatedBy } from "./access-revocation"; import type { Permission, RepoRow } from "~/lib/types"; export async function ownerAccount(name: string): Promise<{ id: string; name: string; kind: string }> { const account = await first<{ id: string; name: string; kind: string }>( "SELECT id, name, kind FROM accounts WHERE name = ?", name, ); if (!account) throw notFoundError("Owner not found"); return account; } export async function findCollaboratorUser(username: string): Promise<{ id: string }> { const user = await first<{ id: string }>( "SELECT a.id FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name = ?", username, ); if (!user) throw notFoundError("User not found"); return user; } /** Grants, changes or (with "none") removes a user's collaborator permission; callers check admin access. */ export async function setCollaborator( repo: Pick, username: string, permission: "read" | "write" | "admin" | "none", ): Promise { const user = await findCollaboratorUser(username); const existing = await first("SELECT 1 AS ok FROM collaborators WHERE repo_id = ? AND user_id = ?", repo.id, user.id); if (!existing && permission !== "none") { const count = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM collaborators WHERE repo_id = ?", repo.id, ); assertBelowCap(count?.count ?? 0, CAPS.collaboratorsPerRepo, "collaborators"); } if (permission === "none") { await run("DELETE FROM collaborators WHERE repo_id = ? AND user_id = ?", repo.id, user.id); // GIT-A1 / GIT-A8: a removed reader's fork pull requests stop relaying and their private forks detach. scheduleRevocationSweep(async () => { const fresh = await loadRepoById(repo.id); if (fresh) await sweepRevokedRepoAccess(fresh, user.id); }); await purgeFollowerIfRevoked(repo.id, user.id); } else { const previous = await first<{ permission: string }>( "SELECT permission FROM collaborators WHERE repo_id = ? AND user_id = ?", repo.id, user.id, ); await run( `INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (repo_id, user_id) DO UPDATE SET permission = excluded.permission`, repo.id, user.id, permission, Date.now(), ); // Deploy tokens are minted by admins; one who is no longer admin (and holds no other admin seat) loses them. if (previous?.permission === "admin" && permission !== "admin") { await revokeDeployTokensCreatedBy(user.id, [repo.id]); } if (previous && hasPermission(previous.permission as Permission, permission) && previous.permission !== permission) await dropMcpAccess(repo.id); } }