@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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Services Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Tests for GitHubAuthService, GitHubAPIClient, and WebhookValidator
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { GitHubAuthService } from "../src/services/GitHubAuthService";
|
|
8
|
+
import { GitHubAPIClient } from "../src/services/GitHubAPIClient";
|
|
9
|
+
import { WebhookValidator } from "../src/services/WebhookValidator";
|
|
10
|
+
import crypto from "crypto";
|
|
11
|
+
|
|
12
|
+
describe("Services", () => {
|
|
13
|
+
// Test private key (PKCS#1 format, Base64 encoded)
|
|
14
|
+
const testPrivateKeyBase64 =
|
|
15
|
+
"LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb2dJQkFBS0NBUUVBcTZ1Ykh3d1BET1FNMitHaHJZUlN3OEs2UytKcXAzem5YRGFITU1JZzg5WTZ1MUcyClZibm9TNjA4RjYwNnVSWGt1WUx2MUh6aHRLczJaOU9MaGh0YW1aV05hZVVEVlZPY2hzQ21MbDVNaCt6MTFLd2gKSG8wcU1NOUxxTHNOL3RYZDJZMDE0TkVhQ2hEZjdEMjUxLzFEWFh4WkNJTGk2NHROYm03SXh3U3J3bDlzeFBJRApPQzZrZU50UEEybGEydEFmNlc0OUJ2MVlxcDBLYnhObnBkeG90SkZsMEZBRjRYeUJSeWF6Mi9VWTN0S1BybjcyCnF6Ti9QMDN4RzhkMlZPU3hpZ3grOWJ0cndFazhRSE5qUGxiR0s1dzI3NU9vMHF2NFhjVTIvUzNsMS9JLzVxeG8KQW1haEJJN0ZoWThxQWRWd0hheGN3NGRkR1VHMzhmSXl0RlhKRlFJREFRQUJBb0lCQUFrQngzRkJFamNVYmdKSgpXOUM5VFJSZFRxWDFtcS92OHptWTJNMzdtWHdCcFBJNERzOS9vZ3I2YTFrNHF3aVQ5L3l0dklTVENzcU9ZeHZlCmN3Y1Z2MUtva0pOYVF5c0NhSWQvYXhpcXRPdzZ5QWtoQU5uWUFUc3ZYU0pjc2hiSlJNc0p5Q1prQWpBK0EybWoKTVhGK0piOHRhNFJ4VFpPYkt2UmMxcWJ1ZlU2RTJpQXY1aDNHcGhjL2RrSTJhRkJyQ29vdndhbjIzUlo0aXVuQgpKZGpxQXZaeFFremlrNy9OeEZUZjJMYUk4L2VqaGhGNlU4aXBuYU84VUNhS2FsMHUwSG9IVmd1eFR6UTRFSjhmCmh0QUdKYi8wRyswZ2xNK3ZHa3hxdHhDUXNhMnBKdmlHaTIwOWFSS3NIYnlZVG05amFnRk9SR2hIZERzY05DWUoKMFdQK20rOENnWUVBNHdVU0dHY0ZpZXJoZEdTTVovTFF6OTlBWWpaSVczR09YRUpEcG1vYmhPMzZXVEluM1hESwpPVDdpdHBWQlptSWxHQXROdTJ5clZEeFRqM3BrQVBIWDRhckwvUUx1Y3hDckZldFROODhubEJ5N2lIUkh1UjA0CkprU0RCL0EwVW9STXlOUzhqTG9JblErUS9VZUVwY1k4ejN6cUdFaVhXMUJLZlVFRjVXZmVEbWNDZ1lFQXdaVzcKeVRCNXRQbExGR0d3Yk5VMWphc1ZRb0pZWGo2MHR3TTU0SktjZElNVmFYS1RLOSt6cGcydjc5aTVzTHNIOG50TApXQ0hOSDlyandiaHRHMmZOT0FweWtnY1pjTHg2VUR3R1RYM3R1NC9NMld3YnFNL0crOTAvL1NYSSttQzVvYTRZCnp1Y1NZeGxyeWxrMlp6ZzRTN005VzBKQTNDNjFQeERSaXNXakJ5TUNnWUFseVFaR0FYK3VnT1dkbGM2NHpuVnEKNCtHM2R3bDhEdDUvQkpoMTdscytPTTNlWXJhMzZMbi81VE9lNkNER2hiZGUxU0xPK3p0WS9lRjZsQWhwRDllNgp1ODdRQWRqbVZmUGo1aE1ueXRidmxBaXlvWWYraTVwNDVCWmJEK1BsaUJldnBaanNZMXBqcWQrY0NIZFBrRHMyCjNiZW82d3dtS3FyN1JnTlJONFNDS1FLQmdCOGk0MEpYM3F1Q0VWWms1QWlOUG9EYnpKNlc4bm11SWtqeFp1UzkKRUJjWllsOUVnM0ZpR0xZVHE0R3JYU3FVMnBGZ3pWeU9penlkYTFha1FFQlJNTXZidWxQTWVvWU1lcXZmQzdCNQpHYnk2UTF1UkxOMjVGYXM3Q2VqQXBCUEpiUElaVzNvajVtdzBFWWRKVkJ2RUNpSDY0VnFGVElOZHE5OUo2RG9tCjBiTDdBb0dBRW9hUk9tUVpVMHJ6UUhXNUkxWnRIM0hKZDk1YTBPSFNlTVdNYllaeE9ST1ZMNWdQckhQSFRzU2YKbTNpWmNLaFgvdXRleEdjWm1zN0M4U3M5NWp4TzRiV3pBcDYxVU1aU3dVYStYNTRUNHkwNUxWRXRUOG5TdVRySwpxV1dMOGVDNXJGMktIYjJ3UFV3Vm1mUEc4SWlMS1FkUHR2b1ZzRGM2dU95NURSdkpzV0k9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t";
|
|
16
|
+
|
|
17
|
+
describe("GitHubAuthService", () => {
|
|
18
|
+
it("should generate valid GitHub App JWT", () => {
|
|
19
|
+
const appId = "123456";
|
|
20
|
+
const baseUrl = "https://api.github.com";
|
|
21
|
+
|
|
22
|
+
const authService = new GitHubAuthService(appId, testPrivateKeyBase64, baseUrl);
|
|
23
|
+
const jwt = authService.generateAppJWT();
|
|
24
|
+
|
|
25
|
+
expect(jwt).toBeTruthy();
|
|
26
|
+
expect(typeof jwt).toBe("string");
|
|
27
|
+
expect(jwt.split(".").length).toBe(3); // JWT has 3 parts
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should throw error with invalid private key", () => {
|
|
31
|
+
const appId = "123456";
|
|
32
|
+
const invalidKey = "not a valid key";
|
|
33
|
+
const baseUrl = "https://api.github.com";
|
|
34
|
+
|
|
35
|
+
expect(() => {
|
|
36
|
+
new GitHubAuthService(appId, invalidKey, baseUrl);
|
|
37
|
+
}).toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Note: Testing token exchange requires mocking GitHub API
|
|
41
|
+
// This is skipped in favor of integration tests
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("GitHubAPIClient", () => {
|
|
45
|
+
it("should initialize with installation ID and auth service", () => {
|
|
46
|
+
const appId = "123456";
|
|
47
|
+
const installationId = 12345;
|
|
48
|
+
const authService = new GitHubAuthService(appId, testPrivateKeyBase64, "https://api.github.com");
|
|
49
|
+
|
|
50
|
+
const apiClient = new GitHubAPIClient(installationId, authService);
|
|
51
|
+
|
|
52
|
+
expect(apiClient).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Note: Testing actual API calls requires mocking GitHub API
|
|
56
|
+
// These are tested in integration tests instead
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("WebhookValidator", () => {
|
|
60
|
+
const secret = "test-webhook-secret";
|
|
61
|
+
const validator = new WebhookValidator(secret);
|
|
62
|
+
|
|
63
|
+
it("should validate signature and parse payload correctly", () => {
|
|
64
|
+
const payload = JSON.stringify({
|
|
65
|
+
action: "opened",
|
|
66
|
+
installation: { id: 12345 },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Generate valid signature
|
|
70
|
+
const signature = "sha256=" + crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
|
71
|
+
|
|
72
|
+
const result = validator.validateSignature(payload, signature);
|
|
73
|
+
|
|
74
|
+
expect(result).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should reject invalid signature", () => {
|
|
78
|
+
const payload = JSON.stringify({ action: "opened" });
|
|
79
|
+
const invalidSignature = "sha256=invalid_signature_here";
|
|
80
|
+
|
|
81
|
+
const result = validator.validateSignature(payload, invalidSignature);
|
|
82
|
+
|
|
83
|
+
expect(result).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should parse webhook payload correctly", () => {
|
|
87
|
+
const payload = JSON.stringify({
|
|
88
|
+
action: "opened",
|
|
89
|
+
installation: { id: 12345 },
|
|
90
|
+
repository: { name: "test-repo" },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const parsed = validator.parsePayload(payload);
|
|
94
|
+
|
|
95
|
+
expect(parsed).toBeTruthy();
|
|
96
|
+
expect(parsed.action).toBe("opened");
|
|
97
|
+
expect(parsed.installation?.id).toBe(12345);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle malformed JSON gracefully", () => {
|
|
101
|
+
const invalidJson = "{ this is not valid json }";
|
|
102
|
+
|
|
103
|
+
expect(() => {
|
|
104
|
+
validator.parsePayload(invalidJson);
|
|
105
|
+
}).toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { FlinkApp, FlinkPlugin, log } from "@flink-app/flink";
|
|
2
|
+
import { Db } from "mongodb";
|
|
3
|
+
import { GitHubAppPluginOptions } from "./GitHubAppPluginOptions";
|
|
4
|
+
import { GitHubAppPluginContext } from "./GitHubAppPluginContext";
|
|
5
|
+
import GitHubAppSessionRepo from "./repos/GitHubAppSessionRepo";
|
|
6
|
+
import GitHubInstallationRepo from "./repos/GitHubInstallationRepo";
|
|
7
|
+
import GitHubWebhookEventRepo from "./repos/GitHubWebhookEventRepo";
|
|
8
|
+
import { GitHubAuthService } from "./services/GitHubAuthService";
|
|
9
|
+
import { GitHubAPIClient } from "./services/GitHubAPIClient";
|
|
10
|
+
import { WebhookValidator } from "./services/WebhookValidator";
|
|
11
|
+
import GitHubInstallation from "./schemas/GitHubInstallation";
|
|
12
|
+
import { createGitHubAppError, GitHubAppErrorCodes } from "./utils/error-utils";
|
|
13
|
+
import { generateState, generateSessionId } from "./utils/state-utils";
|
|
14
|
+
import * as InstallationCallback from "./handlers/InstallationCallback";
|
|
15
|
+
import * as WebhookHandler from "./handlers/WebhookHandler";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* GitHub App Plugin Factory Function
|
|
19
|
+
*
|
|
20
|
+
* Creates a Flink plugin for GitHub App integration with:
|
|
21
|
+
* - Installation management
|
|
22
|
+
* - JWT-based authentication with private key signing
|
|
23
|
+
* - Installation access token management with automatic refresh and caching
|
|
24
|
+
* - Webhook integration with signature validation
|
|
25
|
+
* - GitHub API client wrapper
|
|
26
|
+
*
|
|
27
|
+
* @param options - GitHub App plugin configuration options
|
|
28
|
+
* @returns FlinkPlugin instance
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { githubAppPlugin } from '@flink-app/github-app-plugin';
|
|
33
|
+
*
|
|
34
|
+
* const app = new FlinkApp({
|
|
35
|
+
* plugins: [
|
|
36
|
+
* githubAppPlugin({
|
|
37
|
+
* appId: process.env.GITHUB_APP_ID!,
|
|
38
|
+
* privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
|
|
39
|
+
* webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
40
|
+
* clientId: process.env.GITHUB_APP_CLIENT_ID!,
|
|
41
|
+
* clientSecret: process.env.GITHUB_APP_CLIENT_SECRET!,
|
|
42
|
+
* onInstallationSuccess: async ({ installationId, repositories, account }, ctx) => {
|
|
43
|
+
* const userId = getLoggedInUserId(req); // App-defined function
|
|
44
|
+
* return {
|
|
45
|
+
* userId,
|
|
46
|
+
* redirectUrl: '/dashboard/repos'
|
|
47
|
+
* };
|
|
48
|
+
* },
|
|
49
|
+
* onWebhookEvent: async ({ event, payload, installationId }, ctx) => {
|
|
50
|
+
* if (event === 'push') {
|
|
51
|
+
* // Process push event
|
|
52
|
+
* }
|
|
53
|
+
* }
|
|
54
|
+
* })
|
|
55
|
+
* ]
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function githubAppPlugin(options: GitHubAppPluginOptions): FlinkPlugin {
|
|
60
|
+
// Validate required options
|
|
61
|
+
if (!options.appId) {
|
|
62
|
+
throw new Error("GitHub App Plugin: appId is required");
|
|
63
|
+
}
|
|
64
|
+
if (!options.privateKey) {
|
|
65
|
+
throw new Error("GitHub App Plugin: privateKey is required");
|
|
66
|
+
}
|
|
67
|
+
if (!options.webhookSecret) {
|
|
68
|
+
throw new Error("GitHub App Plugin: webhookSecret is required");
|
|
69
|
+
}
|
|
70
|
+
if (!options.clientId) {
|
|
71
|
+
throw new Error("GitHub App Plugin: clientId is required");
|
|
72
|
+
}
|
|
73
|
+
if (!options.clientSecret) {
|
|
74
|
+
throw new Error("GitHub App Plugin: clientSecret is required");
|
|
75
|
+
}
|
|
76
|
+
if (!options.onInstallationSuccess) {
|
|
77
|
+
throw new Error("GitHub App Plugin: onInstallationSuccess callback is required");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Determine configuration defaults
|
|
81
|
+
const baseUrl = options.baseUrl || "https://api.github.com";
|
|
82
|
+
const tokenCacheTTL = options.tokenCacheTTL || 3300; // 55 minutes
|
|
83
|
+
const sessionTTL = options.sessionTTL || 600; // 10 minutes
|
|
84
|
+
const registerRoutes = options.registerRoutes !== false; // default true
|
|
85
|
+
const logWebhookEvents = options.logWebhookEvents || false; // default false
|
|
86
|
+
|
|
87
|
+
let flinkApp: FlinkApp<any>;
|
|
88
|
+
let authService: GitHubAuthService;
|
|
89
|
+
let webhookValidator: WebhookValidator;
|
|
90
|
+
let sessionRepo: GitHubAppSessionRepo;
|
|
91
|
+
let installationRepo: GitHubInstallationRepo;
|
|
92
|
+
let webhookEventRepo: GitHubWebhookEventRepo | undefined;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Plugin initialization
|
|
96
|
+
*/
|
|
97
|
+
async function init(app: FlinkApp<any>, db?: Db) {
|
|
98
|
+
log.info("Initializing GitHub App Plugin...");
|
|
99
|
+
|
|
100
|
+
flinkApp = app;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (!db) {
|
|
104
|
+
throw new Error("GitHub App Plugin: Database connection is required");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Initialize GitHubAuthService with private key validation
|
|
108
|
+
// This will throw early if the private key is invalid
|
|
109
|
+
try {
|
|
110
|
+
authService = new GitHubAuthService(options.appId, options.privateKey, baseUrl, tokenCacheTTL);
|
|
111
|
+
log.info("GitHub App Plugin: Successfully validated private key and generated test JWT");
|
|
112
|
+
} catch (error: any) {
|
|
113
|
+
log.error("GitHub App Plugin: Failed to initialize auth service", error);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Initialize WebhookValidator
|
|
118
|
+
webhookValidator = new WebhookValidator(options.webhookSecret);
|
|
119
|
+
|
|
120
|
+
// Initialize repositories
|
|
121
|
+
const sessionsCollectionName = options.sessionsCollectionName || "github_app_sessions";
|
|
122
|
+
const installationsCollectionName = options.installationsCollectionName || "github_installations";
|
|
123
|
+
const webhookEventsCollectionName = options.webhookEventsCollectionName || "github_webhook_events";
|
|
124
|
+
|
|
125
|
+
sessionRepo = new GitHubAppSessionRepo(flinkApp.ctx, db, sessionsCollectionName);
|
|
126
|
+
installationRepo = new GitHubInstallationRepo(flinkApp.ctx, db, installationsCollectionName);
|
|
127
|
+
|
|
128
|
+
// Add repositories to FlinkApp
|
|
129
|
+
flinkApp.addRepo("githubAppSessionRepo", sessionRepo);
|
|
130
|
+
flinkApp.addRepo("githubInstallationRepo", installationRepo);
|
|
131
|
+
|
|
132
|
+
// Conditionally initialize webhook event repo if logging is enabled
|
|
133
|
+
if (logWebhookEvents) {
|
|
134
|
+
webhookEventRepo = new GitHubWebhookEventRepo(flinkApp.ctx, db, webhookEventsCollectionName);
|
|
135
|
+
flinkApp.addRepo("githubWebhookEventRepo", webhookEventRepo);
|
|
136
|
+
log.info(`GitHub App Plugin: Webhook event logging enabled (collection: ${webhookEventsCollectionName})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Create TTL indexes
|
|
140
|
+
// Sessions TTL index for automatic cleanup
|
|
141
|
+
await db.collection(sessionsCollectionName).createIndex({ createdAt: 1 }, { expireAfterSeconds: sessionTTL });
|
|
142
|
+
log.info(`GitHub App Plugin: Created TTL index on ${sessionsCollectionName} with ${sessionTTL}s expiration`);
|
|
143
|
+
|
|
144
|
+
// Webhook events TTL index if logging enabled
|
|
145
|
+
if (logWebhookEvents && webhookEventsCollectionName) {
|
|
146
|
+
// Optional TTL for webhook events (default: 30 days)
|
|
147
|
+
const webhookEventTTL = 30 * 24 * 60 * 60; // 30 days in seconds
|
|
148
|
+
await db.collection(webhookEventsCollectionName).createIndex({ createdAt: 1 }, { expireAfterSeconds: webhookEventTTL });
|
|
149
|
+
log.info(`GitHub App Plugin: Created TTL index on ${webhookEventsCollectionName} with ${webhookEventTTL}s expiration`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Conditionally register handlers (only GitHub-required handlers)
|
|
153
|
+
if (registerRoutes) {
|
|
154
|
+
flinkApp.addHandler(InstallationCallback);
|
|
155
|
+
flinkApp.addHandler(WebhookHandler);
|
|
156
|
+
log.info("GitHub App Plugin: Registered handlers (callback and webhook)");
|
|
157
|
+
} else {
|
|
158
|
+
log.info("GitHub App Plugin: Skipped handler registration (routes disabled)");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
log.info(`GitHub App Plugin initialized successfully`);
|
|
162
|
+
log.info(` - App ID: ${options.appId}`);
|
|
163
|
+
log.info(` - Base URL: ${baseUrl}`);
|
|
164
|
+
log.info(` - Token Cache TTL: ${tokenCacheTTL}s`);
|
|
165
|
+
log.info(` - Session TTL: ${sessionTTL}s`);
|
|
166
|
+
log.info(` - Routes Registered: ${registerRoutes}`);
|
|
167
|
+
log.info(` - Webhook Logging: ${logWebhookEvents}`);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
log.error("Failed to initialize GitHub App Plugin:", error);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Initiates GitHub App installation flow
|
|
176
|
+
*/
|
|
177
|
+
async function initiateInstallation(params: {
|
|
178
|
+
userId: string;
|
|
179
|
+
redirectUrl?: string;
|
|
180
|
+
metadata?: Record<string, any>;
|
|
181
|
+
}): Promise<{
|
|
182
|
+
redirectUrl: string;
|
|
183
|
+
state: string;
|
|
184
|
+
sessionId: string;
|
|
185
|
+
}> {
|
|
186
|
+
if (!sessionRepo) {
|
|
187
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Validate that appSlug is configured
|
|
191
|
+
if (!options.appSlug) {
|
|
192
|
+
throw new Error("GitHub App Plugin: appSlug is required for installation flow. Please set appSlug in plugin options.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Generate cryptographically secure state and session ID
|
|
196
|
+
const state = generateState();
|
|
197
|
+
const sessionId = generateSessionId();
|
|
198
|
+
|
|
199
|
+
// Store session for state validation in callback
|
|
200
|
+
await sessionRepo.create({
|
|
201
|
+
sessionId,
|
|
202
|
+
state,
|
|
203
|
+
userId: params.userId,
|
|
204
|
+
metadata: params.metadata || {},
|
|
205
|
+
createdAt: new Date(),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Build GitHub installation URL
|
|
209
|
+
const installationUrl = `https://github.com/apps/${options.appSlug}/installations/new?state=${state}`;
|
|
210
|
+
|
|
211
|
+
log.info("GitHub App installation initiated", { userId: params.userId, sessionId });
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
redirectUrl: installationUrl,
|
|
215
|
+
state,
|
|
216
|
+
sessionId,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Uninstalls GitHub App for a user
|
|
222
|
+
*/
|
|
223
|
+
async function uninstall(params: {
|
|
224
|
+
userId: string;
|
|
225
|
+
installationId: number;
|
|
226
|
+
}): Promise<{
|
|
227
|
+
success: boolean;
|
|
228
|
+
error?: string;
|
|
229
|
+
}> {
|
|
230
|
+
if (!installationRepo || !authService) {
|
|
231
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Find installation
|
|
236
|
+
const installation = await installationRepo.findByUserAndInstallationId(params.userId, params.installationId);
|
|
237
|
+
|
|
238
|
+
if (!installation) {
|
|
239
|
+
return { success: false, error: "installation-not-found" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Verify ownership
|
|
243
|
+
if (installation.userId !== params.userId) {
|
|
244
|
+
log.warn("User attempted to uninstall installation they don't own", {
|
|
245
|
+
userId: params.userId,
|
|
246
|
+
installationId: params.installationId,
|
|
247
|
+
ownerId: installation.userId,
|
|
248
|
+
});
|
|
249
|
+
return { success: false, error: "installation-not-owned" };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Delete from database
|
|
253
|
+
await installationRepo.deleteByInstallationId(params.installationId);
|
|
254
|
+
|
|
255
|
+
// Clear token cache
|
|
256
|
+
authService.deleteInstallationToken(params.installationId);
|
|
257
|
+
|
|
258
|
+
log.info("GitHub App uninstalled", { userId: params.userId, installationId: params.installationId });
|
|
259
|
+
|
|
260
|
+
return { success: true };
|
|
261
|
+
} catch (error: any) {
|
|
262
|
+
log.error("Failed to uninstall GitHub App", {
|
|
263
|
+
userId: params.userId,
|
|
264
|
+
installationId: params.installationId,
|
|
265
|
+
error: error.message,
|
|
266
|
+
});
|
|
267
|
+
return { success: false, error: "uninstall-failed" };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get GitHub API client for an installation
|
|
273
|
+
*/
|
|
274
|
+
async function getClient(installationId: number): Promise<GitHubAPIClient> {
|
|
275
|
+
if (!authService) {
|
|
276
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return new GitHubAPIClient(installationId, authService, baseUrl);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get installation for a user (returns first installation)
|
|
284
|
+
*/
|
|
285
|
+
async function getInstallation(userId: string): Promise<GitHubInstallation | null> {
|
|
286
|
+
if (!installationRepo) {
|
|
287
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const installations = await installationRepo.findByUserId(userId);
|
|
291
|
+
return installations.length > 0 ? installations[0] : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get all installations for a user
|
|
296
|
+
*/
|
|
297
|
+
async function getInstallations(userId: string): Promise<GitHubInstallation[]> {
|
|
298
|
+
if (!installationRepo) {
|
|
299
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return installationRepo.findByUserId(userId);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Delete installation
|
|
307
|
+
*/
|
|
308
|
+
async function deleteInstallation(userId: string, installationId: number): Promise<void> {
|
|
309
|
+
if (!installationRepo || !authService) {
|
|
310
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Verify user owns the installation
|
|
314
|
+
const installation = await installationRepo.findByUserAndInstallationId(userId, installationId);
|
|
315
|
+
if (!installation) {
|
|
316
|
+
throw createGitHubAppError(
|
|
317
|
+
GitHubAppErrorCodes.INSTALLATION_NOT_OWNED,
|
|
318
|
+
"Installation not found or not owned by user",
|
|
319
|
+
{ userId, installationId }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Delete from database
|
|
324
|
+
await installationRepo.deleteByInstallationId(installationId);
|
|
325
|
+
|
|
326
|
+
// Clear token cache for this installation
|
|
327
|
+
authService.deleteInstallationToken(installationId);
|
|
328
|
+
|
|
329
|
+
log.info(`GitHub App Plugin: Deleted installation ${installationId} for user ${userId}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Check if user has access to specific repository
|
|
334
|
+
*/
|
|
335
|
+
async function hasRepositoryAccess(userId: string, owner: string, repo: string): Promise<boolean> {
|
|
336
|
+
if (!installationRepo) {
|
|
337
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const installations = await installationRepo.findByUserId(userId);
|
|
341
|
+
|
|
342
|
+
// Check if any installation has access to the repository
|
|
343
|
+
for (const installation of installations) {
|
|
344
|
+
// Skip suspended installations
|
|
345
|
+
if (installation.suspendedAt) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Check repositories array
|
|
350
|
+
const hasAccess = installation.repositories.some(
|
|
351
|
+
(r) => r.fullName.toLowerCase() === `${owner}/${repo}`.toLowerCase()
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
if (hasAccess) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get installation access token (for advanced usage)
|
|
364
|
+
*/
|
|
365
|
+
async function getInstallationToken(installationId: number): Promise<string> {
|
|
366
|
+
if (!authService) {
|
|
367
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return authService.getInstallationToken(installationId);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Clear token cache
|
|
375
|
+
*/
|
|
376
|
+
function clearTokenCache(): void {
|
|
377
|
+
if (!authService) {
|
|
378
|
+
throw new Error("GitHub App Plugin: Plugin not initialized");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
authService.clearTokenCache();
|
|
382
|
+
log.info("GitHub App Plugin: Cleared token cache");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Plugin context exposed via ctx.plugins.githubApp
|
|
387
|
+
*/
|
|
388
|
+
const pluginCtx: GitHubAppPluginContext["githubApp"] = {
|
|
389
|
+
initiateInstallation,
|
|
390
|
+
uninstall,
|
|
391
|
+
getClient,
|
|
392
|
+
getInstallation,
|
|
393
|
+
getInstallations,
|
|
394
|
+
deleteInstallation,
|
|
395
|
+
hasRepositoryAccess,
|
|
396
|
+
getInstallationToken,
|
|
397
|
+
clearTokenCache,
|
|
398
|
+
options: Object.freeze({ ...options }),
|
|
399
|
+
get authService() { return authService; },
|
|
400
|
+
get webhookValidator() { return webhookValidator; },
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
id: "githubApp",
|
|
405
|
+
db: {
|
|
406
|
+
useHostDb: true,
|
|
407
|
+
},
|
|
408
|
+
ctx: pluginCtx,
|
|
409
|
+
init,
|
|
410
|
+
};
|
|
411
|
+
}
|