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 look | 1 | /** |
| 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 | */ | |
| 9 | import type { Membership, Role, User } from "./identity"; | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 10 | import type { MemberPrivileges } from "./members"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 11 | import type { Result } from "./result"; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 12 | import type { RepoTeam } from "./teams"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 13 | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 14 | /** What members may do in a workspace that has not chosen otherwise: what they could before the setting existed. */ |
| 15 | export 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 look | 23 | /** What someone may do in one repository, from least to most. */ |
| 24 | export type RepoRole = "read" | "triage" | "write" | "maintain" | "admin"; | |
| 25 | ||
| 26 | export const REPO_ROLES: readonly RepoRole[] = ["read", "triage", "write", "maintain", "admin"]; | |
| 27 | ||
| 28 | export 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. */ | |
| 37 | export const REPO_ROLE_SUMMARIES: Record<RepoRole, string> = { | |
| 38 | read: "Read and clone; open issues and pull requests, and comment.", | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 39 | 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 look | 43 | }; |
| 44 | ||
| 45 | /** What every member of a workspace gets on each of its repositories. */ | |
| 46 | export type BasePermission = "none" | "read" | "write" | "admin"; | |
| 47 | ||
| 48 | export const BASE_PERMISSIONS: readonly BasePermission[] = ["none", "read", "write", "admin"]; | |
| 49 | ||
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 50 | /** 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 look | 51 | export const DEFAULT_BASE_PERMISSION: BasePermission = "write"; |
| 52 | ||
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 53 | /** What a new workspace's members get, as on GitHub. Workspaces made before 2026-10-08 kept Write. */ |
| 54 | export const NEW_WORKSPACE_BASE_PERMISSION: BasePermission = "read"; | |
| 55 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 56 | export const BASE_PERMISSION_LABELS: Record<BasePermission, string> = { |
| 57 | none: "No permission", | |
| 58 | read: "Read", | |
| 59 | write: "Write", | |
| 60 | admin: "Admin", | |
| 61 | }; | |
| 62 | ||
| 63 | export type Capability = | |
| 64 | | "read" | |
| 65 | | "participate" | |
| 66 | | "triage" | |
| 67 | | "push" | |
| 68 | | "merge" | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 69 | | "manage_labels" |
| 70 | | "security_alerts" | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 71 | | "run" |
| 72 | | "manage_settings" | |
| 73 | | "manage_protection" | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 74 | | "manage_security" |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 75 | | "manage_integrations" |
| 76 | | "manage_access" | |
| 77 | | "administer" | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 78 | | "change_visibility" |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 79 | | "delete"; |
| 80 | ||
| 81 | /** The permission table: the least role for each capability. */ | |
| 82 | export 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 membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 85 | { 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 look | 86 | { 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 membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 88 | { 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 look | 90 | { 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 membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 92 | { 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 look | 94 | { capability: "manage_integrations", role: "admin", about: "Manage webhooks, secrets, variables, deployments and domains" }, |
| 95 | { capability: "manage_access", role: "admin", about: "Manage who has access, and invitations" }, | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 96 | { 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 look | 99 | ] as const satisfies readonly { capability: Capability; role: RepoRole; about: string }[]; |
| 100 | ||
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 101 | /** |
| 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 | */ | |
| 105 | export 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. */ | |
| 108 | export 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. */ | |
| 111 | export 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 look | 117 | |
| 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 bar | 119 | export 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 look | 126 | |
| 127 | /** What `permission` needs to know about a repository. */ | |
| 128 | export type RepoRef = { id: string; namespace: string; isPrivate: boolean }; | |
| 129 | ||
| 130 | const rank = (role: RepoRole): number => REPO_ROLES.indexOf(role); | |
| 131 | ||
| 132 | /** The higher of two roles; null is lower than any. */ | |
| 133 | export 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 | ||
| 139 | export 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. */ | |
| 144 | export function allows(role: RepoRole | null | undefined, capability: Capability): boolean { | |
| 145 | return role != null && rank(role) >= rank(leastRole(capability)); | |
| 146 | } | |
| 147 | ||
| 148 | export function baseRole(base: BasePermission | null | undefined): RepoRole | null { | |
| 149 | const value = base ?? DEFAULT_BASE_PERMISSION; | |
| 150 | return value === "none" ? null : value; | |
| 151 | } | |
| 152 | ||
| 153 | function membershipRole(user: User, membership: Membership): RepoRole | null { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 154 | // A workspace's own token, and g1t acting in the workspace, do what an |
| 155 | // owner can on its repositories. | |
| 156 | if (user.kind === "workspace" || user.kind === "system") return "admin"; | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 157 | if (membership.role === "owner") return "admin"; |
| 158 | const base = baseRole(membership.base_permission); | |
| 159 | // A security manager reads every repository. | |
| 160 | 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 look | 161 | } |
| 162 | ||
| 163 | /** The user's role on the repository, not counting that it may be public. */ | |
| 164 | export function granted(user: User, repo: RepoRef): RepoRole | null { | |
| 165 | const namespace = repo.namespace.toLowerCase(); | |
| 166 | const membership = user.workspaces?.find((m) => m.slug.toLowerCase() === namespace); | |
| 167 | let role = membership ? membershipRole(user, membership) : null; | |
| 168 | for (const grant of user.grants ?? []) { | |
| 169 | if (grant.repo_id === repo.id) role = maxRole(role, grant.role); | |
| 170 | } | |
| 171 | return role; | |
| 172 | } | |
| 173 | ||
| 174 | /** The viewer's effective role on a repository; null means they may not see it. */ | |
| 175 | export function permission(viewer: User | null | undefined, repo: RepoRef): RepoRole | null { | |
| 176 | const role = viewer ? granted(viewer, repo) : null; | |
| 177 | return repo.isPrivate ? role : maxRole(role, "read"); | |
| 178 | } | |
| 179 | ||
| 180 | /** Whether the viewer may do `capability` in the repository. */ | |
| 181 | export function can(viewer: User | null | undefined, repo: RepoRef, capability: Capability): boolean { | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 182 | const namespace = repo.namespace.toLowerCase(); |
| 183 | const membership = viewer?.workspaces?.find((m) => m.slug.toLowerCase() === namespace); | |
| 184 | if (!allows(permission(viewer, repo), capability)) { | |
| 185 | return ( | |
| 186 | (SECURITY_MANAGER as readonly Capability[]).includes(capability) && | |
| 187 | (viewer?.kind ?? "user") === "user" && | |
| 188 | !!membership?.org_roles?.includes("security_manager") | |
| 189 | ); | |
| 190 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 191 | if ((OWNER_ONLY as readonly Capability[]).includes(capability)) { |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 192 | if (!membership) return false; |
| 193 | return membership.role === "owner" || !ownerOnly(capability, membership.privileges); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 194 | } |
| 195 | return true; | |
| 196 | } | |
| 197 | ||
| 198 | /** Every capability, true or false, for one viewer and repository: what pages pass to their components. */ | |
| 199 | export type Abilities = Record<Capability, boolean>; | |
| 200 | ||
| 201 | export function abilities(viewer: User | null | undefined, repo: RepoRef): Abilities { | |
| 202 | return Object.fromEntries(CAPABILITIES.map((row) => [row.capability, can(viewer, repo, row.capability)])) as Abilities; | |
| 203 | } | |
| 204 | ||
| 205 | /** The sentence shown beside something the viewer cannot use. */ | |
| 206 | export function needs(capability: Capability): string { | |
| Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA | 207 | if ((OWNER_ONLY as readonly Capability[]).includes(capability)) |
| 208 | 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 look | 209 | return `Needs the ${REPO_ROLE_LABELS[leastRole(capability)]} role or higher.`; |
| 210 | } | |
| 211 | ||
| 212 | /** Whether the user belongs to the workspace or has a role on one of its repositories. */ | |
| 213 | export function hasAccessIn(user: User | null | undefined, namespace: string): boolean { | |
| 214 | const slug = namespace.toLowerCase(); | |
| 215 | return !!user && (!!user.workspaces?.some((m) => m.slug.toLowerCase() === slug) || !!user.grants?.some((g) => g.workspace.toLowerCase() === slug)); | |
| 216 | } | |
| 217 | ||
| 218 | /** The workspaces where the user has repositories shared with them without being a member. */ | |
| 219 | export function sharedWorkspaces(user: User | null | undefined): string[] { | |
| 220 | if (!user) return []; | |
| 221 | const member = new Set((user.workspaces ?? []).map((m) => m.slug)); | |
| 222 | return [...new Set((user.grants ?? []).map((g) => g.workspace).filter((slug) => !member.has(slug)))]; | |
| 223 | } | |
| 224 | ||
| 225 | // --- Who has access ---------------------------------------------------------- | |
| 226 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 227 | export type AccessSource = "owner" | "base" | "direct" | "team"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 228 | |
| 229 | export type Collaborator = { | |
| 230 | username: string; | |
| 231 | name: string | null; | |
| 232 | avatar: string | null; | |
| 233 | role: RepoRole; | |
| 234 | source: AccessSource; | |
| 235 | direct: RepoRole | null; | |
| 236 | /** Null for an outside collaborator. */ | |
| 237 | workspace_role: Role | null; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 238 | /** The highest role a team gives them here, and that team's slug. */ |
| 239 | team_role?: RepoRole | null; | |
| 240 | team?: string | null; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 241 | }; |
| 242 | ||
| 243 | export type RepoInvitationStatus = "pending" | "accepted" | "declined" | "revoked" | "expired"; | |
| 244 | ||
| 245 | export type RepoInvitation = { | |
| 246 | id: string; | |
| 247 | /** `workspace/name`. */ | |
| 248 | repo: string; | |
| 249 | repo_id: string; | |
| 250 | invitee: string | null; | |
| 251 | email: string | null; | |
| 252 | role: RepoRole; | |
| 253 | invited_by: string | null; | |
| 254 | /** The inviter's avatar hash, served at `/avatars/<avatar>`; null for the generated letter avatar. */ | |
| 255 | inviter_avatar?: string | null; | |
| 256 | status: RepoInvitationStatus; | |
| 257 | created_at: string; | |
| 258 | expires_at: string; | |
| 259 | }; | |
| 260 | ||
| 261 | export type RepoAccess = { | |
| 262 | repo: string; | |
| 263 | base_permission: BasePermission; | |
| 264 | people: Collaborator[]; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 265 | /** The workspace's teams given a role on it. */ |
| 266 | teams?: RepoTeam[]; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 267 | invitations: RepoInvitation[]; |
| 268 | viewer_role: RepoRole | null; | |
| 269 | can_manage: boolean; | |
| 270 | }; | |
| 271 | ||
| 272 | export type Added = | |
| 273 | | { result: "granted"; collaborator: Collaborator } | |
| 274 | | { result: "invited"; invitation: RepoInvitation }; | |
| 275 | ||
| 276 | export type PermissionInfo = { | |
| 277 | username: string; | |
| 278 | role: RepoRole | null; | |
| 279 | source: AccessSource | null; | |
| 280 | capabilities: Capability[]; | |
| 281 | }; | |
| 282 | ||
| 283 | export type OutsideCollaborator = { | |
| 284 | username: string; | |
| 285 | name: string | null; | |
| 286 | avatar: string | null; | |
| 287 | repos: { repo: string; role: RepoRole }[]; | |
| 288 | }; | |
| 289 | ||
| 290 | /** Identity's access methods, by repository path. */ | |
| 291 | export interface AccessClient { | |
| 292 | repoAccess(owner: string, name: string, viewer: User | null): Promise<Result<RepoAccess>>; | |
| 293 | addCollaborator(actor: User, owner: string, name: string, invitee: string, role: RepoRole): Promise<Result<Added>>; | |
| 294 | setCollaboratorRole(actor: User, owner: string, name: string, username: string, role: RepoRole): Promise<Result<Collaborator>>; | |
| 295 | removeCollaborator(actor: User, owner: string, name: string, username: string): Promise<Result<boolean>>; | |
| 296 | collaboratorPermission(viewer: User | null, owner: string, name: string, username: string): Promise<Result<PermissionInfo>>; | |
| 297 | myRepoInvitations(user: User): Promise<RepoInvitation[]>; | |
| 298 | respondRepoInvitation(user: User, id: string, accept: boolean): Promise<Result<RepoInvitation>>; | |
| 299 | revokeRepoInvitation(actor: User, owner: string, name: string, id: string): Promise<Result<RepoInvitation>>; | |
| 300 | setBasePermission(actor: User, slug: string, base: BasePermission): Promise<Result<BasePermission>>; | |
| 301 | outsideCollaborators(viewer: User | null, slug: string): Promise<Result<OutsideCollaborator[]>>; | |
| 302 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.