Skip to content
312 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1/**
2 * Who may do what in a repository: repository roles, the capabilities each
3 * one carries, and how a person's permission is worked out.
4 *
5 * Mirrors `crates/contracts/src/access.rs`, which holds the one permission
6 * table; a test there reads `CAPABILITIES` and `OWNER_ONLY` below and fails
7 * when the two differ. Keep each row on one line.
8 */
9import type { Membership, Role, User } from "./identity";
Merge main (membership, two-factor, GitHub repo roles) into tokens10import type { MemberPrivileges } from "./members";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look11import type { Result } from "./result";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar12import type { RepoTeam } from "./teams";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look13
Merge main (membership, two-factor, GitHub repo roles) into tokens14/** What members may do in a workspace that has not chosen otherwise: what they could before the setting existed. */
15export const DEFAULT_MEMBER_PRIVILEGES: MemberPrivileges = {
16 members_can_create_public_repositories: true,
17 members_can_create_private_repositories: true,
18 members_can_change_repo_visibility: true,
19 members_can_delete_repositories: false,
20 members_can_invite_outside_collaborators: true,
21};
22
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23/** What someone may do in one repository, from least to most. */
24export type RepoRole = "read" | "triage" | "write" | "maintain" | "admin";
25
26export const REPO_ROLES: readonly RepoRole[] = ["read", "triage", "write", "maintain", "admin"];
27
28export const REPO_ROLE_LABELS: Record<RepoRole, string> = {
29 read: "Read",
30 triage: "Triage",
31 write: "Write",
32 maintain: "Maintain",
33 admin: "Admin",
34};
35
36/** One line on what each role is for, as role pickers show it. */
37export const REPO_ROLE_SUMMARIES: Record<RepoRole, string> = {
38 read: "Read and clone; open issues and pull requests, and comment.",
Merge main (membership, two-factor, GitHub repo roles) into tokens39 triage: "Read, and manage issues and pull requests: apply labels, assign, close.",
40 write: "Triage, and push, merge, manage labels, see security alerts, and put agents to work.",
41 maintain: "Write, and manage the repository's settings and topics.",
42 admin: "Everything: branch protection, webhooks, secrets, security, access, name and visibility.",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43};
44
45/** What every member of a workspace gets on each of its repositories. */
46export type BasePermission = "none" | "read" | "write" | "admin";
47
48export const BASE_PERMISSIONS: readonly BasePermission[] = ["none", "read", "write", "admin"];
49
Merge main (membership, two-factor, GitHub repo roles) into tokens50/** What a membership that does not say gets: what members could do before roles. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look51export const DEFAULT_BASE_PERMISSION: BasePermission = "write";
52
Merge main (membership, two-factor, GitHub repo roles) into tokens53/** What a new workspace's members get, as on GitHub. Workspaces made before 2026-10-08 kept Write. */
54export const NEW_WORKSPACE_BASE_PERMISSION: BasePermission = "read";
55
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look56export const BASE_PERMISSION_LABELS: Record<BasePermission, string> = {
57 none: "No permission",
58 read: "Read",
59 write: "Write",
60 admin: "Admin",
61};
62
63export type Capability =
64 | "read"
65 | "participate"
66 | "triage"
67 | "push"
68 | "merge"
Merge main (membership, two-factor, GitHub repo roles) into tokens69 | "manage_labels"
70 | "security_alerts"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look71 | "run"
72 | "manage_settings"
73 | "manage_protection"
Merge main (membership, two-factor, GitHub repo roles) into tokens74 | "manage_security"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 | "manage_integrations"
76 | "manage_access"
77 | "administer"
Merge main (membership, two-factor, GitHub repo roles) into tokens78 | "change_visibility"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look79 | "delete";
80
81/** The permission table: the least role for each capability. */
82export const CAPABILITIES = [
83 { capability: "read", role: "read", about: "See code, issues and pull requests; clone and fetch" },
84 { capability: "participate", role: "read", about: "Open issues and pull requests, and comment" },
Merge main (membership, two-factor, GitHub repo roles) into tokens85 { capability: "triage", role: "triage", about: "Apply labels and milestones; assign, close and reopen issues and pull requests" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look86 { capability: "push", role: "write", about: "Push to branches that are not protected" },
87 { capability: "merge", role: "write", about: "Merge pull requests and use the merge queue" },
Merge main (membership, two-factor, GitHub repo roles) into tokens88 { capability: "manage_labels", role: "write", about: "Create, edit and delete labels and milestones" },
89 { capability: "security_alerts", role: "write", about: "See and dismiss security alerts" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look90 { capability: "run", role: "write", about: "Assign agents and start runs, plans and workflows" },
91 { capability: "manage_settings", role: "maintain", about: "Change the description, topics, and pull request and agent settings" },
Merge main (membership, two-factor, GitHub repo roles) into tokens92 { capability: "manage_protection", role: "admin", about: "Change branch protection, rulesets and guardrails" },
93 { capability: "manage_security", role: "admin", about: "Change security settings, custom patterns and bypass reviews" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look94 { capability: "manage_integrations", role: "admin", about: "Manage webhooks, secrets, variables, deployments and domains" },
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca95 { capability: "manage_access", role: "admin", about: "Manage who has access, invitations and deploy keys" },
Merge main (membership, two-factor, GitHub repo roles) into tokens96 { capability: "administer", role: "admin", about: "Rename, archive and change the default branch" },
97 { capability: "change_visibility", role: "admin", about: "Change visibility (owners only, unless member privileges allow admins)" },
98 { capability: "delete", role: "admin", about: "Transfer or delete the repository (owners only, unless member privileges allow admins)" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look99] as const satisfies readonly { capability: Capability; role: RepoRole; about: string }[];
100
Merge main (membership, two-factor, GitHub repo roles) into tokens101/**
102 * Capabilities that also need an owner of the repository's workspace,
103 * unless its member privileges let members with the Admin role do them.
104 */
105export const OWNER_ONLY = ["change_visibility", "delete"] as const satisfies readonly Capability[];
106
107/** What a security manager may do on every repository of their workspace. */
108export const SECURITY_MANAGER = ["read", "participate", "security_alerts", "manage_security"] as const satisfies readonly Capability[];
109
110/** Whether an owner-only capability is left to owners by these member privileges. */
111export function ownerOnly(capability: Capability, privileges: MemberPrivileges | null | undefined): boolean {
112 const p = { ...DEFAULT_MEMBER_PRIVILEGES, ...(privileges ?? {}) };
113 if (capability === "change_visibility") return !p.members_can_change_repo_visibility;
114 if (capability === "delete") return !p.members_can_delete_repositories;
115 return false;
116}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look117
118/** A person's role on one repository, given directly. */
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar119export type RepoGrant = {
120 repo_id: string;
121 workspace: string;
122 role: RepoRole;
123 /** The team it comes through, when it is a team's grant. */
124 team?: string;
125};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look126
127/** What `permission` needs to know about a repository. */
128export type RepoRef = { id: string; namespace: string; isPrivate: boolean };
129
130const rank = (role: RepoRole): number => REPO_ROLES.indexOf(role);
131
132/** The higher of two roles; null is lower than any. */
133export function maxRole(a: RepoRole | null | undefined, b: RepoRole | null | undefined): RepoRole | null {
134 if (!a) return b ?? null;
135 if (!b) return a;
136 return rank(a) >= rank(b) ? a : b;
137}
138
139export function leastRole(capability: Capability): RepoRole {
140 return CAPABILITIES.find((row) => row.capability === capability)?.role ?? "admin";
141}
142
143/** Whether `role` has `capability`, going by the table alone. */
144export function allows(role: RepoRole | null | undefined, capability: Capability): boolean {
145 return role != null && rank(role) >= rank(leastRole(capability));
146}
147
148export function baseRole(base: BasePermission | null | undefined): RepoRole | null {
149 const value = base ?? DEFAULT_BASE_PERMISSION;
150 return value === "none" ? null : value;
151}
152
153function membershipRole(user: User, membership: Membership): RepoRole | null {
TS access mirrors the workspace token cap and fine-grained reach154 // A workspace's own token has Write, or Admin when an owner gave it that;
155 // g1t, and a service acting as the workspace, do what an owner can.
156 if (user.kind === "workspace" && user.token) return user.token.admin ? "admin" : "write";
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily157 if (user.kind === "workspace" || user.kind === "system") return "admin";
Merge main (membership, two-factor, GitHub repo roles) into tokens158 if (membership.role === "owner") return "admin";
159 const base = baseRole(membership.base_permission);
160 // A security manager reads every repository.
161 return membership.org_roles?.includes("security_manager") ? maxRole(base, "read") : base;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look162}
163
164/** The user's role on the repository, not counting that it may be public. */
165export function granted(user: User, repo: RepoRef): RepoRole | null {
TS access mirrors the workspace token cap and fine-grained reach166 // A fine-grained token outside its resource owner or selection: no role.
167 const reach = user.token?.fine_grained;
168 if (reach) {
169 const inside =
170 !!reach.workspace &&
171 reach.workspace.toLowerCase() === repo.namespace.toLowerCase() &&
172 (reach.repositories === "all" || (reach.repositories === "selected" && (reach.repo_ids ?? []).includes(repo.id)));
173 if (!inside) return null;
174 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look175 const namespace = repo.namespace.toLowerCase();
176 const membership = user.workspaces?.find((m) => m.slug.toLowerCase() === namespace);
177 let role = membership ? membershipRole(user, membership) : null;
178 for (const grant of user.grants ?? []) {
179 if (grant.repo_id === repo.id) role = maxRole(role, grant.role);
180 }
181 return role;
182}
183
184/** The viewer's effective role on a repository; null means they may not see it. */
185export function permission(viewer: User | null | undefined, repo: RepoRef): RepoRole | null {
186 const role = viewer ? granted(viewer, repo) : null;
187 return repo.isPrivate ? role : maxRole(role, "read");
188}
189
190/** Whether the viewer may do `capability` in the repository. */
191export function can(viewer: User | null | undefined, repo: RepoRef, capability: Capability): boolean {
Merge main (membership, two-factor, GitHub repo roles) into tokens192 const namespace = repo.namespace.toLowerCase();
193 const membership = viewer?.workspaces?.find((m) => m.slug.toLowerCase() === namespace);
194 if (!allows(permission(viewer, repo), capability)) {
195 return (
196 (SECURITY_MANAGER as readonly Capability[]).includes(capability) &&
197 (viewer?.kind ?? "user") === "user" &&
198 !!membership?.org_roles?.includes("security_manager")
199 );
200 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201 if ((OWNER_ONLY as readonly Capability[]).includes(capability)) {
Merge main (membership, two-factor, GitHub repo roles) into tokens202 if (!membership) return false;
203 return membership.role === "owner" || !ownerOnly(capability, membership.privileges);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look204 }
205 return true;
206}
207
208/** Every capability, true or false, for one viewer and repository: what pages pass to their components. */
209export type Abilities = Record<Capability, boolean>;
210
211export function abilities(viewer: User | null | undefined, repo: RepoRef): Abilities {
212 return Object.fromEntries(CAPABILITIES.map((row) => [row.capability, can(viewer, repo, row.capability)])) as Abilities;
213}
214
215/** The sentence shown beside something the viewer cannot use. */
216export function needs(capability: Capability): string {
Merge main (membership, two-factor, GitHub repo roles) into tokens217 if ((OWNER_ONLY as readonly Capability[]).includes(capability))
218 return "Only an owner of the workspace can do this, unless its member privileges let repository admins.";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look219 return `Needs the ${REPO_ROLE_LABELS[leastRole(capability)]} role or higher.`;
220}
221
222/** Whether the user belongs to the workspace or has a role on one of its repositories. */
223export function hasAccessIn(user: User | null | undefined, namespace: string): boolean {
224 const slug = namespace.toLowerCase();
225 return !!user && (!!user.workspaces?.some((m) => m.slug.toLowerCase() === slug) || !!user.grants?.some((g) => g.workspace.toLowerCase() === slug));
226}
227
228/** The workspaces where the user has repositories shared with them without being a member. */
229export function sharedWorkspaces(user: User | null | undefined): string[] {
230 if (!user) return [];
231 const member = new Set((user.workspaces ?? []).map((m) => m.slug));
232 return [...new Set((user.grants ?? []).map((g) => g.workspace).filter((slug) => !member.has(slug)))];
233}
234
235// --- Who has access ----------------------------------------------------------
236
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar237export type AccessSource = "owner" | "base" | "direct" | "team";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look238
239export type Collaborator = {
240 username: string;
241 name: string | null;
242 avatar: string | null;
243 role: RepoRole;
244 source: AccessSource;
245 direct: RepoRole | null;
246 /** Null for an outside collaborator. */
247 workspace_role: Role | null;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar248 /** The highest role a team gives them here, and that team's slug. */
249 team_role?: RepoRole | null;
250 team?: string | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look251};
252
253export type RepoInvitationStatus = "pending" | "accepted" | "declined" | "revoked" | "expired";
254
255export type RepoInvitation = {
256 id: string;
257 /** `workspace/name`. */
258 repo: string;
259 repo_id: string;
260 invitee: string | null;
261 email: string | null;
262 role: RepoRole;
263 invited_by: string | null;
264 /** The inviter's avatar hash, served at `/avatars/<avatar>`; null for the generated letter avatar. */
265 inviter_avatar?: string | null;
266 status: RepoInvitationStatus;
267 created_at: string;
268 expires_at: string;
269};
270
271export type RepoAccess = {
272 repo: string;
273 base_permission: BasePermission;
274 people: Collaborator[];
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar275 /** The workspace's teams given a role on it. */
276 teams?: RepoTeam[];
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look277 invitations: RepoInvitation[];
278 viewer_role: RepoRole | null;
279 can_manage: boolean;
280};
281
282export type Added =
283 | { result: "granted"; collaborator: Collaborator }
284 | { result: "invited"; invitation: RepoInvitation };
285
286export type PermissionInfo = {
287 username: string;
288 role: RepoRole | null;
289 source: AccessSource | null;
290 capabilities: Capability[];
291};
292
293export type OutsideCollaborator = {
294 username: string;
295 name: string | null;
296 avatar: string | null;
297 repos: { repo: string; role: RepoRole }[];
298};
299
300/** Identity's access methods, by repository path. */
301export interface AccessClient {
302 repoAccess(owner: string, name: string, viewer: User | null): Promise<Result<RepoAccess>>;
303 addCollaborator(actor: User, owner: string, name: string, invitee: string, role: RepoRole): Promise<Result<Added>>;
304 setCollaboratorRole(actor: User, owner: string, name: string, username: string, role: RepoRole): Promise<Result<Collaborator>>;
305 removeCollaborator(actor: User, owner: string, name: string, username: string): Promise<Result<boolean>>;
306 collaboratorPermission(viewer: User | null, owner: string, name: string, username: string): Promise<Result<PermissionInfo>>;
307 myRepoInvitations(user: User): Promise<RepoInvitation[]>;
308 respondRepoInvitation(user: User, id: string, accept: boolean): Promise<Result<RepoInvitation>>;
309 revokeRepoInvitation(actor: User, owner: string, name: string, id: string): Promise<Result<RepoInvitation>>;
310 setBasePermission(actor: User, slug: string, base: BasePermission): Promise<Result<BasePermission>>;
311 outsideCollaborators(viewer: User | null, slug: string): Promise<Result<OutsideCollaborator[]>>;
312}

This file's history is long; its oldest lines are credited to the oldest commit read.