Skip to content

g1t/packages/contracts/src/access.ts

255 lines10,937 bytesCodeBlame

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";
10import type { Result } from "./result";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar11import type { RepoTeam } from "./teams";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look12
13/** What someone may do in one repository, from least to most. */
14export type RepoRole = "read" | "triage" | "write" | "maintain" | "admin";
15
16export const REPO_ROLES: readonly RepoRole[] = ["read", "triage", "write", "maintain", "admin"];
17
18export const REPO_ROLE_LABELS: Record<RepoRole, string> = {
19 read: "Read",
20 triage: "Triage",
21 write: "Write",
22 maintain: "Maintain",
23 admin: "Admin",
24};
25
26/** One line on what each role is for, as role pickers show it. */
27export const REPO_ROLE_SUMMARIES: Record<RepoRole, string> = {
28 read: "Read and clone; open issues and pull requests, and comment.",
29 triage: "Read, and manage issues and pull requests: label, assign, close.",
30 write: "Triage, and push, merge, and put agents to work.",
31 maintain: "Write, and manage settings and branch protection.",
32 admin: "Everything: webhooks, secrets, deployments, access, name and visibility.",
33};
34
35/** What every member of a workspace gets on each of its repositories. */
36export type BasePermission = "none" | "read" | "write" | "admin";
37
38export const BASE_PERMISSIONS: readonly BasePermission[] = ["none", "read", "write", "admin"];
39
40/** Unless an owner changes it: what members could do before roles. */
41export const DEFAULT_BASE_PERMISSION: BasePermission = "write";
42
43export const BASE_PERMISSION_LABELS: Record<BasePermission, string> = {
44 none: "No permission",
45 read: "Read",
46 write: "Write",
47 admin: "Admin",
48};
49
50export type Capability =
51 | "read"
52 | "participate"
53 | "triage"
54 | "push"
55 | "merge"
56 | "run"
57 | "manage_settings"
58 | "manage_protection"
59 | "manage_integrations"
60 | "manage_access"
61 | "administer"
62 | "delete";
63
64/** The permission table: the least role for each capability. */
65export const CAPABILITIES = [
66 { capability: "read", role: "read", about: "See code, issues and pull requests; clone and fetch" },
67 { capability: "participate", role: "read", about: "Open issues and pull requests, and comment" },
68 { capability: "triage", role: "triage", about: "Label, assign, close and reopen issues and pull requests" },
69 { capability: "push", role: "write", about: "Push to branches that are not protected" },
70 { capability: "merge", role: "write", about: "Merge pull requests and use the merge queue" },
71 { capability: "run", role: "write", about: "Assign agents and start runs, plans and workflows" },
72 { capability: "manage_settings", role: "maintain", about: "Change the description, topics, and pull request and agent settings" },
73 { capability: "manage_protection", role: "maintain", about: "Change branch protection and guardrails" },
74 { capability: "manage_integrations", role: "admin", about: "Manage webhooks, secrets, variables, deployments and domains" },
75 { capability: "manage_access", role: "admin", about: "Manage who has access, and invitations" },
76 { capability: "administer", role: "admin", about: "Rename, archive, change visibility and the default branch" },
77 { capability: "delete", role: "admin", about: "Transfer or delete the repository (owners of the workspace only)" },
78] as const satisfies readonly { capability: Capability; role: RepoRole; about: string }[];
79
80/** Capabilities that also need an owner of the repository's workspace. */
81export const OWNER_ONLY = ["delete"] as const satisfies readonly Capability[];
82
83/** A person's role on one repository, given directly. */
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar84export type RepoGrant = {
85 repo_id: string;
86 workspace: string;
87 role: RepoRole;
88 /** The team it comes through, when it is a team's grant. */
89 team?: string;
90};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look91
92/** What `permission` needs to know about a repository. */
93export type RepoRef = { id: string; namespace: string; isPrivate: boolean };
94
95const rank = (role: RepoRole): number => REPO_ROLES.indexOf(role);
96
97/** The higher of two roles; null is lower than any. */
98export function maxRole(a: RepoRole | null | undefined, b: RepoRole | null | undefined): RepoRole | null {
99 if (!a) return b ?? null;
100 if (!b) return a;
101 return rank(a) >= rank(b) ? a : b;
102}
103
104export function leastRole(capability: Capability): RepoRole {
105 return CAPABILITIES.find((row) => row.capability === capability)?.role ?? "admin";
106}
107
108/** Whether `role` has `capability`, going by the table alone. */
109export function allows(role: RepoRole | null | undefined, capability: Capability): boolean {
110 return role != null && rank(role) >= rank(leastRole(capability));
111}
112
113export function baseRole(base: BasePermission | null | undefined): RepoRole | null {
114 const value = base ?? DEFAULT_BASE_PERMISSION;
115 return value === "none" ? null : value;
116}
117
118function membershipRole(user: User, membership: Membership): RepoRole | null {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily119 // A workspace's own token, and g1t acting in the workspace, do what an
120 // owner can on its repositories.
121 if (user.kind === "workspace" || user.kind === "system") return "admin";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look122 return membership.role === "owner" ? "admin" : baseRole(membership.base_permission);
123}
124
125/** The user's role on the repository, not counting that it may be public. */
126export function granted(user: User, repo: RepoRef): RepoRole | null {
127 const namespace = repo.namespace.toLowerCase();
128 const membership = user.workspaces?.find((m) => m.slug.toLowerCase() === namespace);
129 let role = membership ? membershipRole(user, membership) : null;
130 for (const grant of user.grants ?? []) {
131 if (grant.repo_id === repo.id) role = maxRole(role, grant.role);
132 }
133 return role;
134}
135
136/** The viewer's effective role on a repository; null means they may not see it. */
137export function permission(viewer: User | null | undefined, repo: RepoRef): RepoRole | null {
138 const role = viewer ? granted(viewer, repo) : null;
139 return repo.isPrivate ? role : maxRole(role, "read");
140}
141
142/** Whether the viewer may do `capability` in the repository. */
143export function can(viewer: User | null | undefined, repo: RepoRef, capability: Capability): boolean {
144 if (!allows(permission(viewer, repo), capability)) return false;
145 if ((OWNER_ONLY as readonly Capability[]).includes(capability)) {
146 const namespace = repo.namespace.toLowerCase();
147 return viewer?.workspaces?.find((m) => m.slug.toLowerCase() === namespace)?.role === "owner";
148 }
149 return true;
150}
151
152/** Every capability, true or false, for one viewer and repository: what pages pass to their components. */
153export type Abilities = Record<Capability, boolean>;
154
155export function abilities(viewer: User | null | undefined, repo: RepoRef): Abilities {
156 return Object.fromEntries(CAPABILITIES.map((row) => [row.capability, can(viewer, repo, row.capability)])) as Abilities;
157}
158
159/** The sentence shown beside something the viewer cannot use. */
160export function needs(capability: Capability): string {
161 if ((OWNER_ONLY as readonly Capability[]).includes(capability)) return "Only an owner of the workspace can do this.";
162 return `Needs the ${REPO_ROLE_LABELS[leastRole(capability)]} role or higher.`;
163}
164
165/** Whether the user belongs to the workspace or has a role on one of its repositories. */
166export function hasAccessIn(user: User | null | undefined, namespace: string): boolean {
167 const slug = namespace.toLowerCase();
168 return !!user && (!!user.workspaces?.some((m) => m.slug.toLowerCase() === slug) || !!user.grants?.some((g) => g.workspace.toLowerCase() === slug));
169}
170
171/** The workspaces where the user has repositories shared with them without being a member. */
172export function sharedWorkspaces(user: User | null | undefined): string[] {
173 if (!user) return [];
174 const member = new Set((user.workspaces ?? []).map((m) => m.slug));
175 return [...new Set((user.grants ?? []).map((g) => g.workspace).filter((slug) => !member.has(slug)))];
176}
177
178// --- Who has access ----------------------------------------------------------
179
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar180export type AccessSource = "owner" | "base" | "direct" | "team";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look181
182export type Collaborator = {
183 username: string;
184 name: string | null;
185 avatar: string | null;
186 role: RepoRole;
187 source: AccessSource;
188 direct: RepoRole | null;
189 /** Null for an outside collaborator. */
190 workspace_role: Role | null;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar191 /** The highest role a team gives them here, and that team's slug. */
192 team_role?: RepoRole | null;
193 team?: string | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look194};
195
196export type RepoInvitationStatus = "pending" | "accepted" | "declined" | "revoked" | "expired";
197
198export type RepoInvitation = {
199 id: string;
200 /** `workspace/name`. */
201 repo: string;
202 repo_id: string;
203 invitee: string | null;
204 email: string | null;
205 role: RepoRole;
206 invited_by: string | null;
207 /** The inviter's avatar hash, served at `/avatars/<avatar>`; null for the generated letter avatar. */
208 inviter_avatar?: string | null;
209 status: RepoInvitationStatus;
210 created_at: string;
211 expires_at: string;
212};
213
214export type RepoAccess = {
215 repo: string;
216 base_permission: BasePermission;
217 people: Collaborator[];
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar218 /** The workspace's teams given a role on it. */
219 teams?: RepoTeam[];
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look220 invitations: RepoInvitation[];
221 viewer_role: RepoRole | null;
222 can_manage: boolean;
223};
224
225export type Added =
226 | { result: "granted"; collaborator: Collaborator }
227 | { result: "invited"; invitation: RepoInvitation };
228
229export type PermissionInfo = {
230 username: string;
231 role: RepoRole | null;
232 source: AccessSource | null;
233 capabilities: Capability[];
234};
235
236export type OutsideCollaborator = {
237 username: string;
238 name: string | null;
239 avatar: string | null;
240 repos: { repo: string; role: RepoRole }[];
241};
242
243/** Identity's access methods, by repository path. */
244export interface AccessClient {
245 repoAccess(owner: string, name: string, viewer: User | null): Promise<Result<RepoAccess>>;
246 addCollaborator(actor: User, owner: string, name: string, invitee: string, role: RepoRole): Promise<Result<Added>>;
247 setCollaboratorRole(actor: User, owner: string, name: string, username: string, role: RepoRole): Promise<Result<Collaborator>>;
248 removeCollaborator(actor: User, owner: string, name: string, username: string): Promise<Result<boolean>>;
249 collaboratorPermission(viewer: User | null, owner: string, name: string, username: string): Promise<Result<PermissionInfo>>;
250 myRepoInvitations(user: User): Promise<RepoInvitation[]>;
251 respondRepoInvitation(user: User, id: string, accept: boolean): Promise<Result<RepoInvitation>>;
252 revokeRepoInvitation(actor: User, owner: string, name: string, id: string): Promise<Result<RepoInvitation>>;
253 setBasePermission(actor: User, slug: string, base: BasePermission): Promise<Result<BasePermission>>;
254 outsideCollaborators(viewer: User | null, slug: string): Promise<Result<OutsideCollaborator[]>>;
255}