| 1 | /** |
| 2 | * The parts of signing in with GitHub and installing g1t's GitHub App that |
| 3 | * the site itself decides: the cookies that bind a trip to GitHub to the |
| 4 | * browser that started it, and checking what comes back. |
| 5 | */ |
| 6 | |
| 7 | /** The state sent to GitHub to sign in or link, for ten minutes. */ |
| 8 | export const STATE_COOKIE = "g1t_github_state"; |
| 9 | /** A GitHub sign-in waiting on a username, or on signing in to link. */ |
| 10 | export const PENDING_COOKIE = "g1t_github_pending"; |
| 11 | /** A sign-in that gave the right password and waits for a two-factor code: identity's challenge. */ |
| 12 | export const TWO_FACTOR_COOKIE = "g1t_two_factor"; |
| 13 | /** How long that waits, in seconds, as identity's `TWO_FACTOR_CHALLENGE_SECONDS`. */ |
| 14 | export const TWO_FACTOR_SECONDS = 600; |
| 15 | /** An installation under way: its state and the workspace it is for. */ |
| 16 | export const INSTALL_COOKIE = "g1t_github_install"; |
| 17 | |
| 18 | /** The value of a cookie, or null. Values here are hex and slugs only. */ |
| 19 | export function readCookie(header: string | null, name: string): string | null { |
| 20 | for (const part of (header ?? "").split(";")) { |
| 21 | const [key, ...rest] = part.trim().split("="); |
| 22 | if (key === name) { |
| 23 | const value = rest.join("="); |
| 24 | return /^[0-9a-z.-]{1,200}$/.test(value) ? value : null; |
| 25 | } |
| 26 | } |
| 27 | return null; |
| 28 | } |
| 29 | |
| 30 | /** A `Set-Cookie` value: HttpOnly, Secure, same-site Lax; 0 clears it. */ |
| 31 | export function cookie(name: string, value: string, maxAge: number): string { |
| 32 | return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; |
| 33 | } |
| 34 | |
| 35 | /** Compares two strings in time that does not depend on where they differ. */ |
| 36 | export function sameString(a: string, b: string): boolean { |
| 37 | if (a.length !== b.length) return false; |
| 38 | let difference = 0; |
| 39 | for (let i = 0; i < a.length; i++) difference |= a.charCodeAt(i) ^ b.charCodeAt(i); |
| 40 | return difference === 0; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Whether the state GitHub sent back is the one this browser was given. |
| 45 | * Both must be present: a callback without the cookie came from somewhere |
| 46 | * else. |
| 47 | */ |
| 48 | export function stateMatches(fromCookie: string | null, fromGithub: string | null): boolean { |
| 49 | if (!fromCookie || !fromGithub) return false; |
| 50 | return sameString(fromCookie, fromGithub); |
| 51 | } |
| 52 | |
| 53 | /** The install cookie's value: `<state>.<workspace>`. */ |
| 54 | export function installCookieValue(state: string, workspace: string): string { |
| 55 | return `${state}.${workspace}`; |
| 56 | } |
| 57 | |
| 58 | /** The workspace an installation was started for, if the state matches. */ |
| 59 | export function installWorkspace(fromCookie: string | null, fromGithub: string | null): string | null { |
| 60 | if (!fromCookie) return null; |
| 61 | const dot = fromCookie.indexOf("."); |
| 62 | if (dot < 0) return null; |
| 63 | const state = fromCookie.slice(0, dot); |
| 64 | const workspace = fromCookie.slice(dot + 1); |
| 65 | return stateMatches(state, fromGithub) && workspace ? workspace : null; |
| 66 | } |
| 67 | |
| 68 | /** A fresh random state: 32 bytes, hex. */ |
| 69 | export function newState(): string { |
| 70 | const bytes = new Uint8Array(32); |
| 71 | crypto.getRandomValues(bytes); |
| 72 | return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); |
| 73 | } |
| 74 | |
| 75 | /** An installation as GitHub lists it to the person, with the workspaces of theirs that have it. */ |
| 76 | export type SeenInstallation = { |
| 77 | id: number; |
| 78 | account: string; |
| 79 | accountType: string; |
| 80 | repositorySelection: string; |
| 81 | suspended: boolean; |
| 82 | recordedIn: string[]; |
| 83 | }; |
| 84 | |
| 85 | /** |
| 86 | * The installations the person can see on GitHub that `workspace` has not |
| 87 | * added yet: the app installed on GitHub directly, or from a return that |
| 88 | * lost g1t's state. |
| 89 | */ |
| 90 | export function notYetAdded<T extends SeenInstallation>(seen: T[], workspace: string): T[] { |
| 91 | return seen.filter((item) => !item.recordedIn.includes(workspace)); |
| 92 | } |
| 93 | |
| 94 | /** How an installation is described in a list: `Organization · all repositories`. */ |
| 95 | export function installationSummary(item: { accountType: string; repositorySelection: string }): string { |
| 96 | const kind = item.accountType === "Organization" ? "Organization" : "Personal"; |
| 97 | return `${kind} · ${item.repositorySelection === "all" ? "all repositories" : "selected repositories"}`; |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * The workspaces an installation can be added to from the setup page: the |
| 102 | * ones the person owns, each saying whether it has the installation already. |
| 103 | */ |
| 104 | export function setupChoices( |
| 105 | workspaces: { slug: string; name?: string | null; role: string }[], |
| 106 | installation: SeenInstallation | null, |
| 107 | ): { slug: string; name: string; added: boolean }[] { |
| 108 | return workspaces |
| 109 | .filter((membership) => membership.role === "owner") |
| 110 | .map((membership) => ({ |
| 111 | slug: membership.slug, |
| 112 | name: membership.name ?? membership.slug, |
| 113 | added: installation?.recordedIn.includes(membership.slug) ?? false, |
| 114 | })); |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * What the Integrations directory and the Marketplace say about GitHub |
| 119 | * from the installations a workspace has recorded: nothing until there is |
| 120 | * one, then the accounts it is on and any that GitHub has suspended. |
| 121 | */ |
| 122 | export function githubConnected( |
| 123 | installations: { account: string; suspended: boolean }[], |
| 124 | ): { detail: string; problem: string | null; manage: null } | null { |
| 125 | if (installations.length === 0) return null; |
| 126 | const suspended = installations.find((installation) => installation.suspended); |
| 127 | return { |
| 128 | detail: `On ${installations.map((installation) => installation.account).join(", ")}`, |
| 129 | problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null, |
| 130 | manage: null, |
| 131 | }; |
| 132 | } |
| 133 | |
| 134 | /** What each way of bringing a repository across does, for the picker. */ |
| 135 | export const MODES = [ |
| 136 | { |
| 137 | id: "import", |
| 138 | title: "Import", |
| 139 | text: "Copy it once: every branch and tag, and its issues if you like. The copy on g1t is then its own.", |
| 140 | }, |
| 141 | { |
| 142 | id: "mirror", |
| 143 | title: "Standby mirror", |
| 144 | text: "g1t keeps a read-only copy that follows GitHub. Take over whenever you need to work here.", |
| 145 | }, |
| 146 | { |
| 147 | id: "push", |
| 148 | title: "Move to g1t", |
| 149 | text: "g1t leads; GitHub follows every push.", |
| 150 | }, |
| 151 | ] as const; |