@flink-app/github-app-plugin 0.12.1-alpha.38 → 0.12.1-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Installation Service
3
+ *
4
+ * Handles GitHub App installation completion flow including:
5
+ * - State validation (CSRF protection)
6
+ * - Fetching installation details from GitHub API
7
+ * - Storing installation in database
8
+ */
9
+
10
+ import { log } from "@flink-app/flink";
11
+ import { GitHubAuthService } from "./GitHubAuthService";
12
+ import GitHubAppSessionRepo from "../repos/GitHubAppSessionRepo";
13
+ import GitHubInstallationRepo from "../repos/GitHubInstallationRepo";
14
+ import GitHubInstallation from "../schemas/GitHubInstallation";
15
+ import { createGitHubAppError, GitHubAppErrorCodes, handleGitHubAPIError } from "../utils/error-utils";
16
+ import { validateState } from "../utils/state-utils";
17
+
18
+ /**
19
+ * Installation details from GitHub API
20
+ */
21
+ interface GitHubInstallationDetails {
22
+ id: number;
23
+ account: {
24
+ id: number;
25
+ login: string;
26
+ type: "User" | "Organization";
27
+ avatar_url?: string;
28
+ };
29
+ permissions?: Record<string, string>;
30
+ events?: string[];
31
+ }
32
+
33
+ /**
34
+ * Repository details from GitHub API
35
+ */
36
+ interface GitHubRepository {
37
+ id: number;
38
+ name: string;
39
+ full_name: string;
40
+ private: boolean;
41
+ }
42
+
43
+ /**
44
+ * Result of completing installation
45
+ */
46
+ export interface CompleteInstallationResult {
47
+ success: boolean;
48
+ installation?: GitHubInstallation;
49
+ error?: {
50
+ code: string;
51
+ message: string;
52
+ details?: any;
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Installation Service
58
+ *
59
+ * Provides methods for completing GitHub App installation flow.
60
+ * Host applications call these methods from their own handlers with
61
+ * their own authentication and authorization logic.
62
+ */
63
+ export class InstallationService {
64
+ constructor(
65
+ private authService: GitHubAuthService,
66
+ private sessionRepo: GitHubAppSessionRepo,
67
+ private installationRepo: GitHubInstallationRepo,
68
+ private baseUrl: string = "https://api.github.com"
69
+ ) {}
70
+
71
+ /**
72
+ * Complete GitHub App installation
73
+ *
74
+ * This method handles all the boilerplate of:
75
+ * 1. Validating the state parameter (CSRF protection)
76
+ * 2. Fetching installation details from GitHub API
77
+ * 3. Storing the installation in database
78
+ *
79
+ * The host application should:
80
+ * - Check if user is authenticated
81
+ * - Parse query parameters from the callback URL
82
+ * - Call this method with userId
83
+ * - Handle the response and redirect
84
+ *
85
+ * @param params - Installation completion parameters
86
+ * @param params.installationId - GitHub installation ID from query params
87
+ * @param params.state - State parameter from query params (CSRF protection)
88
+ * @param params.userId - Application user ID (from auth system)
89
+ * @returns Result with installation details or error
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * // In your custom handler
94
+ * export default async function GitHubCallback({ ctx, req }: GetHandlerParams) {
95
+ * // 1. Check authentication (your way)
96
+ * if (!ctx.auth?.tokenData?.userId) {
97
+ * return unauthorized('Please log in first');
98
+ * }
99
+ *
100
+ * // 2. Parse query params
101
+ * const { installation_id, state } = req.query;
102
+ *
103
+ * // 3. Complete installation
104
+ * const result = await ctx.plugins.githubApp.completeInstallation({
105
+ * installationId: parseInt(installation_id),
106
+ * state,
107
+ * userId: ctx.auth.tokenData.userId
108
+ * });
109
+ *
110
+ * // 4. Handle response (your way)
111
+ * if (!result.success) {
112
+ * return redirect(`/error?code=${result.error.code}`);
113
+ * }
114
+ *
115
+ * return redirect('/dashboard/github');
116
+ * }
117
+ * ```
118
+ */
119
+ async completeInstallation(params: {
120
+ installationId: number;
121
+ state: string;
122
+ userId: string;
123
+ }): Promise<CompleteInstallationResult> {
124
+ const { installationId, state, userId } = params;
125
+
126
+ try {
127
+ // 1. Find session by state
128
+ const session = await this.sessionRepo.getOne({ state });
129
+
130
+ if (!session) {
131
+ return {
132
+ success: false,
133
+ error: {
134
+ code: GitHubAppErrorCodes.SESSION_EXPIRED,
135
+ message: "Installation session not found or expired. Please try again.",
136
+ details: { state: state.substring(0, 10) + "..." },
137
+ },
138
+ };
139
+ }
140
+
141
+ // 2. Validate state parameter (CSRF protection)
142
+ if (!validateState(state, session.state)) {
143
+ return {
144
+ success: false,
145
+ error: {
146
+ code: GitHubAppErrorCodes.INVALID_STATE,
147
+ message: "Invalid state parameter. Possible CSRF attack detected.",
148
+ details: { providedState: state.substring(0, 10) + "..." },
149
+ },
150
+ };
151
+ }
152
+
153
+ // 3. Delete session immediately after validation (one-time use)
154
+ await this.sessionRepo.deleteBySessionId(session.sessionId);
155
+
156
+ // 4. Generate GitHub App JWT
157
+ const jwt = this.authService.generateAppJWT();
158
+
159
+ // 5. Fetch installation details from GitHub API
160
+ const installationDetails = await this.fetchInstallationDetails(installationId, jwt);
161
+
162
+ // 6. Fetch repositories accessible by this installation
163
+ const repositories = await this.fetchInstallationRepositories(installationId, jwt);
164
+
165
+ // 7. Store installation in database
166
+ const installation = await this.installationRepo.create({
167
+ userId,
168
+ installationId,
169
+ accountId: installationDetails.account.id,
170
+ accountLogin: installationDetails.account.login,
171
+ accountType: installationDetails.account.type,
172
+ avatarUrl: installationDetails.account.avatar_url,
173
+ repositories: repositories.map((repo) => ({
174
+ id: repo.id,
175
+ name: repo.name,
176
+ fullName: repo.full_name,
177
+ private: repo.private,
178
+ })),
179
+ permissions: installationDetails.permissions || {},
180
+ events: installationDetails.events || [],
181
+ createdAt: new Date(),
182
+ updatedAt: new Date(),
183
+ });
184
+
185
+ log.info("GitHub App installation completed", {
186
+ userId,
187
+ installationId,
188
+ accountLogin: installationDetails.account.login,
189
+ repositoryCount: repositories.length,
190
+ });
191
+
192
+ return {
193
+ success: true,
194
+ installation,
195
+ };
196
+ } catch (error: any) {
197
+ log.error("GitHub App installation completion failed", {
198
+ userId,
199
+ installationId,
200
+ error: error.message,
201
+ });
202
+
203
+ return {
204
+ success: false,
205
+ error: {
206
+ code: error.code || GitHubAppErrorCodes.SERVER_ERROR,
207
+ message: error.message || "Installation completion failed. Please try again.",
208
+ details: error.details,
209
+ },
210
+ };
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Fetch installation details from GitHub API
216
+ *
217
+ * @param installationId - GitHub installation ID
218
+ * @param jwt - GitHub App JWT
219
+ * @returns Installation details
220
+ * @private
221
+ */
222
+ private async fetchInstallationDetails(installationId: number, jwt: string): Promise<GitHubInstallationDetails> {
223
+ const url = `${this.baseUrl}/app/installations/${installationId}`;
224
+
225
+ const response = await fetch(url, {
226
+ method: "GET",
227
+ headers: {
228
+ Authorization: `Bearer ${jwt}`,
229
+ Accept: "application/vnd.github+json",
230
+ "User-Agent": "Flink-GitHub-App-Plugin",
231
+ },
232
+ });
233
+
234
+ if (!response.ok) {
235
+ const errorBody = await response.text();
236
+ throw createGitHubAppError(
237
+ GitHubAppErrorCodes.SERVER_ERROR,
238
+ `Failed to fetch installation details: ${response.statusText}`,
239
+ { status: response.status, body: errorBody }
240
+ );
241
+ }
242
+
243
+ return (await response.json()) as GitHubInstallationDetails;
244
+ }
245
+
246
+ /**
247
+ * Fetch repositories accessible by installation
248
+ *
249
+ * @param installationId - GitHub installation ID
250
+ * @param jwt - GitHub App JWT
251
+ * @returns Array of repositories
252
+ * @private
253
+ */
254
+ private async fetchInstallationRepositories(installationId: number, jwt: string): Promise<GitHubRepository[]> {
255
+ const url = `${this.baseUrl}/installation/repositories`;
256
+
257
+ // First get an installation token
258
+ const tokenUrl = `${this.baseUrl}/app/installations/${installationId}/access_tokens`;
259
+ const tokenResponse = await fetch(tokenUrl, {
260
+ method: "POST",
261
+ headers: {
262
+ Authorization: `Bearer ${jwt}`,
263
+ Accept: "application/vnd.github+json",
264
+ "User-Agent": "Flink-GitHub-App-Plugin",
265
+ },
266
+ });
267
+
268
+ if (!tokenResponse.ok) {
269
+ const errorBody = await tokenResponse.text();
270
+ throw createGitHubAppError(
271
+ GitHubAppErrorCodes.TOKEN_EXCHANGE_FAILED,
272
+ `Failed to get installation token: ${tokenResponse.statusText}`,
273
+ { status: tokenResponse.status, body: errorBody }
274
+ );
275
+ }
276
+
277
+ const tokenData: any = await tokenResponse.json();
278
+ const token = tokenData.token;
279
+
280
+ // Now fetch repositories with the installation token
281
+ const reposResponse = await fetch(url, {
282
+ method: "GET",
283
+ headers: {
284
+ Authorization: `Bearer ${token}`,
285
+ Accept: "application/vnd.github+json",
286
+ "User-Agent": "Flink-GitHub-App-Plugin",
287
+ },
288
+ });
289
+
290
+ if (!reposResponse.ok) {
291
+ const errorBody = await reposResponse.text();
292
+ throw createGitHubAppError(
293
+ GitHubAppErrorCodes.SERVER_ERROR,
294
+ `Failed to fetch repositories: ${reposResponse.statusText}`,
295
+ { status: reposResponse.status, body: errorBody }
296
+ );
297
+ }
298
+
299
+ const data: any = await reposResponse.json();
300
+ return data.repositories || [];
301
+ }
302
+ }
@@ -1,292 +0,0 @@
1
- /**
2
- * GitHub App Installation Callback Handler
3
- *
4
- * Handles the callback from GitHub after app installation by:
5
- * 1. Validating the state parameter to prevent CSRF attacks
6
- * 2. Fetching installation details from GitHub API
7
- * 3. Calling the onInstallationSuccess callback to link installation to user
8
- * 4. Storing the installation in the database
9
- * 5. Redirecting to the application
10
- *
11
- * Route: GET /github-app/callback?installation_id=...&setup_action=...&state=...
12
- */
13
-
14
- import { badRequest, HttpMethod, internalServerError, log, RouteProps } from "@flink-app/flink";
15
- import { createGitHubAppError, GitHubAppErrorCodes } from "../utils/error-utils";
16
- import { validateState } from "../utils/state-utils";
17
-
18
- /**
19
- * Context with GitHub App Plugin
20
- *
21
- * Note: Using 'any' for simplicity. In a real-world scenario, you'd want to properly
22
- * type the context with both FlinkContext and GitHubAppPluginContext including the repos.
23
- */
24
- type InstallationCallbackContext = any;
25
-
26
- /**
27
- * Route configuration
28
- * Registered programmatically by the plugin if registerRoutes is enabled
29
- */
30
- export const Route: RouteProps = {
31
- path: "/github-app/callback",
32
- method: HttpMethod.get,
33
- };
34
-
35
- /**
36
- * GitHub App Installation Callback Handler
37
- *
38
- * Completes the installation flow by validating state, fetching installation
39
- * details, calling the app's callback, and storing the installation.
40
- */
41
- const InstallationCallback = async ({ ctx, req }: { ctx: InstallationCallbackContext; req: any }) => {
42
- const { installation_id, setup_action, state, code } = req.query;
43
-
44
- try {
45
- // Validate required parameters
46
- if (!installation_id || !setup_action || !state) {
47
- return badRequest("Missing required parameters: installation_id, setup_action, or state");
48
- }
49
-
50
- // Find installation session by state
51
- const session = await ctx.repos.githubAppSessionRepo.getOne({ state });
52
-
53
- if (!session) {
54
- const error = createGitHubAppError(GitHubAppErrorCodes.SESSION_EXPIRED, "Installation session not found or expired. Please try again.", {
55
- state: state.substring(0, 10) + "...",
56
- });
57
-
58
- // Call onInstallationError callback if provided
59
- const { options } = ctx.plugins.githubApp;
60
- if (options.onInstallationError) {
61
- const errorResult = await options.onInstallationError({ error, installationId: installation_id });
62
- if (errorResult.redirectUrl) {
63
- return {
64
- status: 302,
65
- headers: { Location: errorResult.redirectUrl },
66
- data: {},
67
- };
68
- }
69
- }
70
-
71
- return badRequest(error.message);
72
- }
73
-
74
- // Validate state parameter using constant-time comparison (CSRF protection)
75
- if (!validateState(state, session.state)) {
76
- const error = createGitHubAppError(GitHubAppErrorCodes.INVALID_STATE, "Invalid state parameter. Possible CSRF attack detected.", {
77
- providedState: state.substring(0, 10) + "...",
78
- });
79
-
80
- // Call onInstallationError callback if provided
81
- const { options } = ctx.plugins.githubApp;
82
- if (options.onInstallationError) {
83
- const errorResult = await options.onInstallationError({ error, installationId: installation_id });
84
- if (errorResult.redirectUrl) {
85
- return {
86
- status: 302,
87
- headers: { Location: errorResult.redirectUrl },
88
- data: {},
89
- };
90
- }
91
- }
92
-
93
- return badRequest(error.message);
94
- }
95
-
96
- // Delete session immediately after validation (one-time use)
97
- await ctx.repos.githubAppSessionRepo.deleteBySessionId(session.sessionId);
98
-
99
- // Get plugin options and auth service
100
- const { options, authService } = ctx.plugins.githubApp;
101
-
102
- // Generate GitHub App JWT
103
- const jwt = authService.generateAppJWT();
104
-
105
- // Fetch installation details from GitHub API
106
- const installationIdNum = parseInt(installation_id, 10);
107
- const installationDetails = await fetchInstallationDetails(installationIdNum, jwt, options.baseUrl);
108
-
109
- // Fetch repositories accessible by this installation
110
- const repositories = await fetchInstallationRepositories(installationIdNum, jwt, options.baseUrl);
111
-
112
- // Call onInstallationSuccess callback to get userId and redirect URL
113
- let callbackResult;
114
- try {
115
- callbackResult = await options.onInstallationSuccess(
116
- {
117
- installationId: installationIdNum,
118
- setupAction: setup_action,
119
- account: installationDetails.account,
120
- repositories: repositories,
121
- permissions: installationDetails.permissions || {},
122
- events: installationDetails.events || [],
123
- },
124
- ctx
125
- );
126
- } catch (error: any) {
127
- log.error("GitHub App onInstallationSuccess callback failed:", error);
128
-
129
- const callbackError = createGitHubAppError(GitHubAppErrorCodes.SERVER_ERROR, "Failed to complete installation. Please try again.", {
130
- originalError: error.message,
131
- });
132
-
133
- // Call onInstallationError callback if provided
134
- if (options.onInstallationError) {
135
- const errorResult = await options.onInstallationError({
136
- error: callbackError,
137
- installationId: installation_id,
138
- });
139
- if (errorResult.redirectUrl) {
140
- return {
141
- status: 302,
142
- headers: { Location: errorResult.redirectUrl },
143
- data: {},
144
- };
145
- }
146
- }
147
-
148
- return internalServerError("Installation failed. Please try again.");
149
- }
150
-
151
- // Extract userId and redirectUrl from callback result
152
- const { userId, redirectUrl } = callbackResult;
153
-
154
- if (!userId) {
155
- return badRequest("onInstallationSuccess callback must return userId");
156
- }
157
-
158
- // Store installation in database
159
- await ctx.repos.githubInstallationRepo.create({
160
- userId,
161
- installationId: installationIdNum,
162
- accountId: installationDetails.account.id,
163
- accountLogin: installationDetails.account.login,
164
- accountType: installationDetails.account.type,
165
- avatarUrl: installationDetails.account.avatar_url,
166
- repositories: repositories.map((repo: any) => ({
167
- id: repo.id,
168
- name: repo.name,
169
- fullName: repo.full_name,
170
- private: repo.private,
171
- })),
172
- permissions: installationDetails.permissions || {},
173
- events: installationDetails.events || [],
174
- createdAt: new Date(),
175
- updatedAt: new Date(),
176
- });
177
-
178
- // Redirect to app using callback's redirectUrl or default to root
179
- const finalRedirectUrl = redirectUrl || "/";
180
-
181
- return {
182
- status: 302,
183
- headers: {
184
- Location: finalRedirectUrl,
185
- },
186
- data: {},
187
- };
188
- } catch (error: any) {
189
- log.error("GitHub App installation callback error:", error);
190
-
191
- // Call onInstallationError callback if provided
192
- const { options } = ctx.plugins.githubApp;
193
- if (options.onInstallationError) {
194
- try {
195
- const errorResult = await options.onInstallationError({
196
- error: error,
197
- installationId: installation_id,
198
- });
199
- if (errorResult.redirectUrl) {
200
- return {
201
- status: 302,
202
- headers: { Location: errorResult.redirectUrl },
203
- data: {},
204
- };
205
- }
206
- } catch (callbackError) {
207
- log.error("onInstallationError callback failed:", callbackError);
208
- }
209
- }
210
-
211
- return internalServerError(error.message || "Installation callback failed. Please try again.");
212
- }
213
- };
214
-
215
- /**
216
- * Fetch installation details from GitHub API
217
- *
218
- * @param installationId - GitHub installation ID
219
- * @param jwt - GitHub App JWT
220
- * @param baseUrl - GitHub API base URL
221
- * @returns Installation details
222
- */
223
- async function fetchInstallationDetails(installationId: number, jwt: string, baseUrl: string = "https://api.github.com"): Promise<any> {
224
- const url = `${baseUrl}/app/installations/${installationId}`;
225
-
226
- const response = await fetch(url, {
227
- method: "GET",
228
- headers: {
229
- Authorization: `Bearer ${jwt}`,
230
- Accept: "application/vnd.github+json",
231
- "User-Agent": "Flink-GitHub-App-Plugin",
232
- },
233
- });
234
-
235
- if (!response.ok) {
236
- const errorBody = await response.text();
237
- throw new Error(`Failed to fetch installation details: ${response.statusText} - ${errorBody}`);
238
- }
239
-
240
- return await response.json();
241
- }
242
-
243
- /**
244
- * Fetch repositories accessible by installation
245
- *
246
- * @param installationId - GitHub installation ID
247
- * @param jwt - GitHub App JWT
248
- * @param baseUrl - GitHub API base URL
249
- * @returns Array of repositories
250
- */
251
- async function fetchInstallationRepositories(installationId: number, jwt: string, baseUrl: string = "https://api.github.com"): Promise<any[]> {
252
- const url = `${baseUrl}/installation/repositories`;
253
-
254
- // First get an installation token
255
- const tokenUrl = `${baseUrl}/app/installations/${installationId}/access_tokens`;
256
- const tokenResponse = await fetch(tokenUrl, {
257
- method: "POST",
258
- headers: {
259
- Authorization: `Bearer ${jwt}`,
260
- Accept: "application/vnd.github+json",
261
- "User-Agent": "Flink-GitHub-App-Plugin",
262
- },
263
- });
264
-
265
- if (!tokenResponse.ok) {
266
- const errorBody = await tokenResponse.text();
267
- throw new Error(`Failed to get installation token: ${tokenResponse.statusText} - ${errorBody}`);
268
- }
269
-
270
- const tokenData: any = await tokenResponse.json();
271
- const token = tokenData.token;
272
-
273
- // Now fetch repositories with the installation token
274
- const reposResponse = await fetch(url, {
275
- method: "GET",
276
- headers: {
277
- Authorization: `Bearer ${token}`,
278
- Accept: "application/vnd.github+json",
279
- "User-Agent": "Flink-GitHub-App-Plugin",
280
- },
281
- });
282
-
283
- if (!reposResponse.ok) {
284
- const errorBody = await reposResponse.text();
285
- throw new Error(`Failed to fetch repositories: ${reposResponse.statusText} - ${errorBody}`);
286
- }
287
-
288
- const data: any = await reposResponse.json();
289
- return data.repositories || [];
290
- }
291
-
292
- export default InstallationCallback;
@@ -1,10 +0,0 @@
1
- /**
2
- * Query parameters received from GitHub after app installation.
3
- */
4
- export default interface InstallationCallbackRequest {
5
- installation_id: string;
6
- setup_action: 'install' | 'update' | 'request';
7
- state: string;
8
- code?: string;
9
- [key: string]: string | undefined;
10
- }