@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,28 @@
1
+ /**
2
+ * GitHub App installation data linked to a user.
3
+ * Stores which repositories the user granted access to and installation metadata.
4
+ */
5
+ export default interface GitHubInstallation {
6
+ _id?: string;
7
+ userId: string;
8
+ installationId: number;
9
+ accountId: number;
10
+ accountLogin: string;
11
+ accountType: 'User' | 'Organization';
12
+ avatarUrl?: string;
13
+ repositories: {
14
+ id: number;
15
+ name: string;
16
+ fullName: string;
17
+ private: boolean;
18
+ }[];
19
+ permissions: Record<string, string>;
20
+ events: string[];
21
+ suspendedAt?: Date;
22
+ suspendedBy?: {
23
+ id: number;
24
+ login: string;
25
+ };
26
+ createdAt: Date;
27
+ updatedAt: Date;
28
+ }
@@ -0,0 +1,10 @@
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
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Optional webhook event logging for debugging and auditing.
3
+ * Only stored if logWebhookEvents config option is enabled.
4
+ */
5
+ export default interface WebhookEvent {
6
+ _id?: string;
7
+ installationId: number;
8
+ event: string;
9
+ action?: string;
10
+ deliveryId: string;
11
+ payload: Record<string, any>;
12
+ processed: boolean;
13
+ processedAt?: Date;
14
+ error?: string;
15
+ createdAt: Date;
16
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Generic webhook event payload structure from GitHub.
3
+ * Contains common fields; specific events may have additional fields.
4
+ */
5
+ export default interface WebhookPayload {
6
+ action?: string;
7
+ installation?: {
8
+ id: number;
9
+ account: {
10
+ id: number;
11
+ login: string;
12
+ type: 'User' | 'Organization';
13
+ avatar_url?: string;
14
+ };
15
+ };
16
+ repositories?: {
17
+ id: number;
18
+ name: string;
19
+ full_name: string;
20
+ private: boolean;
21
+ }[];
22
+ repository?: {
23
+ id: number;
24
+ name: string;
25
+ full_name: string;
26
+ private: boolean;
27
+ owner: {
28
+ login: string;
29
+ };
30
+ };
31
+ sender?: {
32
+ id: number;
33
+ login: string;
34
+ };
35
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * GitHub API Client
3
+ *
4
+ * Wrapper for GitHub API calls with automatic token injection,
5
+ * retry logic, and error handling.
6
+ */
7
+
8
+ import { GitHubAuthService } from "./GitHubAuthService";
9
+ import { createGitHubAppError, GitHubAppErrorCodes, handleGitHubAPIError } from "../utils/error-utils";
10
+
11
+ /**
12
+ * Repository data structure
13
+ */
14
+ export interface Repository {
15
+ id: number;
16
+ name: string;
17
+ full_name: string;
18
+ private: boolean;
19
+ owner: {
20
+ login: string;
21
+ id: number;
22
+ };
23
+ html_url: string;
24
+ description?: string;
25
+ }
26
+
27
+ /**
28
+ * Content data structure
29
+ */
30
+ export interface Content {
31
+ name: string;
32
+ path: string;
33
+ sha: string;
34
+ size: number;
35
+ url: string;
36
+ html_url: string;
37
+ git_url: string;
38
+ download_url?: string;
39
+ type: "file" | "dir" | "symlink" | "submodule";
40
+ }
41
+
42
+ /**
43
+ * Issue data structure
44
+ */
45
+ export interface Issue {
46
+ id: number;
47
+ number: number;
48
+ title: string;
49
+ body?: string;
50
+ state: "open" | "closed";
51
+ html_url: string;
52
+ user: {
53
+ login: string;
54
+ id: number;
55
+ };
56
+ created_at: string;
57
+ updated_at: string;
58
+ }
59
+
60
+ /**
61
+ * Create issue parameters
62
+ */
63
+ export interface CreateIssueParams {
64
+ title: string;
65
+ body?: string;
66
+ assignees?: string[];
67
+ labels?: string[];
68
+ }
69
+
70
+ /**
71
+ * GitHub API Client
72
+ *
73
+ * Provides wrapper methods for common GitHub API operations with:
74
+ * - Automatic token injection
75
+ * - Retry logic with exponential backoff for rate limits
76
+ * - Error handling and transformation
77
+ */
78
+ export class GitHubAPIClient {
79
+ private installationId: number;
80
+ private authService: GitHubAuthService;
81
+ private baseUrl: string;
82
+ private maxRetries: number = 3;
83
+ private retryDelayMs: number = 1000;
84
+
85
+ /**
86
+ * Create GitHub API Client
87
+ *
88
+ * @param installationId - GitHub installation ID
89
+ * @param authService - GitHub Auth Service instance
90
+ * @param baseUrl - GitHub API base URL (default: https://api.github.com)
91
+ */
92
+ constructor(installationId: number, authService: GitHubAuthService, baseUrl: string = "https://api.github.com") {
93
+ this.installationId = installationId;
94
+ this.authService = authService;
95
+ this.baseUrl = baseUrl;
96
+ }
97
+
98
+ /**
99
+ * Generic API request method with automatic token injection
100
+ *
101
+ * @param method - HTTP method (GET, POST, PUT, DELETE)
102
+ * @param endpoint - API endpoint (e.g., "/repos/owner/repo")
103
+ * @param data - Request body data (for POST, PUT)
104
+ * @param retryCount - Current retry attempt (internal use)
105
+ * @returns Response data
106
+ * @throws Error if request fails after retries
107
+ */
108
+ async request<T = any>(method: "GET" | "POST" | "PUT" | "DELETE", endpoint: string, data?: any, retryCount: number = 0): Promise<T> {
109
+ try {
110
+ // Get installation token (cached or fresh)
111
+ const token = await this.authService.getInstallationToken(this.installationId);
112
+
113
+ // Build full URL
114
+ const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`;
115
+
116
+ // Make request
117
+ const response = await fetch(url, {
118
+ method,
119
+ headers: {
120
+ Authorization: `Bearer ${token}`,
121
+ Accept: "application/vnd.github+json",
122
+ "User-Agent": "Flink-GitHub-App-Plugin",
123
+ ...(data ? { "Content-Type": "application/json" } : {}),
124
+ },
125
+ ...(data ? { body: JSON.stringify(data) } : {}),
126
+ });
127
+
128
+ // Handle rate limiting with retry
129
+ if (response.status === 403) {
130
+ const rateLimitRemaining = response.headers.get("x-ratelimit-remaining");
131
+ if (rateLimitRemaining === "0" && retryCount < this.maxRetries) {
132
+ // Calculate exponential backoff delay
133
+ const delay = this.retryDelayMs * Math.pow(2, retryCount);
134
+ await this.sleep(delay);
135
+ return this.request<T>(method, endpoint, data, retryCount + 1);
136
+ }
137
+ }
138
+
139
+ // Handle non-OK responses
140
+ if (!response.ok) {
141
+ const errorBody = await response.text();
142
+ const error: any = new Error(`GitHub API request failed: ${response.statusText}`);
143
+ error.status = response.status;
144
+ error.message = errorBody;
145
+ throw error;
146
+ }
147
+
148
+ // Parse and return response
149
+ const responseData = await response.json();
150
+ return responseData as T;
151
+ } catch (error: any) {
152
+ // If we can retry, do so
153
+ if (this.shouldRetry(error) && retryCount < this.maxRetries) {
154
+ const delay = this.retryDelayMs * Math.pow(2, retryCount);
155
+ await this.sleep(delay);
156
+ return this.request<T>(method, endpoint, data, retryCount + 1);
157
+ }
158
+
159
+ // Transform error to standardized format
160
+ throw handleGitHubAPIError(error);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Get repositories accessible by this installation
166
+ *
167
+ * @returns Array of repositories
168
+ */
169
+ async getRepositories(): Promise<Repository[]> {
170
+ const response = await this.request<{ repositories: Repository[] }>("GET", "/installation/repositories");
171
+ return response.repositories;
172
+ }
173
+
174
+ /**
175
+ * Get repository details
176
+ *
177
+ * @param owner - Repository owner (user or org)
178
+ * @param repo - Repository name
179
+ * @returns Repository details
180
+ */
181
+ async getRepository(owner: string, repo: string): Promise<Repository> {
182
+ return this.request<Repository>("GET", `/repos/${owner}/${repo}`);
183
+ }
184
+
185
+ /**
186
+ * Get repository contents
187
+ *
188
+ * @param owner - Repository owner
189
+ * @param repo - Repository name
190
+ * @param path - File or directory path
191
+ * @returns Content array (for directories) or single content (for files)
192
+ */
193
+ async getContents(owner: string, repo: string, path: string): Promise<Content | Content[]> {
194
+ return this.request<Content | Content[]>("GET", `/repos/${owner}/${repo}/contents/${path}`);
195
+ }
196
+
197
+ /**
198
+ * Create an issue
199
+ *
200
+ * @param owner - Repository owner
201
+ * @param repo - Repository name
202
+ * @param params - Issue creation parameters
203
+ * @returns Created issue
204
+ */
205
+ async createIssue(owner: string, repo: string, params: CreateIssueParams): Promise<Issue> {
206
+ return this.request<Issue>("POST", `/repos/${owner}/${repo}/issues`, params);
207
+ }
208
+
209
+ /**
210
+ * Sleep utility for retry delays
211
+ *
212
+ * @param ms - Milliseconds to sleep
213
+ * @private
214
+ */
215
+ private sleep(ms: number): Promise<void> {
216
+ return new Promise((resolve) => setTimeout(resolve, ms));
217
+ }
218
+
219
+ /**
220
+ * Determine if error should trigger retry
221
+ *
222
+ * @param error - Error to check
223
+ * @returns true if should retry
224
+ * @private
225
+ */
226
+ private shouldRetry(error: any): boolean {
227
+ // Retry on network errors
228
+ if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT") {
229
+ return true;
230
+ }
231
+
232
+ // Retry on rate limit (403)
233
+ if (error.status === 403) {
234
+ return true;
235
+ }
236
+
237
+ // Retry on server errors (500, 502, 503, 504)
238
+ if (error.status >= 500 && error.status < 600) {
239
+ return true;
240
+ }
241
+
242
+ return false;
243
+ }
244
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * GitHub Authentication Service
3
+ *
4
+ * Handles GitHub App authentication including JWT generation and
5
+ * installation token exchange with automatic caching.
6
+ */
7
+
8
+ import { generateJWT } from "../utils/jwt-utils";
9
+ import { TokenCache } from "../utils/token-cache-utils";
10
+ import { validatePrivateKey, createGitHubAppError, GitHubAppErrorCodes, handleGitHubAPIError } from "../utils/error-utils";
11
+
12
+ /**
13
+ * Installation token response from GitHub API
14
+ */
15
+ interface InstallationTokenResponse {
16
+ token: string;
17
+ expires_at: string;
18
+ permissions?: Record<string, string>;
19
+ repository_selection?: string;
20
+ }
21
+
22
+ /**
23
+ * GitHub Authentication Service
24
+ *
25
+ * Manages GitHub App authentication flow:
26
+ * 1. Generate GitHub App JWT using private key
27
+ * 2. Exchange JWT for installation access token
28
+ * 3. Cache installation tokens with automatic expiration
29
+ * 4. Automatically refresh expired tokens
30
+ */
31
+ export class GitHubAuthService {
32
+ private appId: string;
33
+ private privateKey: string;
34
+ private baseUrl: string;
35
+ private tokenCache: TokenCache;
36
+ private tokenCacheTTL: number;
37
+
38
+ /**
39
+ * Create GitHub Auth Service
40
+ *
41
+ * @param appId - GitHub App ID
42
+ * @param privateKeyBase64 - Base64 encoded RSA private key (will be decoded to PEM format, PKCS#1 or PKCS#8)
43
+ * @param baseUrl - GitHub API base URL (default: https://api.github.com)
44
+ * @param tokenCacheTTL - Token cache TTL in seconds (default: 3300 = 55 minutes)
45
+ * @throws Error if private key is invalid or cannot be decoded
46
+ */
47
+ constructor(appId: string, privateKeyBase64: string, baseUrl: string = "https://api.github.com", tokenCacheTTL: number = 3300) {
48
+ // Decode base64 private key to PEM format
49
+ let privateKey: string;
50
+ try {
51
+ privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8');
52
+ } catch (error: any) {
53
+ throw createGitHubAppError(
54
+ GitHubAppErrorCodes.INVALID_PRIVATE_KEY,
55
+ `Failed to decode base64 private key: ${error.message}`
56
+ );
57
+ }
58
+
59
+ // Validate private key format after decoding
60
+ validatePrivateKey(privateKey);
61
+
62
+ this.appId = appId;
63
+ this.privateKey = privateKey;
64
+ this.baseUrl = baseUrl;
65
+ this.tokenCache = new TokenCache();
66
+ this.tokenCacheTTL = tokenCacheTTL;
67
+
68
+ // Test JWT signing on startup to catch key errors early
69
+ try {
70
+ this.generateAppJWT();
71
+ } catch (error: any) {
72
+ throw createGitHubAppError(GitHubAppErrorCodes.JWT_SIGNING_FAILED, "Failed to sign JWT with provided private key. Please verify the key format.", {
73
+ error: error.message,
74
+ });
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Generate GitHub App JWT
80
+ *
81
+ * Creates a JWT signed with the app's private key. The JWT is valid
82
+ * for 10 minutes and is used to authenticate as the GitHub App itself.
83
+ *
84
+ * @returns Signed JWT token
85
+ * @throws Error if JWT signing fails
86
+ */
87
+ generateAppJWT(): string {
88
+ try {
89
+ return generateJWT(this.appId, this.privateKey);
90
+ } catch (error: any) {
91
+ throw createGitHubAppError(GitHubAppErrorCodes.JWT_SIGNING_FAILED, "Failed to generate GitHub App JWT", { error: error.message });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get installation access token
97
+ *
98
+ * Retrieves an installation access token for the specified installation.
99
+ * Tokens are automatically cached and refreshed when expired.
100
+ *
101
+ * Flow:
102
+ * 1. Check cache for valid token
103
+ * 2. If not cached or expired, generate GitHub App JWT
104
+ * 3. Exchange JWT for installation token via GitHub API
105
+ * 4. Cache the new token
106
+ * 5. Return token
107
+ *
108
+ * @param installationId - GitHub installation ID
109
+ * @returns Installation access token
110
+ * @throws Error if token exchange fails
111
+ */
112
+ async getInstallationToken(installationId: number): Promise<string> {
113
+ // Check cache first
114
+ const cachedToken = this.tokenCache.getToken(installationId);
115
+ if (cachedToken) {
116
+ return cachedToken;
117
+ }
118
+
119
+ // Generate GitHub App JWT
120
+ const jwt = this.generateAppJWT();
121
+
122
+ // Exchange JWT for installation token
123
+ try {
124
+ const response = await this.exchangeJWTForInstallationToken(installationId, jwt);
125
+
126
+ // Cache the token
127
+ this.tokenCache.setToken(installationId, response.token, this.tokenCacheTTL);
128
+
129
+ return response.token;
130
+ } catch (error: any) {
131
+ throw handleGitHubAPIError(error);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Exchange GitHub App JWT for installation access token
137
+ *
138
+ * Calls GitHub API to exchange the app JWT for an installation-specific
139
+ * access token that can be used to make API calls on behalf of the installation.
140
+ *
141
+ * @param installationId - GitHub installation ID
142
+ * @param jwt - GitHub App JWT
143
+ * @returns Installation token response
144
+ * @private
145
+ */
146
+ private async exchangeJWTForInstallationToken(installationId: number, jwt: string): Promise<InstallationTokenResponse> {
147
+ const url = `${this.baseUrl}/app/installations/${installationId}/access_tokens`;
148
+
149
+ const response = await fetch(url, {
150
+ method: "POST",
151
+ headers: {
152
+ Authorization: `Bearer ${jwt}`,
153
+ Accept: "application/vnd.github+json",
154
+ "User-Agent": "Flink-GitHub-App-Plugin",
155
+ },
156
+ });
157
+
158
+ if (!response.ok) {
159
+ const errorBody = await response.text();
160
+ const error: any = new Error(`GitHub API request failed: ${response.statusText}`);
161
+ error.status = response.status;
162
+ error.message = errorBody;
163
+ throw error;
164
+ }
165
+
166
+ const data = await response.json();
167
+ return data as InstallationTokenResponse;
168
+ }
169
+
170
+ /**
171
+ * Clear token cache
172
+ *
173
+ * Removes all cached installation tokens. Useful for testing,
174
+ * forcing token refresh, or plugin shutdown.
175
+ */
176
+ clearTokenCache(): void {
177
+ this.tokenCache.clearCache();
178
+ }
179
+
180
+ /**
181
+ * Delete specific installation token from cache
182
+ *
183
+ * @param installationId - GitHub installation ID
184
+ */
185
+ deleteInstallationToken(installationId: number): void {
186
+ this.tokenCache.deleteToken(installationId);
187
+ }
188
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Webhook Validator Service
3
+ *
4
+ * Validates GitHub webhook signatures and parses webhook payloads.
5
+ */
6
+
7
+ import { validateWebhookSignature } from "../utils/webhook-signature-utils";
8
+ import { createGitHubAppError, GitHubAppErrorCodes } from "../utils/error-utils";
9
+
10
+ /**
11
+ * Parsed webhook payload data
12
+ */
13
+ export interface WebhookPayload {
14
+ action?: string;
15
+ installation?: {
16
+ id: number;
17
+ [key: string]: any;
18
+ };
19
+ [key: string]: any;
20
+ }
21
+
22
+ /**
23
+ * Webhook validation result
24
+ */
25
+ export interface WebhookValidationResult {
26
+ isValid: boolean;
27
+ payload?: WebhookPayload;
28
+ error?: string;
29
+ }
30
+
31
+ /**
32
+ * Webhook Validator
33
+ *
34
+ * Handles webhook signature validation and payload parsing for GitHub webhooks.
35
+ * Uses constant-time comparison to prevent timing attacks.
36
+ */
37
+ export class WebhookValidator {
38
+ private secret: string;
39
+
40
+ /**
41
+ * Create Webhook Validator
42
+ *
43
+ * @param secret - Webhook secret configured in GitHub App
44
+ */
45
+ constructor(secret: string) {
46
+ if (!secret || typeof secret !== "string") {
47
+ throw createGitHubAppError(
48
+ GitHubAppErrorCodes.WEBHOOK_SIGNATURE_INVALID,
49
+ "Webhook secret is required and must be a string"
50
+ );
51
+ }
52
+
53
+ this.secret = secret;
54
+ }
55
+
56
+ /**
57
+ * Validate webhook signature
58
+ *
59
+ * Verifies the X-Hub-Signature-256 header using HMAC-SHA256 with
60
+ * constant-time comparison to prevent timing attacks.
61
+ *
62
+ * @param rawBody - Raw request body (string or Buffer)
63
+ * @param signature - X-Hub-Signature-256 header value
64
+ * @returns true if signature is valid, false otherwise
65
+ */
66
+ validateSignature(rawBody: string | Buffer, signature: string): boolean {
67
+ if (!rawBody || !signature) {
68
+ return false;
69
+ }
70
+
71
+ return validateWebhookSignature(rawBody, signature, this.secret);
72
+ }
73
+
74
+ /**
75
+ * Parse webhook payload
76
+ *
77
+ * Parses the JSON payload from a webhook request.
78
+ * Extracts common fields like action and installationId.
79
+ *
80
+ * @param body - Raw request body (string)
81
+ * @returns Parsed webhook payload
82
+ * @throws Error if payload is malformed JSON
83
+ */
84
+ parsePayload(body: string): WebhookPayload {
85
+ try {
86
+ const payload = JSON.parse(body);
87
+ return payload as WebhookPayload;
88
+ } catch (error: any) {
89
+ throw createGitHubAppError(
90
+ GitHubAppErrorCodes.WEBHOOK_PAYLOAD_INVALID,
91
+ "Failed to parse webhook payload as JSON",
92
+ { error: error.message }
93
+ );
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Extract event type from payload
99
+ *
100
+ * Gets the event type from the parsed payload.
101
+ * For installation webhooks, this is typically found in the 'action' field.
102
+ *
103
+ * @param payload - Parsed webhook payload
104
+ * @returns Event type (e.g., 'created', 'deleted')
105
+ */
106
+ extractEventType(payload: WebhookPayload): string | undefined {
107
+ return payload.action;
108
+ }
109
+
110
+ /**
111
+ * Extract installation ID from payload
112
+ *
113
+ * Gets the installation ID from the parsed payload.
114
+ *
115
+ * @param payload - Parsed webhook payload
116
+ * @returns Installation ID, or undefined if not present
117
+ */
118
+ extractInstallationId(payload: WebhookPayload): number | undefined {
119
+ return payload.installation?.id;
120
+ }
121
+
122
+ /**
123
+ * Validate and parse webhook in one step
124
+ *
125
+ * Convenience method that validates signature and parses payload
126
+ * in a single call.
127
+ *
128
+ * @param rawBody - Raw request body
129
+ * @param signature - X-Hub-Signature-256 header
130
+ * @returns Validation result with parsed payload if valid
131
+ */
132
+ validateAndParse(rawBody: string | Buffer, signature: string): WebhookValidationResult {
133
+ // Validate signature first
134
+ const isValid = this.validateSignature(rawBody, signature);
135
+
136
+ if (!isValid) {
137
+ return {
138
+ isValid: false,
139
+ error: "Invalid webhook signature",
140
+ };
141
+ }
142
+
143
+ // Parse payload
144
+ try {
145
+ const bodyString = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
146
+ const payload = this.parsePayload(bodyString);
147
+
148
+ return {
149
+ isValid: true,
150
+ payload,
151
+ };
152
+ } catch (error: any) {
153
+ return {
154
+ isValid: false,
155
+ error: error.message || "Failed to parse webhook payload",
156
+ };
157
+ }
158
+ }
159
+ }