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