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