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
35function requireOAuthApp(): App {
36 if (!process.env.NUXT_GITHUB_APP_CLIENT_ID || !process.env.NUXT_GITHUB_APP_CLIENT_SECRET) {
37 throw createError({
38 statusCode: 500,
39 statusMessage: 'GitHub user-OAuth is not configured (set NUXT_GITHUB_APP_CLIENT_ID and NUXT_GITHUB_APP_CLIENT_SECRET)',
40 })
41 }
42 return useApp()
43}
44
45/**
46 * Build the GitHub user-to-server OAuth authorize URL. `state` is round-tripped
47 * back to the callback for CSRF protection; the callback verifies it against a
48 * sealed cookie.
49 */
50export function githubOAuthUrl(opts: { state: string, redirectUri: string }): string {
51 const app = requireOAuthApp()
52 const { url } = app.oauth.getWebFlowAuthorizationUrl({
53 state: opts.state,
54 redirectUrl: opts.redirectUri,
55 allowSignup: false,
56 })
57 return url
58}
59
60/**
61 * Exchange an OAuth `code` for a user access token and return an Octokit
62 * authenticated as that user.
63 */
64export async function userOctokitFromCode(code: string): Promise<UserOctokit> {
65 const app = requireOAuthApp()
66 return app.oauth.getUserOctokit({ code })
67}
68
69/**
70 * True if the authenticated user administers `installationId`. The
71 * user-to-server `GET /user/installations` endpoint only returns installations
72 * the user can administer, so membership in the list is the ownership proof.
73 */
74export async function userAdministersInstallation(
75 userOctokit: UserOctokit,
76 installationId: number,
77): Promise<boolean> {
78 // A user with >100 app installations is implausible here, so one page is
79 // enough; `/user/installations` only lists installs the user administers.
80 const { data } = await userOctokit.request('GET /user/installations', { per_page: 100 })
81 return data.installations.some(install => install.id === installationId)
82}
83
84/** Resolve an installation's account login (for display on the connect page). */
85export async function installationAccountLogin(installationId: number): Promise<string | null> {
86 const app = useApp()
87 try {
88 const { data } = await app.octokit.request('GET /app/installations/{installation_id}', {
89 installation_id: installationId,
90 })
91 const account = data.account
92 if (account && 'login' in account) return account.login
93 return null
94 }
95 catch {
96 return null
97 }
98}
99
100/** Test hook. */
101export function clearGitHubAppCache() {
102 cachedApp = undefined
103}