Skip to content
288 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/** The resources a token can hold: a workspace's own token holds none about a person. */
64export function resourcesFor(workspaceOwned: boolean): { resource: ScopeResource; label: string; group: ResourceGroup }[] {
65 return SCOPE_RESOURCES.filter((row) => !workspaceOwned || row.group !== "account");
66}
67
68/** The form's groups, with their resources. */
69export function permissionGroups(workspaceOwned: boolean) {
70 const resources = resourcesFor(workspaceOwned);
71 return RESOURCE_GROUPS.map((group) => ({ ...group, resources: resources.filter((row) => row.group === group.group) })).filter(
72 (group) => group.resources.length > 0,
73 );
74}
75
76export function resourceLabel(resource: string): string {
77 return SCOPE_RESOURCES.find((row) => row.resource === resource)?.label ?? resource;
78}
79
80/** A level as the form names it: Read, Read and write, Admin… */
81export function levelLabel(resource: ScopeResource, level: ScopeLevel | "none"): string {
82 switch (level) {
83 case "none":
84 return "No access";
85 case "read":
86 return "Read";
87 case "write":
88 return levelsOf(resource).includes("read") ? "Read and write" : "Write";
89 case "run":
90 return "Run";
91 case "delete":
92 return "Read, write and delete";
93 case "admin":
94 return "Admin";
95 }
96}
97
98/** What a resource's level lets a token do, in plain words. */
99export function levelAbout(resource: ScopeResource, level: ScopeLevel | "none"): string | null {
100 if (level === "none") return null;
101 return describeScope(`${resource}:${level}` as Scope);
102}
103
104/** Whether a level is hard to undo or decides who can reach what. */
105export function isDangerousLevel(level: ScopeLevel | "none"): boolean {
106 return level === "admin" || level === "delete";
107}
108
109/** A token's permissions: as identity sends them, or read from its scopes. */
110export function tokenPermissions(token: Pick<AccessToken, "scopes" | "permissions">): Permissions {
111 return token.permissions && Object.keys(token.permissions).length > 0 ? token.permissions : permissionsOf(token.scopes);
112}
113
114/**
115 * What the form posts, ready for identity, or what is wrong with it. In
116 * `editing`, the reach's workspace and the expiry are the token's own and
117 * are not read. `workspaceOwned` is a workspace's own token.
118 */
119export function tokenFromForm(
120 form: FormLike,
121 options: { editing?: boolean; workspaceOwned?: boolean; owner?: string | null } = {},
122): Parsed<TokenInput> {
123 const { editing = false, workspaceOwned = false } = options;
124 const name = String(form.get("name") ?? "").trim();
125 if (!editing && !name) return { ok: false, error: "Name the token after what will use it." };
126
127 let ttlSeconds: number | null = null;
128 if (!editing) {
129 const expires = String(form.get("expires") ?? DEFAULT_EXPIRY_DAYS);
130 if (expires !== "never") {
131 const days = Number(expires);
132 if (!Number.isInteger(days) || days < 1 || days > MAX_TOKEN_LIFETIME_DAYS) {
133 return { ok: false, error: `Choose when it expires: at most ${MAX_TOKEN_LIFETIME_DAYS} days, or never.` };
134 }
135 ttlSeconds = days * 86_400;
136 }
137 }
138
139 // Where it reaches.
140 const reach = String(form.get("workspace") ?? ALL_WORKSPACES).trim().toLowerCase();
141 let workspace: string | null = null;
142 let repositorySelection: RepositorySelection = "all";
143 const posted = String(form.get("repository_selection") ?? "");
144 const selection = (["all", "selected", "public"] as const).find((value) => value === posted);
145 if (workspaceOwned) {
146 repositorySelection = selection === "selected" ? "selected" : "all";
147 } else if (reach === NO_WORKSPACE) {
148 repositorySelection = "public";
149 } else if (reach === ALL_WORKSPACES || reach === "") {
150 repositorySelection = "all";
151 } else {
152 workspace = reach;
153 repositorySelection = selection ?? "all";
154 }
155 const repositories = [...new Set(form.getAll("repo").map((repo) => String(repo).trim()).filter(Boolean))];
156 if (repositorySelection === "selected") {
157 if (repositories.length === 0) return { ok: false, error: "Choose at least one repository, or all repositories." };
158 if (repositories.length > MAX_SELECTED_REPOSITORIES) {
159 return { ok: false, error: `A token can reach at most ${MAX_SELECTED_REPOSITORIES} selected repositories.` };
160 }
161 }
162
163 // What it may do there.
164 const permissions: Permissions = {};
165 for (const { resource, label } of resourcesFor(workspaceOwned)) {
166 const level = String(form.get(`perm.${resource}`) ?? "none");
167 if (level === "none" || level === "") continue;
168 if (!levelsOf(resource).includes(level as ScopeLevel)) return { ok: false, error: `${label} cannot be ${level}.` };
169 permissions[resource] = level as ScopeLevel;
170 }
171 if (Object.keys(permissions).length === 0) return { ok: false, error: "Give the token at least one permission." };
172
173 return {
174 ok: true,
175 value: {
176 owner: options.owner ?? null,
177 name,
178 description: String(form.get("description") ?? "").trim() || null,
179 ttlSeconds,
180 workspace,
181 repositorySelection,
182 repositories: repositorySelection === "selected" ? repositories : [],
183 permissions,
184 // A person's token only: a workspace's acts as no one who signs in.
185 website: !workspaceOwned && ["on", "true", "1"].includes(String(form.get("website") ?? "")),
186 },
187 };
188}
189
190/** Where a token reaches, in a few words: "All your workspaces", "acme · 2 repositories". */
191export function reachSummary(token: Pick<AccessToken, "workspace" | "repositorySelection" | "repositories" | "workspaceOwned">): string {
192 const selection = token.repositorySelection ?? "all";
193 const count = token.repositories?.length ?? 0;
194 const some = count === 1 ? "1 repository" : `${count} repositories`;
195 if (token.workspaceOwned) return selection === "selected" ? some : "All repositories";
196 if (!token.workspace) return selection === "public" ? "Your account and public repositories" : "All your workspaces";
197 switch (selection) {
198 case "all":
199 return `${token.workspace} · all repositories`;
200 case "public":
201 return `${token.workspace} · no private repositories`;
202 case "selected":
203 return `${token.workspace} · ${some}`;
204 }
205}
206
207/** A token's permissions as short chips, in the form's order: "Issues: write". */
208export function permissionChips(token: Pick<AccessToken, "scopes" | "permissions">): { label: string; dangerous: boolean }[] {
209 const permissions = tokenPermissions(token);
210 return SCOPE_RESOURCES.filter(({ resource }) => permissions[resource]).map(({ resource, label }) => {
211 const level = permissions[resource]!;
212 return { label: `${label}: ${level}`, dangerous: isDangerousLevel(level) };
213 });
214}
215
216/** A status as a badge's words and tone; null for an active token. */
217export function statusBadge(status: TokenStatus | undefined): { label: string; tone: "warn" | "danger" } | null {
218 switch (status) {
219 case "pending":
220 return { label: "Pending approval", tone: "warn" };
221 case "denied":
222 return { label: "Denied", tone: "danger" };
223 case "revoked":
224 return { label: "Revoked", tone: "danger" };
225 default:
226 return null;
227 }
228}
229
230/** What a workspace's rules say about a token made for it, for the form. */
231export function policyNote(slug: string, policy: TokenPolicy | null | undefined, owner: boolean): string | null {
232 if (!policy) return null;
233 if (!policy.allowTokensForThisWorkspace) return `${slug} does not allow tokens made for it.`;
234 if (policy.requireApproval && !owner) {
235 return `An owner of ${slug} must approve this token before it reaches the workspace. Until then it reads public repositories only.`;
236 }
237 return null;
238}
239
240/** The workspaces whose rules keep out a token made for all of yours, with why. */
241export function keptOutOfAll(choices: readonly { slug: string; policy: TokenPolicy | null }[]): string[] {
242 return choices.filter((choice) => choice.policy && !choice.policy.allowTokensForAllWorkspaces).map((choice) => choice.slug);
243}
244
245/** Days a policy's lifetime field holds: a number, or null for no limit. */
246export function lifetimeFromForm(value: unknown): Parsed<number | null> {
247 const text = String(value ?? "").trim();
248 if (text === "" || text === "none") return { ok: true, value: null };
249 const days = Number(text);
250 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." };
251 return { ok: true, value: days };
252}
253
254/**
255 * Where an old address of the token settings goes now: `?edit=<id>` to the
256 * token's page, and `?tab=` (the two kinds tokens used to come in) to the
257 * list. Null when the address is current.
258 */
259export function currentTokensPath(base: string, search: URLSearchParams): string | null {
260 const edit = search.get("edit");
261 if (edit && /^tok_[A-Za-z0-9]+$/.test(edit)) return `${base}/${edit}`;
262 if (search.has("tab") || search.has("edit") || search.has("kind")) return base;
263 return null;
264}
265
266const sameNames = (a: readonly string[] | undefined, b: readonly string[] | undefined) =>
267 [...(a ?? [])].map((name) => name.toLowerCase()).sort().join(" ") === [...(b ?? [])].map((name) => name.toLowerCase()).sort().join(" ");
268
269/**
270 * What the edit form changes about a token, and nothing else: a token made
271 * for a workspace that approves tokens asks again only when its
272 * repositories or permissions really change.
273 */
274export function changesTo(token: AccessToken, input: TokenInput): TokenChange {
275 const change: TokenChange = {};
276 if (input.name && input.name !== token.name) change.name = input.name;
277 if ((input.description ?? "") !== (token.description ?? "")) change.description = input.description ?? "";
278 if (scopesOfPermissions(input.permissions).join(" ") !== scopesOfPermissions(tokenPermissions(token)).join(" ")) {
279 change.permissions = input.permissions;
280 }
281 if (!token.workspaceOwned && Boolean(input.website) !== Boolean(token.website)) change.website = Boolean(input.website);
282 if (input.repositorySelection !== (token.repositorySelection ?? "all")) change.repositorySelection = input.repositorySelection;
283 if (input.repositorySelection === "selected" && (change.repositorySelection || !sameNames(input.repositories, token.repositories))) {
284 change.repositorySelection = "selected";
285 change.repositories = input.repositories;
286 }
287 return change;
288}