@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/LICENSE +21 -0
  3. package/README.md +667 -0
  4. package/SECURITY.md +498 -0
  5. package/dist/GitHubAppInternalContext.d.ts +44 -0
  6. package/dist/GitHubAppInternalContext.js +2 -0
  7. package/dist/GitHubAppPlugin.d.ts +45 -0
  8. package/dist/GitHubAppPlugin.js +367 -0
  9. package/dist/GitHubAppPluginContext.d.ts +242 -0
  10. package/dist/GitHubAppPluginContext.js +2 -0
  11. package/dist/GitHubAppPluginOptions.d.ts +369 -0
  12. package/dist/GitHubAppPluginOptions.js +2 -0
  13. package/dist/handlers/InitiateInstallation.d.ts +32 -0
  14. package/dist/handlers/InitiateInstallation.js +66 -0
  15. package/dist/handlers/InstallationCallback.d.ts +42 -0
  16. package/dist/handlers/InstallationCallback.js +248 -0
  17. package/dist/handlers/UninstallHandler.d.ts +37 -0
  18. package/dist/handlers/UninstallHandler.js +153 -0
  19. package/dist/handlers/WebhookHandler.d.ts +54 -0
  20. package/dist/handlers/WebhookHandler.js +157 -0
  21. package/dist/index.d.ts +19 -0
  22. package/dist/index.js +23 -0
  23. package/dist/repos/GitHubAppSessionRepo.d.ts +24 -0
  24. package/dist/repos/GitHubAppSessionRepo.js +32 -0
  25. package/dist/repos/GitHubInstallationRepo.d.ts +53 -0
  26. package/dist/repos/GitHubInstallationRepo.js +83 -0
  27. package/dist/repos/GitHubWebhookEventRepo.d.ts +29 -0
  28. package/dist/repos/GitHubWebhookEventRepo.js +42 -0
  29. package/dist/schemas/GitHubAppSession.d.ts +13 -0
  30. package/dist/schemas/GitHubAppSession.js +2 -0
  31. package/dist/schemas/GitHubInstallation.d.ts +28 -0
  32. package/dist/schemas/GitHubInstallation.js +2 -0
  33. package/dist/schemas/InstallationCallbackRequest.d.ts +10 -0
  34. package/dist/schemas/InstallationCallbackRequest.js +2 -0
  35. package/dist/schemas/WebhookEvent.d.ts +16 -0
  36. package/dist/schemas/WebhookEvent.js +2 -0
  37. package/dist/schemas/WebhookPayload.d.ts +35 -0
  38. package/dist/schemas/WebhookPayload.js +2 -0
  39. package/dist/services/GitHubAPIClient.d.ts +143 -0
  40. package/dist/services/GitHubAPIClient.js +167 -0
  41. package/dist/services/GitHubAuthService.d.ts +85 -0
  42. package/dist/services/GitHubAuthService.js +160 -0
  43. package/dist/services/WebhookValidator.d.ts +93 -0
  44. package/dist/services/WebhookValidator.js +123 -0
  45. package/dist/utils/error-utils.d.ts +67 -0
  46. package/dist/utils/error-utils.js +121 -0
  47. package/dist/utils/jwt-utils.d.ts +35 -0
  48. package/dist/utils/jwt-utils.js +67 -0
  49. package/dist/utils/state-utils.d.ts +38 -0
  50. package/dist/utils/state-utils.js +74 -0
  51. package/dist/utils/token-cache-utils.d.ts +47 -0
  52. package/dist/utils/token-cache-utils.js +74 -0
  53. package/dist/utils/webhook-signature-utils.d.ts +22 -0
  54. package/dist/utils/webhook-signature-utils.js +57 -0
  55. package/examples/basic-installation.ts +246 -0
  56. package/examples/create-issue.ts +392 -0
  57. package/examples/error-handling.ts +396 -0
  58. package/examples/multi-event-webhook.ts +367 -0
  59. package/examples/organization-installation.ts +316 -0
  60. package/examples/repository-access.ts +480 -0
  61. package/examples/webhook-handling.ts +343 -0
  62. package/examples/with-jwt-auth.ts +319 -0
  63. package/package.json +41 -0
  64. package/spec/core-utilities.spec.ts +243 -0
  65. package/spec/handlers.spec.ts +216 -0
  66. package/spec/helpers/reporter.ts +41 -0
  67. package/spec/integration-and-security.spec.ts +483 -0
  68. package/spec/plugin-core.spec.ts +258 -0
  69. package/spec/project-setup.spec.ts +56 -0
  70. package/spec/repos-and-schemas.spec.ts +288 -0
  71. package/spec/services.spec.ts +108 -0
  72. package/spec/support/jasmine.json +7 -0
  73. package/src/GitHubAppPlugin.ts +411 -0
  74. package/src/GitHubAppPluginContext.ts +254 -0
  75. package/src/GitHubAppPluginOptions.ts +412 -0
  76. package/src/handlers/InstallationCallback.ts +292 -0
  77. package/src/handlers/WebhookHandler.ts +179 -0
  78. package/src/index.ts +29 -0
  79. package/src/repos/GitHubAppSessionRepo.ts +36 -0
  80. package/src/repos/GitHubInstallationRepo.ts +95 -0
  81. package/src/repos/GitHubWebhookEventRepo.ts +48 -0
  82. package/src/schemas/GitHubAppSession.ts +13 -0
  83. package/src/schemas/GitHubInstallation.ts +28 -0
  84. package/src/schemas/InstallationCallbackRequest.ts +10 -0
  85. package/src/schemas/WebhookEvent.ts +16 -0
  86. package/src/schemas/WebhookPayload.ts +35 -0
  87. package/src/services/GitHubAPIClient.ts +244 -0
  88. package/src/services/GitHubAuthService.ts +188 -0
  89. package/src/services/WebhookValidator.ts +159 -0
  90. package/src/utils/error-utils.ts +148 -0
  91. package/src/utils/jwt-utils.ts +64 -0
  92. package/src/utils/state-utils.ts +72 -0
  93. package/src/utils/token-cache-utils.ts +89 -0
  94. package/src/utils/webhook-signature-utils.ts +57 -0
  95. package/tsconfig.dist.json +4 -0
  96. 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,7 @@
1
+ {
2
+ "spec_dir": "spec",
3
+ "spec_files": ["**/*[sS]pec.ts"],
4
+ "helpers": ["helpers/**/*.ts"],
5
+ "stopSpecOnExpectationFailure": false,
6
+ "random": true
7
+ }
@@ -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
+ }