@flink-app/github-app-plugin 0.12.1-alpha.38
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.
- package/CHANGELOG.md +209 -0
- package/LICENSE +21 -0
- package/README.md +667 -0
- package/SECURITY.md +498 -0
- package/dist/GitHubAppInternalContext.d.ts +44 -0
- package/dist/GitHubAppInternalContext.js +2 -0
- package/dist/GitHubAppPlugin.d.ts +45 -0
- package/dist/GitHubAppPlugin.js +367 -0
- package/dist/GitHubAppPluginContext.d.ts +242 -0
- package/dist/GitHubAppPluginContext.js +2 -0
- package/dist/GitHubAppPluginOptions.d.ts +369 -0
- package/dist/GitHubAppPluginOptions.js +2 -0
- package/dist/handlers/InitiateInstallation.d.ts +32 -0
- package/dist/handlers/InitiateInstallation.js +66 -0
- package/dist/handlers/InstallationCallback.d.ts +42 -0
- package/dist/handlers/InstallationCallback.js +248 -0
- package/dist/handlers/UninstallHandler.d.ts +37 -0
- package/dist/handlers/UninstallHandler.js +153 -0
- package/dist/handlers/WebhookHandler.d.ts +54 -0
- package/dist/handlers/WebhookHandler.js +157 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +23 -0
- package/dist/repos/GitHubAppSessionRepo.d.ts +24 -0
- package/dist/repos/GitHubAppSessionRepo.js +32 -0
- package/dist/repos/GitHubInstallationRepo.d.ts +53 -0
- package/dist/repos/GitHubInstallationRepo.js +83 -0
- package/dist/repos/GitHubWebhookEventRepo.d.ts +29 -0
- package/dist/repos/GitHubWebhookEventRepo.js +42 -0
- package/dist/schemas/GitHubAppSession.d.ts +13 -0
- package/dist/schemas/GitHubAppSession.js +2 -0
- package/dist/schemas/GitHubInstallation.d.ts +28 -0
- package/dist/schemas/GitHubInstallation.js +2 -0
- package/dist/schemas/InstallationCallbackRequest.d.ts +10 -0
- package/dist/schemas/InstallationCallbackRequest.js +2 -0
- package/dist/schemas/WebhookEvent.d.ts +16 -0
- package/dist/schemas/WebhookEvent.js +2 -0
- package/dist/schemas/WebhookPayload.d.ts +35 -0
- package/dist/schemas/WebhookPayload.js +2 -0
- package/dist/services/GitHubAPIClient.d.ts +143 -0
- package/dist/services/GitHubAPIClient.js +167 -0
- package/dist/services/GitHubAuthService.d.ts +85 -0
- package/dist/services/GitHubAuthService.js +160 -0
- package/dist/services/WebhookValidator.d.ts +93 -0
- package/dist/services/WebhookValidator.js +123 -0
- package/dist/utils/error-utils.d.ts +67 -0
- package/dist/utils/error-utils.js +121 -0
- package/dist/utils/jwt-utils.d.ts +35 -0
- package/dist/utils/jwt-utils.js +67 -0
- package/dist/utils/state-utils.d.ts +38 -0
- package/dist/utils/state-utils.js +74 -0
- package/dist/utils/token-cache-utils.d.ts +47 -0
- package/dist/utils/token-cache-utils.js +74 -0
- package/dist/utils/webhook-signature-utils.d.ts +22 -0
- package/dist/utils/webhook-signature-utils.js +57 -0
- package/examples/basic-installation.ts +246 -0
- package/examples/create-issue.ts +392 -0
- package/examples/error-handling.ts +396 -0
- package/examples/multi-event-webhook.ts +367 -0
- package/examples/organization-installation.ts +316 -0
- package/examples/repository-access.ts +480 -0
- package/examples/webhook-handling.ts +343 -0
- package/examples/with-jwt-auth.ts +319 -0
- package/package.json +41 -0
- package/spec/core-utilities.spec.ts +243 -0
- package/spec/handlers.spec.ts +216 -0
- package/spec/helpers/reporter.ts +41 -0
- package/spec/integration-and-security.spec.ts +483 -0
- package/spec/plugin-core.spec.ts +258 -0
- package/spec/project-setup.spec.ts +56 -0
- package/spec/repos-and-schemas.spec.ts +288 -0
- package/spec/services.spec.ts +108 -0
- package/spec/support/jasmine.json +7 -0
- package/src/GitHubAppPlugin.ts +411 -0
- package/src/GitHubAppPluginContext.ts +254 -0
- package/src/GitHubAppPluginOptions.ts +412 -0
- package/src/handlers/InstallationCallback.ts +292 -0
- package/src/handlers/WebhookHandler.ts +179 -0
- package/src/index.ts +29 -0
- package/src/repos/GitHubAppSessionRepo.ts +36 -0
- package/src/repos/GitHubInstallationRepo.ts +95 -0
- package/src/repos/GitHubWebhookEventRepo.ts +48 -0
- package/src/schemas/GitHubAppSession.ts +13 -0
- package/src/schemas/GitHubInstallation.ts +28 -0
- package/src/schemas/InstallationCallbackRequest.ts +10 -0
- package/src/schemas/WebhookEvent.ts +16 -0
- package/src/schemas/WebhookPayload.ts +35 -0
- package/src/services/GitHubAPIClient.ts +244 -0
- package/src/services/GitHubAuthService.ts +188 -0
- package/src/services/WebhookValidator.ts +159 -0
- package/src/utils/error-utils.ts +148 -0
- package/src/utils/jwt-utils.ts +64 -0
- package/src/utils/state-utils.ts +72 -0
- package/src/utils/token-cache-utils.ts +89 -0
- package/src/utils/webhook-signature-utils.ts +57 -0
- package/tsconfig.dist.json +4 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,292 @@
|
|
|
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;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App Webhook Handler
|
|
3
|
+
*
|
|
4
|
+
* Processes GitHub webhook events by:
|
|
5
|
+
* 1. Validating the webhook signature using HMAC-SHA256
|
|
6
|
+
* 2. Parsing the webhook payload
|
|
7
|
+
* 3. Optionally logging the event to the database
|
|
8
|
+
* 4. Calling the onWebhookEvent callback for processing
|
|
9
|
+
* 5. Returning 200 OK to GitHub
|
|
10
|
+
*
|
|
11
|
+
* Route: POST /github-app/webhook
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { FlinkContext, HttpMethod, RouteProps, log } from "@flink-app/flink";
|
|
15
|
+
import { GitHubAppPluginContext } from "../GitHubAppPluginContext";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Context with GitHub App Plugin
|
|
19
|
+
*
|
|
20
|
+
* Note: Using 'any' for simplicity. In a real-world scenario, you'd want to properly
|
|
21
|
+
* type the context with both FlinkContext and GitHubAppPluginContext including the repos.
|
|
22
|
+
*/
|
|
23
|
+
type WebhookHandlerContext = FlinkContext<GitHubAppPluginContext>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Route configuration
|
|
27
|
+
* Registered programmatically by the plugin if registerRoutes is enabled
|
|
28
|
+
*/
|
|
29
|
+
export const Route: RouteProps = {
|
|
30
|
+
path: "/github-app/webhook",
|
|
31
|
+
method: HttpMethod.post,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* GitHub App Webhook Handler
|
|
36
|
+
*
|
|
37
|
+
* Validates webhook signatures and processes GitHub webhook events.
|
|
38
|
+
*/
|
|
39
|
+
const WebhookHandler = async ({ ctx, req }: { ctx: WebhookHandlerContext; req: any }) => {
|
|
40
|
+
try {
|
|
41
|
+
// Extract headers
|
|
42
|
+
const signature = req.headers["x-hub-signature-256"] as string;
|
|
43
|
+
const event = req.headers["x-github-event"] as string;
|
|
44
|
+
const deliveryId = req.headers["x-github-delivery"] as string;
|
|
45
|
+
|
|
46
|
+
// Get raw request body
|
|
47
|
+
// In Express, if you use express.json(), the raw body is lost
|
|
48
|
+
// We need to access the raw body for signature validation
|
|
49
|
+
const rawBody = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
|
|
50
|
+
|
|
51
|
+
// Validate webhook signature using constant-time comparison
|
|
52
|
+
const { webhookValidator, options } = ctx.plugins.githubApp;
|
|
53
|
+
|
|
54
|
+
const isValid = webhookValidator.validateSignature(rawBody, signature);
|
|
55
|
+
|
|
56
|
+
if (!isValid) {
|
|
57
|
+
log.warn("GitHub webhook signature validation failed", {
|
|
58
|
+
event,
|
|
59
|
+
deliveryId,
|
|
60
|
+
hasSignature: !!signature,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
status: 401,
|
|
65
|
+
data: { error: "Invalid webhook signature" },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Parse webhook payload
|
|
70
|
+
let payload;
|
|
71
|
+
try {
|
|
72
|
+
payload = webhookValidator.parsePayload(rawBody);
|
|
73
|
+
} catch (error: any) {
|
|
74
|
+
log.error("Failed to parse webhook payload", {
|
|
75
|
+
event,
|
|
76
|
+
deliveryId,
|
|
77
|
+
error: error.message,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
status: 400,
|
|
82
|
+
data: { error: "Invalid webhook payload" },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Extract event data
|
|
87
|
+
const action = payload.action;
|
|
88
|
+
const installationId = webhookValidator.extractInstallationId(payload);
|
|
89
|
+
|
|
90
|
+
log.info("GitHub webhook received", {
|
|
91
|
+
event,
|
|
92
|
+
action,
|
|
93
|
+
installationId,
|
|
94
|
+
deliveryId,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Optionally log webhook event to database
|
|
98
|
+
if (options.logWebhookEvents && ctx.repos.githubWebhookEventRepo) {
|
|
99
|
+
try {
|
|
100
|
+
await ctx.repos.githubWebhookEventRepo.create({
|
|
101
|
+
installationId: installationId || 0,
|
|
102
|
+
event,
|
|
103
|
+
action,
|
|
104
|
+
deliveryId,
|
|
105
|
+
payload,
|
|
106
|
+
processed: false,
|
|
107
|
+
createdAt: new Date(),
|
|
108
|
+
});
|
|
109
|
+
} catch (error: any) {
|
|
110
|
+
// Don't fail the webhook if logging fails
|
|
111
|
+
log.error("Failed to log webhook event to database", {
|
|
112
|
+
event,
|
|
113
|
+
deliveryId,
|
|
114
|
+
error: error.message,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Call onWebhookEvent callback if provided
|
|
120
|
+
if (options.onWebhookEvent && installationId) {
|
|
121
|
+
try {
|
|
122
|
+
await options.onWebhookEvent(
|
|
123
|
+
{
|
|
124
|
+
event,
|
|
125
|
+
action,
|
|
126
|
+
payload,
|
|
127
|
+
installationId,
|
|
128
|
+
deliveryId,
|
|
129
|
+
},
|
|
130
|
+
ctx
|
|
131
|
+
);
|
|
132
|
+
} catch (error: any) {
|
|
133
|
+
// Log the error but still return 200 to GitHub to prevent retries
|
|
134
|
+
log.error("Error in onWebhookEvent callback", {
|
|
135
|
+
event,
|
|
136
|
+
action,
|
|
137
|
+
installationId,
|
|
138
|
+
deliveryId,
|
|
139
|
+
error: error.message,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Update webhook event log if enabled
|
|
143
|
+
if (options.logWebhookEvents && ctx.repos.githubWebhookEventRepo) {
|
|
144
|
+
try {
|
|
145
|
+
const webhookEvent = await ctx.repos.githubWebhookEventRepo.getOne({ deliveryId });
|
|
146
|
+
if (webhookEvent && webhookEvent._id) {
|
|
147
|
+
await ctx.repos.githubWebhookEventRepo.updateOne(webhookEvent._id, {
|
|
148
|
+
processed: true,
|
|
149
|
+
processedAt: new Date(),
|
|
150
|
+
error: error.message,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} catch (updateError) {
|
|
154
|
+
// Ignore errors updating the log
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Return 200 OK to GitHub to acknowledge receipt
|
|
161
|
+
return {
|
|
162
|
+
status: 200,
|
|
163
|
+
data: { received: true },
|
|
164
|
+
};
|
|
165
|
+
} catch (error: any) {
|
|
166
|
+
log.error("Unexpected error in webhook handler", {
|
|
167
|
+
error: error.message,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Still return 200 to GitHub to prevent infinite retries
|
|
171
|
+
// The error is logged for debugging
|
|
172
|
+
return {
|
|
173
|
+
status: 200,
|
|
174
|
+
data: { received: true, error: "Internal processing error" },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export default WebhookHandler;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App Plugin for Flink Framework
|
|
3
|
+
*
|
|
4
|
+
* Provides GitHub App integration with:
|
|
5
|
+
* - Installation management
|
|
6
|
+
* - JWT-based authentication
|
|
7
|
+
* - Webhook handling with signature validation
|
|
8
|
+
* - API client wrapper
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Plugin factory
|
|
12
|
+
export { githubAppPlugin } from './GitHubAppPlugin';
|
|
13
|
+
|
|
14
|
+
// Type exports
|
|
15
|
+
export type { GitHubAppPluginOptions, InstallationSuccessParams, InstallationSuccessResponse, InstallationErrorParams, InstallationErrorResponse, WebhookEventParams } from './GitHubAppPluginOptions';
|
|
16
|
+
export type { GitHubAppPluginContext } from './GitHubAppPluginContext';
|
|
17
|
+
|
|
18
|
+
// Schema exports
|
|
19
|
+
export type { default as GitHubInstallation } from './schemas/GitHubInstallation';
|
|
20
|
+
export type { default as GitHubAppSession } from './schemas/GitHubAppSession';
|
|
21
|
+
export type { default as WebhookEvent } from './schemas/WebhookEvent';
|
|
22
|
+
export type { default as WebhookPayload } from './schemas/WebhookPayload';
|
|
23
|
+
|
|
24
|
+
// Service exports (for advanced usage)
|
|
25
|
+
export { GitHubAPIClient, type Repository, type Content, type Issue, type CreateIssueParams } from './services/GitHubAPIClient';
|
|
26
|
+
export { GitHubAuthService } from './services/GitHubAuthService';
|
|
27
|
+
|
|
28
|
+
// Error utilities
|
|
29
|
+
export { GitHubAppErrorCodes } from './utils/error-utils';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { FlinkRepo } from "@flink-app/flink";
|
|
2
|
+
import { Db } from "mongodb";
|
|
3
|
+
import GitHubAppSession from "../schemas/GitHubAppSession";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Repository for managing temporary GitHub App installation sessions with TTL.
|
|
7
|
+
* Sessions are used during the installation flow to prevent CSRF attacks.
|
|
8
|
+
*/
|
|
9
|
+
class GitHubAppSessionRepo extends FlinkRepo<any, GitHubAppSession> {
|
|
10
|
+
constructor(ctx: any, db: Db, collectionName: string = "github_app_sessions") {
|
|
11
|
+
super(collectionName, db);
|
|
12
|
+
this.ctx = ctx;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Find a GitHub App session by its unique session ID.
|
|
17
|
+
* @param sessionId - The unique session identifier
|
|
18
|
+
* @returns The session if found, null otherwise
|
|
19
|
+
*/
|
|
20
|
+
async findBySessionId(sessionId: string): Promise<GitHubAppSession | null> {
|
|
21
|
+
return this.getOne({ sessionId });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Delete a GitHub App session by its unique session ID.
|
|
26
|
+
* Used after the installation callback is processed to prevent session reuse.
|
|
27
|
+
* @param sessionId - The unique session identifier
|
|
28
|
+
* @returns The number of deleted sessions (0 or 1)
|
|
29
|
+
*/
|
|
30
|
+
async deleteBySessionId(sessionId: string): Promise<number> {
|
|
31
|
+
const { deletedCount } = await this.collection.deleteOne({ sessionId });
|
|
32
|
+
return deletedCount || 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default GitHubAppSessionRepo;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { FlinkRepo } from "@flink-app/flink";
|
|
2
|
+
import { Db } from "mongodb";
|
|
3
|
+
import GitHubInstallation from "../schemas/GitHubInstallation";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Repository for managing GitHub App installations.
|
|
7
|
+
* Stores installation-to-user mappings and repository access information.
|
|
8
|
+
*/
|
|
9
|
+
class GitHubInstallationRepo extends FlinkRepo<any, GitHubInstallation> {
|
|
10
|
+
constructor(ctx: any, db: Db, collectionName: string = "github_installations") {
|
|
11
|
+
super(collectionName, db);
|
|
12
|
+
this.ctx = ctx;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Find an installation by user ID and installation ID.
|
|
17
|
+
* @param userId - The application user ID
|
|
18
|
+
* @param installationId - The GitHub installation ID
|
|
19
|
+
* @returns The installation if found, null otherwise
|
|
20
|
+
*/
|
|
21
|
+
async findByUserAndInstallationId(userId: string, installationId: number): Promise<GitHubInstallation | null> {
|
|
22
|
+
return this.getOne({ userId, installationId });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find all installations for a specific user.
|
|
27
|
+
* @param userId - The application user ID
|
|
28
|
+
* @returns Array of installations for the user
|
|
29
|
+
*/
|
|
30
|
+
async findByUserId(userId: string): Promise<GitHubInstallation[]> {
|
|
31
|
+
return this.findAll({ userId });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Find an installation by its GitHub installation ID.
|
|
36
|
+
* @param installationId - The GitHub installation ID
|
|
37
|
+
* @returns The installation if found, null otherwise
|
|
38
|
+
*/
|
|
39
|
+
async findByInstallationId(installationId: number): Promise<GitHubInstallation | null> {
|
|
40
|
+
return this.getOne({ installationId });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Update the repositories list for an installation.
|
|
45
|
+
* @param installationId - The GitHub installation ID
|
|
46
|
+
* @param repositories - The new repositories list
|
|
47
|
+
* @returns The updated installation
|
|
48
|
+
*/
|
|
49
|
+
async updateRepositories(installationId: number, repositories: GitHubInstallation["repositories"]): Promise<GitHubInstallation | null> {
|
|
50
|
+
const installation = await this.getOne({ installationId });
|
|
51
|
+
if (!installation) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const updated = await this.updateOne(installation._id!, {
|
|
56
|
+
repositories,
|
|
57
|
+
updatedAt: new Date(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return updated;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Suspend an installation.
|
|
65
|
+
* @param installationId - The GitHub installation ID
|
|
66
|
+
* @param suspendedBy - Information about who suspended the installation
|
|
67
|
+
* @returns The updated installation
|
|
68
|
+
*/
|
|
69
|
+
async suspend(installationId: number, suspendedBy: { id: number; login: string }): Promise<GitHubInstallation | null> {
|
|
70
|
+
const installation = await this.getOne({ installationId });
|
|
71
|
+
if (!installation) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const updated = await this.updateOne(installation._id!, {
|
|
76
|
+
suspendedAt: new Date(),
|
|
77
|
+
suspendedBy,
|
|
78
|
+
updatedAt: new Date(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return updated;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Delete an installation by its GitHub installation ID.
|
|
86
|
+
* @param installationId - The GitHub installation ID
|
|
87
|
+
* @returns The number of deleted installations (0 or 1)
|
|
88
|
+
*/
|
|
89
|
+
async deleteByInstallationId(installationId: number): Promise<number> {
|
|
90
|
+
const { deletedCount } = await this.collection.deleteOne({ installationId });
|
|
91
|
+
return deletedCount || 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default GitHubInstallationRepo;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { FlinkRepo } from "@flink-app/flink";
|
|
2
|
+
import { Db } from "mongodb";
|
|
3
|
+
import WebhookEvent from "../schemas/WebhookEvent";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Repository for managing GitHub webhook events.
|
|
7
|
+
* Only used if logWebhookEvents config option is enabled.
|
|
8
|
+
* Provides webhook event logging for debugging and auditing.
|
|
9
|
+
*/
|
|
10
|
+
class GitHubWebhookEventRepo extends FlinkRepo<any, WebhookEvent> {
|
|
11
|
+
constructor(ctx: any, db: Db, collectionName: string = "github_webhook_events") {
|
|
12
|
+
super(collectionName, db);
|
|
13
|
+
this.ctx = ctx;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find all unprocessed webhook events.
|
|
18
|
+
* @returns Array of unprocessed events
|
|
19
|
+
*/
|
|
20
|
+
async findUnprocessed(): Promise<WebhookEvent[]> {
|
|
21
|
+
return this.findAll({ processed: false });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mark a webhook event as processed.
|
|
26
|
+
* @param eventId - The webhook event ID
|
|
27
|
+
* @returns The updated event
|
|
28
|
+
*/
|
|
29
|
+
async markProcessed(eventId: string): Promise<WebhookEvent | null> {
|
|
30
|
+
const updated = await this.updateOne(eventId, {
|
|
31
|
+
processed: true,
|
|
32
|
+
processedAt: new Date(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return updated;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find all webhook events for a specific installation.
|
|
40
|
+
* @param installationId - The GitHub installation ID
|
|
41
|
+
* @returns Array of webhook events for the installation
|
|
42
|
+
*/
|
|
43
|
+
async findByInstallationId(installationId: number): Promise<WebhookEvent[]> {
|
|
44
|
+
return this.findAll({ installationId });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default GitHubWebhookEventRepo;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporary session storage for GitHub App installation flow.
|
|
3
|
+
* Used for CSRF protection via state parameter.
|
|
4
|
+
* Sessions automatically expire after TTL (default 10 minutes).
|
|
5
|
+
*/
|
|
6
|
+
export default interface GitHubAppSession {
|
|
7
|
+
_id?: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
state: string;
|
|
10
|
+
userId?: string;
|
|
11
|
+
metadata?: Record<string, any>;
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
}
|