mirror your GitHub repos to tangled.org automatically
1import { App } from '@octokit/app'
2
3let cachedApp: App | undefined
4
5function useApp(): App {
6 if (cachedApp) return cachedApp
7 const appId = process.env.NUXT_GITHUB_APP_ID
8 const privateKey = process.env.NUXT_GITHUB_APP_PRIVATE_KEY
9 if (!appId || !privateKey) {
10 throw new Error('NUXT_GITHUB_APP_ID and NUXT_GITHUB_APP_PRIVATE_KEY must be set')
11 }
12 // The OAuth client id/secret are optional at construction time so the
13 // webhook + sync paths (server-to-server only) keep working without them;
14 // requireOAuthApp() throws explicitly when the /connect flow needs them.
15 const clientId = process.env.NUXT_GITHUB_APP_CLIENT_ID
16 const clientSecret = process.env.NUXT_GITHUB_APP_CLIENT_SECRET
17 cachedApp = new App({
18 appId,
19 ...(clientId && clientSecret ? { oauth: { clientId, clientSecret } } : {}),
20 // Vercel env vars escape newlines; restore them so PEM parsing works.
21 privateKey: privateKey.replaceAll('\\n', '\n'),
22 })
23 return cachedApp
24}
25
26export type InstallationOctokit = Awaited<ReturnType<App['getInstallationOctokit']>>
27export type UserOctokit = Awaited<ReturnType<App['oauth']['getUserOctokit']>>
28
29/** Get an Octokit pre-authed for a specific GitHub App installation. */
30export async function installationOctokit(installationId: number): Promise<InstallationOctokit> {
31 const app = useApp()
32 return app.getInstallationOctokit(installationId)
33}
34
35/** Mint a short-lived installation access token for authenticating git fetches. */
36export async function installationToken(octokit: InstallationOctokit): Promise<string> {
37 const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
38 return token
39}
40
41function requireOAuthApp(): App {
42 if (!process.env.NUXT_GITHUB_APP_CLIENT_ID || !process.env.NUXT_GITHUB_APP_CLIENT_SECRET) {
43 throw createError({
44 statusCode: 500,
45 statusMessage: 'GitHub user-OAuth is not configured (set NUXT_GITHUB_APP_CLIENT_ID and NUXT_GITHUB_APP_CLIENT_SECRET)',
46 })
47 }
48 return useApp()
49}
50
51/**
52 * Build the GitHub user-to-server OAuth authorize URL. `state` is round-tripped
53 * back to the callback for CSRF protection; the callback verifies it against a
54 * sealed cookie.
55 */
56export function githubOAuthUrl(opts: { state: string, redirectUri: string }): string {
57 const app = requireOAuthApp()
58 const { url } = app.oauth.getWebFlowAuthorizationUrl({
59 state: opts.state,
60 redirectUrl: opts.redirectUri,
61 allowSignup: false,
62 })
63 return url
64}
65
66/**
67 * Exchange an OAuth `code` for a user access token and return an Octokit
68 * authenticated as that user.
69 */
70export async function userOctokitFromCode(code: string): Promise<UserOctokit> {
71 const app = requireOAuthApp()
72 return app.oauth.getUserOctokit({ code })
73}
74
75/**
76 * True if the authenticated user administers `installationId`. The
77 * user-to-server `GET /user/installations` endpoint only returns installations
78 * the user can administer, so membership in the list is the ownership proof.
79 */
80export async function userAdministersInstallation(
81 userOctokit: UserOctokit,
82 installationId: number,
83): Promise<boolean> {
84 // A user with >100 app installations is implausible here, so one page is
85 // enough; `/user/installations` only lists installs the user administers.
86 const { data } = await userOctokit.request('GET /user/installations', { per_page: 100 })
87 return data.installations.some(install => install.id === installationId)
88}
89
90/** Resolve an installation's account login (for display on the connect page). */
91export async function installationAccountLogin(installationId: number): Promise<string | null> {
92 const app = useApp()
93 try {
94 const { data } = await app.octokit.request('GET /app/installations/{installation_id}', {
95 installation_id: installationId,
96 })
97 const account = data.account
98 if (account && 'login' in account) return account.login
99 return null
100 }
101 catch {
102 return null
103 }
104}
105
106/** Test hook. */
107export function clearGitHubAppCache() {
108 cachedApp = undefined
109}