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.

301 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(
133 form: FormLike,
134 options: { editing?: boolean; workspaceOwned?: boolean; owner?: string | null } = {},
135): Parsed<TokenInput> {
136 const { editing = false, workspaceOwned = false } = options;
137 const name = String(form.get("name") ?? "").trim();
138 if (!editing && !name) return { ok: false, error: "Name the token after what will use it." };
139
140 let ttlSeconds: number | null = null;
141 if (!editing) {
142 const expires = String(form.get("expires") ?? DEFAULT_EXPIRY_DAYS);
143 if (expires !== "never") {
144 const days = Number(expires);
145 if (!Number.isInteger(days) || days < 1 || days > MAX_TOKEN_LIFETIME_DAYS) {
146 return { ok: false, error: `Choose when it expires: at most ${MAX_TOKEN_LIFETIME_DAYS} days, or never.` };
147 }
148 ttlSeconds = days * 86_400;
149 }
150 }
151
152 // Where it reaches.
153 const reach = String(form.get("workspace") ?? ALL_WORKSPACES).trim().toLowerCase();
154 let workspace: string | null = null;
155 let repositorySelection: RepositorySelection = "all";
156 const posted = String(form.get("repository_selection") ?? "");
157 const selection = (["all", "selected", "public"] as const).find((value) => value === posted);
158 if (workspaceOwned) {
159 repositorySelection = selection === "selected" ? "selected" : "all";
160 } else if (reach === NO_WORKSPACE) {
161 repositorySelection = "public";
162 } else if (reach === ALL_WORKSPACES || reach === "") {
163 repositorySelection = "all";
164 } else {
165 workspace = reach;
166 repositorySelection = selection ?? "all";
167 }
168 const repositories = [...new Set(form.getAll("repo").map((repo) => String(repo).trim()).filter(Boolean))];
169 if (repositorySelection === "selected") {
170 if (repositories.length === 0) return { ok: false, error: "Choose at least one repository, or all repositories." };
171 if (repositories.length > MAX_SELECTED_REPOSITORIES) {
172 return { ok: false, error: `A token can reach at most ${MAX_SELECTED_REPOSITORIES} selected repositories.` };
173 }
174 }
175
176 // What it may do there.
177 const permissions: Permissions = {};
178 for (const { resource, label } of resourcesFor(workspaceOwned)) {
179 const level = String(form.get(`perm.${resource}`) ?? "none");
180 if (level === "none" || level === "") continue;
181 if (!levelsOf(resource).includes(level as ScopeLevel)) return { ok: false, error: `${label} cannot be ${level}.` };
182 permissions[resource] = level as ScopeLevel;
183 }
184 if (Object.keys(permissions).length === 0) return { ok: false, error: "Give the token at least one permission." };
185
186 return {
187 ok: true,
188 value: {
189 owner: options.owner ?? null,
190 name,
191 description: String(form.get("description") ?? "").trim() || null,
192 ttlSeconds,
193 workspace,
194 repositorySelection,
195 repositories: repositorySelection === "selected" ? repositories : [],
196 permissions,
197 // A person's token only: a workspace's acts as no one who signs in.
198 website: !workspaceOwned && ["on", "true", "1"].includes(String(form.get("website") ?? "")),
199 },
200 };
201}
202
203/** Where a token reaches, in a few words: "All your workspaces", "acme · 2 repositories". */
204export function reachSummary(token: Pick<AccessToken, "workspace" | "repositorySelection" | "repositories" | "workspaceOwned">): string {
205 const selection = token.repositorySelection ?? "all";
206 const count = token.repositories?.length ?? 0;
207 const some = count === 1 ? "1 repository" : `${count} repositories`;
208 if (token.workspaceOwned) return selection === "selected" ? some : "All repositories";
209 if (!token.workspace) return selection === "public" ? "Your account and public repositories" : "All your workspaces";
210 switch (selection) {
211 case "all":
212 return `${token.workspace} · all repositories`;
213 case "public":
214 return `${token.workspace} · no private repositories`;
215 case "selected":
216 return `${token.workspace} · ${some}`;
217 }
218}
219
220/** A token's permissions as short chips, in the form's order: "Issues: write". */
221export function permissionChips(token: Pick<AccessToken, "scopes" | "permissions">): { label: string; dangerous: boolean }[] {
222 const permissions = tokenPermissions(token);
223 return SCOPE_RESOURCES.filter(({ resource }) => permissions[resource]).map(({ resource, label }) => {
224 const level = permissions[resource]!;
225 return { label: `${label}: ${level}`, dangerous: isDangerousLevel(level) };
226 });
227}
228
229/** A status as a badge's words and tone; null for an active token. */
230export function statusBadge(status: TokenStatus | undefined): { label: string; tone: "warn" | "danger" } | null {
231 switch (status) {
232 case "pending":
233 return { label: "Pending approval", tone: "warn" };
234 case "denied":
235 return { label: "Denied", tone: "danger" };
236 case "revoked":
237 return { label: "Revoked", tone: "danger" };
238 default:
239 return null;
240 }
241}
242
243/** What a workspace's rules say about a token made for it, for the form. */
244export function policyNote(slug: string, policy: TokenPolicy | null | undefined, owner: boolean): string | null {
245 if (!policy) return null;
246 if (!policy.allowTokensForThisWorkspace) return `${slug} does not allow tokens made for it.`;
247 if (policy.requireApproval && !owner) {
248 return `An owner of ${slug} must approve this token before it reaches the workspace. Until then it reads public repositories only.`;
249 }
250 return null;
251}
252
253/** The workspaces whose rules keep out a token made for all of yours, with why. */
254export function keptOutOfAll(choices: readonly { slug: string; policy: TokenPolicy | null }[]): string[] {
255 return choices.filter((choice) => choice.policy && !choice.policy.allowTokensForAllWorkspaces).map((choice) => choice.slug);
256}
257
258/** Days a policy's lifetime field holds: a number, or null for no limit. */
259export function lifetimeFromForm(value: unknown): Parsed<number | null> {
260 const text = String(value ?? "").trim();
261 if (text === "" || text === "none") return { ok: true, value: null };
262 const days = Number(text);
263 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." };
264 return { ok: true, value: days };
265}
266
267/**
268 * Where an old address of the token settings goes now: `?edit=<id>` to the
269 * token's page, and `?tab=` (the two kinds tokens used to come in) to the
270 * list. Null when the address is current.
271 */
272export function currentTokensPath(base: string, search: URLSearchParams): string | null {
273 const edit = search.get("edit");
274 if (edit && /^tok_[A-Za-z0-9]+$/.test(edit)) return `${base}/${edit}`;
275 if (search.has("tab") || search.has("edit") || search.has("kind")) return base;
276 return null;
277}
278
279const sameNames = (a: readonly string[] | undefined, b: readonly string[] | undefined) =>
280 [...(a ?? [])].map((name) => name.toLowerCase()).sort().join(" ") === [...(b ?? [])].map((name) => name.toLowerCase()).sort().join(" ");
281
282/**
283 * What the edit form changes about a token, and nothing else: a token made
284 * for a workspace that approves tokens asks again only when its
285 * repositories or permissions really change.
286 */
287export function changesTo(token: AccessToken, input: TokenInput): TokenChange {
288 const change: TokenChange = {};
289 if (input.name && input.name !== token.name) change.name = input.name;
290 if ((input.description ?? "") !== (token.description ?? "")) change.description = input.description ?? "";
291 if (scopesOfPermissions(input.permissions).join(" ") !== scopesOfPermissions(tokenPermissions(token)).join(" ")) {
292 change.permissions = input.permissions;
293 }
294 if (!token.workspaceOwned && Boolean(input.website) !== Boolean(token.website)) change.website = Boolean(input.website);
295 if (input.repositorySelection !== (token.repositorySelection ?? "all")) change.repositorySelection = input.repositorySelection;
296 if (input.repositorySelection === "selected" && (change.repositorySelection || !sameNames(input.repositories, token.repositories))) {
297 change.repositorySelection = "selected";
298 change.repositories = input.repositories;
299 }
300 return change;
301}