Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

314 linesCodeBlameRaw
1/**
2 * Access tokens on the site: what the token form posts, read back into
3 * what identity takes, and the words lists and pages use for a token.
4 *
5 * There is one kind of token. It belongs to you or to a workspace, has a
6 * level for each resource (its permissions, stored as scopes), a reach
7 * (the workspaces and repositories it is made for) and an expiry.
8 *
9 * The form posts `name`, `description`, `expires` (days, or `never`),
10 * `workspace` (`*` for every workspace you belong to, `-` for none, or a
11 * slug), `repository_selection`, one `repo` per chosen repository, and
12 * `perm.<resource>` for each resource's level, and `website` when a personal
13 * token may use the website as you. Every field is a form field,
14 * so the form posts the same with or without JavaScript.
15 */
16
17import {
18 MAX_SELECTED_REPOSITORIES,
19 MAX_TOKEN_LIFETIME_DAYS,
20 RESOURCE_GROUPS,
21 SCOPE_RESOURCES,
22 describeScope,
23 levelsOf,
24 permissionsOf,
25 scopesOfPermissions,
26 type Permissions,
27 type ResourceGroup,
28 type Scope,
29 type ScopeLevel,
30 type ScopeResource,
31} from "@g1t/contracts/scopes";
32import type { AccessToken, RepositorySelection, TokenChange, TokenInput, TokenPolicy, TokenStatus } from "@g1t/contracts";
33
34import type { FormLike, Parsed } from "./token-scopes";
35
36/** What the reach field posts for every workspace you belong to, and for none. */
37export const ALL_WORKSPACES = "*";
38export const NO_WORKSPACE = "-";
39
40/** Lifetimes the form offers, in days; the longest is the most a token with an expiry may last. */
41export const EXPIRY_DAYS = [7, 30, 60, 90, 180, MAX_TOKEN_LIFETIME_DAYS] as const;
42
43export const DEFAULT_EXPIRY_DAYS = 30;
44
45export function describeDays(days: number): string {
46 if (days === MAX_TOKEN_LIFETIME_DAYS) return "1 year";
47 return `${days} day${days === 1 ? "" : "s"}`;
48}
49
50/**
51 * The lifetimes the rules of the workspaces a token reaches leave, in days,
52 * longest last, and whether it may never expire.
53 */
54export function expiryChoices(policies: readonly (TokenPolicy | null | undefined)[]): { days: number[]; never: boolean } {
55 const limits = policies.map((policy) => policy?.maxLifetimeDays).filter((days): days is number => typeof days === "number");
56 const most = Math.min(MAX_TOKEN_LIFETIME_DAYS, ...limits);
57 const days: number[] = EXPIRY_DAYS.filter((choice) => choice <= most);
58 if (!days.includes(most)) days.push(most);
59 const never = limits.length === 0 && !policies.some((policy) => policy?.forbidNoExpiry);
60 return { days, never };
61}
62
63/** A new lifetime chosen in a form's `expires` (days, or `never`), as seconds; null for none, or an error. */
64export function ttlFromForm(form: FormData): { ok: true; value: number | null } | { ok: false; error: string } {
65 const expires = String(form.get("expires") ?? DEFAULT_EXPIRY_DAYS);
66 if (expires === "never") return { ok: true, value: null };
67 const days = Number(expires);
68 if (!Number.isInteger(days) || days < 1 || days > MAX_TOKEN_LIFETIME_DAYS) {
69 return { ok: false, error: `Choose when it expires: at most ${MAX_TOKEN_LIFETIME_DAYS} days, or never.` };
70 }
71 return { ok: true, value: days * 86_400 };
72}
73
74/** The resources a token can hold: a workspace's own token holds none about a person. */
75export function resourcesFor(workspaceOwned: boolean): { resource: ScopeResource; label: string; group: ResourceGroup }[] {
76 return SCOPE_RESOURCES.filter((row) => !workspaceOwned || row.group !== "account");
77}
78
79/** The form's groups, with their resources. */
80export function permissionGroups(workspaceOwned: boolean) {
81 const resources = resourcesFor(workspaceOwned);
82 return RESOURCE_GROUPS.map((group) => ({ ...group, resources: resources.filter((row) => row.group === group.group) })).filter(
83 (group) => group.resources.length > 0,
84 );
85}
86
87export function resourceLabel(resource: string): string {
88 return SCOPE_RESOURCES.find((row) => row.resource === resource)?.label ?? resource;
89}
90
91/** A level as the form names it: Read, Read and write, Admin… */
92export function levelLabel(resource: ScopeResource, level: ScopeLevel | "none"): string {
93 switch (level) {
94 case "none":
95 return "No access";
96 case "read":
97 return "Read";
98 case "create":
99 return "Read and create";
100 case "write":
101 return levelsOf(resource).includes("read") ? "Read and write" : "Write";
102 case "run":
103 return "Run";
104 case "delete":
105 return "Read, write and delete";
106 case "admin":
107 return "Admin";
108 }
109}
110
111/** What a resource's level lets a token do, in plain words. */
112export function levelAbout(resource: ScopeResource, level: ScopeLevel | "none"): string | null {
113 if (level === "none") return null;
114 return describeScope(`${resource}:${level}` as Scope);
115}
116
117/** Whether a level is hard to undo or decides who can reach what. */
118export function isDangerousLevel(level: ScopeLevel | "none"): boolean {
119 return level === "admin" || level === "delete";
120}
121
122/** A token's permissions: as identity sends them, or read from its scopes. */
123export function tokenPermissions(token: Pick<AccessToken, "scopes" | "permissions">): Permissions {
124 return token.permissions && Object.keys(token.permissions).length > 0 ? token.permissions : permissionsOf(token.scopes);
125}
126
127/**
128 * What the form posts, ready for identity, or what is wrong with it. In
129 * `editing`, the reach's workspace and the expiry are the token's own and
130 * are not read. `workspaceOwned` is a workspace's own token.
131 */
132export function tokenFromForm(form: FormLike, options: { editing?: boolean; workspaceOwned?: boolean; owner?: string | null } = {}): Parsed<TokenInput> {
133 const { editing = false, workspaceOwned = false } = options;
134 const name = String(form.get("name") ?? "").trim();
135 if (!editing && !name) return { ok: false, error: "Name the token after what will use it." };
136
137 let ttlSeconds: number | null = null;
138 if (!editing) {
139 const expires = String(form.get("expires") ?? DEFAULT_EXPIRY_DAYS);
140 if (expires !== "never") {
141 const days = Number(expires);
142 if (!Number.isInteger(days) || days < 1 || days > MAX_TOKEN_LIFETIME_DAYS) {
143 return { ok: false, error: `Choose when it expires: at most ${MAX_TOKEN_LIFETIME_DAYS} days, or never.` };
144 }
145 ttlSeconds = days * 86_400;
146 }
147 }
148
149 // Where it reaches.
150 const reach = String(form.get("workspace") ?? ALL_WORKSPACES)
151 .trim()
152 .toLowerCase();
153 let workspace: string | null = null;
154 let repositorySelection: RepositorySelection = "all";
155 const posted = String(form.get("repository_selection") ?? "");
156 const selection = (["all", "selected", "public"] as const).find((value) => value === posted);
157 if (workspaceOwned) {
158 repositorySelection = selection === "selected" ? "selected" : "all";
159 } else if (reach === NO_WORKSPACE) {
160 repositorySelection = "public";
161 } else if (reach === ALL_WORKSPACES || reach === "") {
162 repositorySelection = "all";
163 } else {
164 workspace = reach;
165 repositorySelection = selection ?? "all";
166 }
167 const repositories = [
168 ...new Set(
169 form
170 .getAll("repo")
171 .map((repo) => String(repo).trim())
172 .filter(Boolean),
173 ),
174 ];
175 if (repositorySelection === "selected") {
176 if (repositories.length === 0) return { ok: false, error: "Choose at least one repository, or all repositories." };
177 if (repositories.length > MAX_SELECTED_REPOSITORIES) {
178 return { ok: false, error: `A token can reach at most ${MAX_SELECTED_REPOSITORIES} selected repositories.` };
179 }
180 }
181
182 // What it may do there.
183 const permissions: Permissions = {};
184 for (const { resource, label } of resourcesFor(workspaceOwned)) {
185 const level = String(form.get(`perm.${resource}`) ?? "none");
186 if (level === "none" || level === "") continue;
187 if (!levelsOf(resource).includes(level as ScopeLevel)) return { ok: false, error: `${label} cannot be ${level}.` };
188 permissions[resource] = level as ScopeLevel;
189 }
190 if (Object.keys(permissions).length === 0) return { ok: false, error: "Give the token at least one permission." };
191
192 return {
193 ok: true,
194 value: {
195 owner: options.owner ?? null,
196 name,
197 description: String(form.get("description") ?? "").trim() || null,
198 ttlSeconds,
199 workspace,
200 repositorySelection,
201 repositories: repositorySelection === "selected" ? repositories : [],
202 permissions,
203 // A person's token only: a workspace's acts as no one who signs in.
204 website: !workspaceOwned && ["on", "true", "1"].includes(String(form.get("website") ?? "")),
205 },
206 };
207}
208
209/** Where a token reaches, in a few words: "All your workspaces", "acme · 2 repositories". */
210export function reachSummary(token: Pick<AccessToken, "workspace" | "repositorySelection" | "repositories" | "workspaceOwned">): string {
211 const selection = token.repositorySelection ?? "all";
212 const count = token.repositories?.length ?? 0;
213 const some = count === 1 ? "1 repository" : `${count} repositories`;
214 if (token.workspaceOwned) return selection === "selected" ? some : "All repositories";
215 if (!token.workspace) return selection === "public" ? "Your account and public repositories" : "All your workspaces";
216 switch (selection) {
217 case "all":
218 return `${token.workspace} · all repositories`;
219 case "public":
220 return `${token.workspace} · no private repositories`;
221 case "selected":
222 return `${token.workspace} · ${some}`;
223 }
224}
225
226/** A token's permissions as short chips, in the form's order: "Issues: write". */
227export function permissionChips(token: Pick<AccessToken, "scopes" | "permissions">): { label: string; dangerous: boolean }[] {
228 const permissions = tokenPermissions(token);
229 return SCOPE_RESOURCES.filter(({ resource }) => permissions[resource]).map(({ resource, label }) => {
230 const level = permissions[resource]!;
231 return { label: `${label}: ${level}`, dangerous: isDangerousLevel(level) };
232 });
233}
234
235/** A status as a badge's words and tone; null for an active token. */
236export function statusBadge(status: TokenStatus | undefined): { label: string; tone: "warn" | "danger" } | null {
237 switch (status) {
238 case "pending":
239 return { label: "Pending approval", tone: "warn" };
240 case "denied":
241 return { label: "Denied", tone: "danger" };
242 case "revoked":
243 return { label: "Revoked", tone: "danger" };
244 default:
245 return null;
246 }
247}
248
249/** What a workspace's rules say about a token made for it, for the form. */
250export function policyNote(slug: string, policy: TokenPolicy | null | undefined, owner: boolean): string | null {
251 if (!policy) return null;
252 if (!policy.allowTokensForThisWorkspace) return `${slug} does not allow tokens made for it.`;
253 if (policy.requireApproval && !owner) {
254 return `An owner of ${slug} must approve this token before it reaches the workspace. Until then it reads public repositories only.`;
255 }
256 return null;
257}
258
259/** The workspaces whose rules keep out a token made for all of yours, with why. */
260export function keptOutOfAll(choices: readonly { slug: string; policy: TokenPolicy | null }[]): string[] {
261 return choices.filter((choice) => choice.policy && !choice.policy.allowTokensForAllWorkspaces).map((choice) => choice.slug);
262}
263
264/** Days a policy's lifetime field holds: a number, or null for no limit. */
265export function lifetimeFromForm(value: unknown): Parsed<number | null> {
266 const text = String(value ?? "").trim();
267 if (text === "" || text === "none") return { ok: true, value: null };
268 const days = Number(text);
269 if (!Number.isInteger(days) || days < 1 || days > 3650) return { ok: false, error: "The longest lifetime is a whole number of days, 1 to 3650, or none." };
270 return { ok: true, value: days };
271}
272
273/**
274 * Where an old address of the token settings goes now: `?edit=<id>` to the
275 * token's page, and `?tab=` (the two kinds tokens used to come in) to the
276 * list. Null when the address is current.
277 */
278export function currentTokensPath(base: string, search: URLSearchParams): string | null {
279 const edit = search.get("edit");
280 if (edit && /^tok_[A-Za-z0-9]+$/.test(edit)) return `${base}/${edit}`;
281 if (search.has("tab") || search.has("edit") || search.has("kind")) return base;
282 return null;
283}
284
285const sameNames = (a: readonly string[] | undefined, b: readonly string[] | undefined) =>
286 [...(a ?? [])]
287 .map((name) => name.toLowerCase())
288 .sort()
289 .join(" ") ===
290 [...(b ?? [])]
291 .map((name) => name.toLowerCase())
292 .sort()
293 .join(" ");
294
295/**
296 * What the edit form changes about a token, and nothing else: a token made
297 * for a workspace that approves tokens asks again only when its
298 * repositories or permissions really change.
299 */
300export function changesTo(token: AccessToken, input: TokenInput): TokenChange {
301 const change: TokenChange = {};
302 if (input.name && input.name !== token.name) change.name = input.name;
303 if ((input.description ?? "") !== (token.description ?? "")) change.description = input.description ?? "";
304 if (scopesOfPermissions(input.permissions).join(" ") !== scopesOfPermissions(tokenPermissions(token)).join(" ")) {
305 change.permissions = input.permissions;
306 }
307 if (!token.workspaceOwned && Boolean(input.website) !== Boolean(token.website)) change.website = Boolean(input.website);
308 if (input.repositorySelection !== (token.repositorySelection ?? "all")) change.repositorySelection = input.repositorySelection;
309 if (input.repositorySelection === "selected" && (change.repositorySelection || !sameNames(input.repositories, token.repositories))) {
310 change.repositorySelection = "selected";
311 change.repositories = input.repositories;
312 }
313 return change;
314}