@shipfox/api-integration-github 5.0.0 → 7.0.1

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 (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/dist/core/webhook-processor.d.ts +18 -0
  4. package/dist/core/webhook-processor.d.ts.map +1 -0
  5. package/dist/core/webhook-processor.js +83 -0
  6. package/dist/core/webhook-processor.js.map +1 -0
  7. package/dist/core/webhook.d.ts +7 -6
  8. package/dist/core/webhook.d.ts.map +1 -1
  9. package/dist/core/webhook.js +12 -28
  10. package/dist/core/webhook.js.map +1 -1
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +25 -6
  14. package/dist/index.js.map +1 -1
  15. package/dist/presentation/routes/install.d.ts +6 -1
  16. package/dist/presentation/routes/install.d.ts.map +1 -1
  17. package/dist/presentation/routes/install.js +5 -3
  18. package/dist/presentation/routes/install.js.map +1 -1
  19. package/dist/presentation/routes/webhooks.d.ts +2 -0
  20. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  21. package/dist/presentation/routes/webhooks.js +73 -56
  22. package/dist/presentation/routes/webhooks.js.map +1 -1
  23. package/dist/tsconfig.test.tsbuildinfo +1 -1
  24. package/package.json +9 -7
  25. package/src/core/webhook-processor.test.ts +166 -0
  26. package/src/core/webhook-processor.ts +97 -0
  27. package/src/core/webhook.test.ts +16 -16
  28. package/src/core/webhook.ts +29 -44
  29. package/src/index.test.ts +50 -0
  30. package/src/index.ts +24 -8
  31. package/src/presentation/routes/install.test.ts +2 -6
  32. package/src/presentation/routes/install.ts +16 -2
  33. package/src/presentation/routes/webhooks.ts +81 -47
  34. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-github",
3
3
  "license": "MIT",
4
- "version": "5.0.0",
4
+ "version": "7.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,13 +27,13 @@
27
27
  "ky": "^2.0.0",
28
28
  "octokit": "^5.0.3",
29
29
  "zod": "^4.4.3",
30
- "@shipfox/api-auth-context": "5.0.0",
31
- "@shipfox/api-integration-core-dto": "5.0.0",
32
- "@shipfox/api-integration-github-dto": "5.0.0",
33
- "@shipfox/api-workspaces": "5.0.0",
30
+ "@shipfox/api-auth-context": "6.0.0",
31
+ "@shipfox/api-integration-core-dto": "6.0.0",
32
+ "@shipfox/api-integration-github-dto": "6.0.0",
33
+ "@shipfox/api-workspaces": "7.0.1",
34
34
  "@shipfox/config": "1.2.2",
35
- "@shipfox/node-drizzle": "0.3.1",
36
- "@shipfox/node-fastify": "0.2.3",
35
+ "@shipfox/node-drizzle": "0.3.2",
36
+ "@shipfox/node-fastify": "0.2.4",
37
37
  "@shipfox/node-postgres": "0.4.2",
38
38
  "@shipfox/node-opentelemetry": "0.5.2"
39
39
  },
@@ -44,6 +44,7 @@
44
44
  "fastify-type-provider-zod": "^6.0.0",
45
45
  "fishery": "^2.4.0",
46
46
  "@shipfox/biome": "1.8.2",
47
+ "@shipfox/depcruise": "1.0.2",
47
48
  "@shipfox/swc": "1.2.6",
48
49
  "@shipfox/ts-config": "1.3.8",
49
50
  "@shipfox/typescript": "1.1.7",
@@ -53,6 +54,7 @@
53
54
  "build": "shipfox-swc",
54
55
  "check": "shipfox-biome-check",
55
56
  "check:fix": "shipfox-biome-check --write",
57
+ "depcruise": "shipfox-depcruise",
56
58
  "test": "shipfox-vitest-run",
57
59
  "test:watch": "shipfox-vitest-watch",
58
60
  "type": "shipfox-tsc-check",
@@ -0,0 +1,166 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import {
3
+ createStoredWebhookRequest,
4
+ decodeWebhookBody,
5
+ type IntegrationConnection,
6
+ } from '@shipfox/api-integration-core-dto';
7
+ import {db} from '#db/db.js';
8
+ import {githubInstallations} from '#db/schema/installations.js';
9
+ import {githubInstallationFactory} from '#test/index.js';
10
+ import {createGithubWebhookProcessor} from './webhook-processor.js';
11
+
12
+ const WEBHOOK_SECRET = 'test-webhook-secret';
13
+
14
+ function fakeConnection(id: string): IntegrationConnection {
15
+ return {
16
+ id,
17
+ workspaceId: randomUUID(),
18
+ provider: 'github',
19
+ externalAccountId: '123',
20
+ slug: 'github_shipfox',
21
+ displayName: 'GitHub shipfox',
22
+ lifecycleStatus: 'active',
23
+ createdAt: new Date(),
24
+ updatedAt: new Date(),
25
+ };
26
+ }
27
+
28
+ function signedInstallationRequest(deliveryId: string, installationId: number) {
29
+ const body = Buffer.from(JSON.stringify({action: 'deleted', installation: {id: installationId}}));
30
+ return createStoredWebhookRequest({
31
+ requestId: randomUUID(),
32
+ routeId: 'github',
33
+ receivedAt: new Date().toISOString(),
34
+ rawQueryString: '',
35
+ headers: {
36
+ 'x-github-delivery': deliveryId,
37
+ 'x-github-event': 'installation',
38
+ 'x-hub-signature-256': `sha256=${createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}`,
39
+ },
40
+ body,
41
+ });
42
+ }
43
+
44
+ describe('GitHub webhook processor', () => {
45
+ beforeEach(async () => {
46
+ await db().delete(githubInstallations);
47
+ });
48
+
49
+ it('discards a stored request with an invalid signature before opening a transaction', async () => {
50
+ const coreDb = vi.fn();
51
+ const processor = createGithubWebhookProcessor({
52
+ coreDb,
53
+ publishIntegrationEventReceived: vi.fn(),
54
+ publishSourcePush: vi.fn(),
55
+ recordDeliveryOnly: vi.fn(),
56
+ getIntegrationConnectionById: vi.fn(),
57
+ });
58
+ const request = createStoredWebhookRequest({
59
+ requestId: randomUUID(),
60
+ routeId: 'github',
61
+ receivedAt: new Date().toISOString(),
62
+ rawQueryString: '',
63
+ headers: {
64
+ 'x-github-delivery': randomUUID(),
65
+ 'x-github-event': 'push',
66
+ 'x-hub-signature-256': 'sha256=not-a-valid-signature',
67
+ },
68
+ body: Buffer.from('{}'),
69
+ });
70
+
71
+ const result = await processor.process(request);
72
+
73
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
74
+ expect(coreDb).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('discards a stored request missing required provider headers', async () => {
78
+ const processor = createGithubWebhookProcessor({
79
+ coreDb: vi.fn(),
80
+ publishIntegrationEventReceived: vi.fn(),
81
+ publishSourcePush: vi.fn(),
82
+ recordDeliveryOnly: vi.fn(),
83
+ getIntegrationConnectionById: vi.fn(),
84
+ });
85
+ const request = createStoredWebhookRequest({
86
+ requestId: randomUUID(),
87
+ routeId: 'github',
88
+ receivedAt: new Date().toISOString(),
89
+ rawQueryString: '',
90
+ headers: {},
91
+ body: Buffer.from('{}'),
92
+ });
93
+
94
+ const result = await processor.process(request);
95
+
96
+ expect(result).toEqual({outcome: 'discarded', reason: 'missing_required_input'});
97
+ });
98
+
99
+ it('preserves the signed raw body from a stored request before reporting malformed JSON', async () => {
100
+ const rawBody = Buffer.from('{"message":"h\u00e9llo"');
101
+ const request = createStoredWebhookRequest({
102
+ requestId: randomUUID(),
103
+ routeId: 'github',
104
+ receivedAt: new Date().toISOString(),
105
+ rawQueryString: '',
106
+ headers: {
107
+ 'x-github-delivery': randomUUID(),
108
+ 'x-github-event': 'push',
109
+ 'x-hub-signature-256': `sha256=${createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex')}`,
110
+ },
111
+ body: rawBody,
112
+ });
113
+ const processor = createGithubWebhookProcessor({
114
+ coreDb: () =>
115
+ ({transaction: (callback: (tx: unknown) => Promise<unknown>) => callback({})}) as never,
116
+ publishIntegrationEventReceived: vi.fn(),
117
+ publishSourcePush: vi.fn(),
118
+ recordDeliveryOnly: vi.fn(),
119
+ getIntegrationConnectionById: vi.fn(),
120
+ });
121
+
122
+ const result = await processor.process(request);
123
+
124
+ expect(Buffer.from(decodeWebhookBody(request.body))).toEqual(rawBody);
125
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'malformed_payload'});
126
+ });
127
+
128
+ it('retries credential cleanup after the delivery transaction commits', async () => {
129
+ const installationId = 8412;
130
+ const connectionId = randomUUID();
131
+ const connection = fakeConnection(connectionId);
132
+ const deliveryId = randomUUID();
133
+ await githubInstallationFactory.create({
134
+ connectionId,
135
+ installationId: String(installationId),
136
+ });
137
+ const publishIntegrationEventReceived = vi
138
+ .fn()
139
+ .mockResolvedValueOnce({published: true})
140
+ .mockResolvedValueOnce({published: false});
141
+ const deleteInstallationTokenSecret = vi
142
+ .fn()
143
+ .mockRejectedValueOnce(new Error('secret store unavailable'))
144
+ .mockResolvedValueOnce(1);
145
+ const processor = createGithubWebhookProcessor({
146
+ coreDb: db,
147
+ publishIntegrationEventReceived,
148
+ publishSourcePush: vi.fn(),
149
+ recordDeliveryOnly: vi.fn(),
150
+ getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
151
+ deleteInstallationTokenSecret,
152
+ });
153
+ const request = signedInstallationRequest(deliveryId, installationId);
154
+
155
+ const firstAttempt = processor.process(request);
156
+ await expect(firstAttempt).rejects.toThrow('secret store unavailable');
157
+ const retryResult = await processor.process(request);
158
+
159
+ expect(retryResult).toEqual({outcome: 'duplicate', deliveryId});
160
+ expect(deleteInstallationTokenSecret).toHaveBeenCalledTimes(2);
161
+ expect(deleteInstallationTokenSecret).toHaveBeenLastCalledWith({
162
+ workspaceId: connection.workspaceId,
163
+ installationId,
164
+ });
165
+ });
166
+ });
@@ -0,0 +1,97 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {Webhooks} from '@octokit/webhooks';
3
+ import {
4
+ decodeWebhookBody,
5
+ type GetIntegrationConnectionByIdFn,
6
+ type PublishIntegrationEventReceivedFn,
7
+ type PublishSourcePushFn,
8
+ type RecordDeliveryOnlyFn,
9
+ type StoredWebhookRequest,
10
+ type WebhookProcessingResult,
11
+ } from '@shipfox/api-integration-core-dto';
12
+ import {logger} from '@shipfox/node-opentelemetry';
13
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
14
+ import {config} from '#config.js';
15
+ import {handleGithubEvent} from '#core/webhook.js';
16
+
17
+ const DELIVERY_HEADER = 'x-github-delivery';
18
+ const EVENT_HEADER = 'x-github-event';
19
+ const SIGNATURE_HEADER = 'x-hub-signature-256';
20
+
21
+ export interface CreateGithubWebhookProcessorOptions {
22
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
23
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
24
+ publishSourcePush: PublishSourcePushFn;
25
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
26
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
27
+ deleteInstallationTokenSecret?:
28
+ | ((params: {workspaceId: string; installationId: number}) => Promise<unknown>)
29
+ | undefined;
30
+ }
31
+
32
+ export interface GithubWebhookProcessor {
33
+ process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
34
+ }
35
+
36
+ export function createGithubWebhookProcessor(
37
+ options: CreateGithubWebhookProcessorOptions,
38
+ ): GithubWebhookProcessor {
39
+ const webhooks = new Webhooks({secret: config.GITHUB_APP_WEBHOOK_SECRET});
40
+ return {process: (request) => processGithubWebhookRequest(options, webhooks, request)};
41
+ }
42
+
43
+ async function processGithubWebhookRequest(
44
+ options: CreateGithubWebhookProcessorOptions,
45
+ webhooks: Webhooks,
46
+ request: StoredWebhookRequest,
47
+ ): Promise<WebhookProcessingResult> {
48
+ if (request.route_id !== 'github') {
49
+ throw new Error(`GitHub processor cannot process ${request.route_id} requests`);
50
+ }
51
+
52
+ const deliveryId = request.headers[DELIVERY_HEADER];
53
+ const event = request.headers[EVENT_HEADER];
54
+ const signature = request.headers[SIGNATURE_HEADER];
55
+ if (!deliveryId || !event || !signature) {
56
+ return {outcome: 'discarded', reason: 'missing_required_input'};
57
+ }
58
+
59
+ const rawBody = Buffer.from(decodeWebhookBody(request.body));
60
+ let verified: boolean;
61
+ try {
62
+ verified = await webhooks.verify(rawBody.toString('utf8'), signature);
63
+ } catch (error) {
64
+ logger().warn({deliveryId, err: error}, 'github webhook signature verification threw');
65
+ verified = false;
66
+ }
67
+ if (!verified) return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};
68
+
69
+ let payload: unknown;
70
+ try {
71
+ payload = JSON.parse(rawBody.toString('utf8'));
72
+ } catch (error) {
73
+ logger().warn({deliveryId, err: error}, 'github webhook payload JSON parse failed');
74
+ return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};
75
+ }
76
+
77
+ const result = await options.coreDb().transaction(async (tx) =>
78
+ handleGithubEvent({
79
+ tx,
80
+ deliveryId,
81
+ event,
82
+ payload,
83
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
84
+ publishSourcePush: options.publishSourcePush,
85
+ recordDeliveryOnly: options.recordDeliveryOnly,
86
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
87
+ }),
88
+ );
89
+
90
+ if (result.installationTokenCleanup && options.deleteInstallationTokenSecret) {
91
+ await options.deleteInstallationTokenSecret(result.installationTokenCleanup);
92
+ }
93
+
94
+ return result.outcome.startsWith('duplicate')
95
+ ? {outcome: 'duplicate', deliveryId}
96
+ : {outcome: 'processed', deliveryId};
97
+ }
@@ -299,70 +299,70 @@ describe('handleGithubEvent', () => {
299
299
  });
300
300
  });
301
301
 
302
- it('requests cached installation token cleanup when the installation is deleted', async () => {
302
+ it('returns the cached installation token cleanup handle when the installation is deleted', async () => {
303
303
  const installationId = 7791;
304
304
  const connection = fakeConnection();
305
305
  await seedInstallation(installationId, connection.id);
306
306
  const handlers = deps({connection});
307
- const deleteInstallationTokenSecret = vi.fn(() => Promise.resolve());
308
307
 
309
308
  const result = await handleGithubEvent({
310
309
  tx: db(),
311
310
  deliveryId: randomUUID(),
312
311
  event: 'installation',
313
312
  payload: {action: 'deleted', installation: {id: installationId}},
314
- deleteInstallationTokenSecret,
315
313
  ...handlers,
316
314
  });
317
315
 
318
316
  expect(result.outcome).toBe('published-envelope');
319
- expect(deleteInstallationTokenSecret).toHaveBeenCalledWith({
317
+ expect(result.installationTokenCleanup).toEqual({
320
318
  workspaceId: connection.workspaceId,
321
319
  installationId,
322
320
  });
323
321
  });
324
322
 
325
- it('does not clean up cached installation tokens for duplicate lifecycle deliveries', async () => {
323
+ it('keeps the cleanup handle for duplicate lifecycle deliveries', async () => {
326
324
  const installationId = 7792;
327
325
  const connection = fakeConnection();
328
326
  await seedInstallation(installationId, connection.id);
329
327
  const handlers = deps({connection, publishIntegrationEventReceivedResult: {published: false}});
330
- const deleteInstallationTokenSecret = vi.fn(() => Promise.resolve());
331
328
 
332
329
  const result = await handleGithubEvent({
333
330
  tx: db(),
334
331
  deliveryId: randomUUID(),
335
332
  event: 'installation',
336
333
  payload: {action: 'suspend', installation: {id: installationId}},
337
- deleteInstallationTokenSecret,
338
334
  ...handlers,
339
335
  });
340
336
 
341
337
  expect(result.outcome).toBe('duplicate-envelope');
342
- expect(deleteInstallationTokenSecret).not.toHaveBeenCalled();
338
+ expect(result.installationTokenCleanup).toEqual({
339
+ workspaceId: connection.workspaceId,
340
+ installationId,
341
+ });
343
342
  });
344
343
 
345
- it('does not fail lifecycle webhook handling when cached token cleanup fails', async () => {
344
+ it('returns the cleanup handle for a deletion delivered after the connection is disabled', async () => {
346
345
  const installationId = 7793;
347
- const connection = fakeConnection();
346
+ const connection = fakeConnection({lifecycleStatus: 'disabled'});
348
347
  await seedInstallation(installationId, connection.id);
349
348
  const handlers = deps({connection});
350
- const deleteInstallationTokenSecret = vi.fn(() => Promise.reject(new Error('store down')));
351
349
 
352
350
  const result = await handleGithubEvent({
353
351
  tx: db(),
354
352
  deliveryId: randomUUID(),
355
353
  event: 'installation',
356
354
  payload: {action: 'deleted', installation: {id: installationId}},
357
- deleteInstallationTokenSecret,
358
355
  ...handlers,
359
356
  });
360
357
 
361
- expect(result.outcome).toBe('published-envelope');
362
- expect(deleteInstallationTokenSecret).toHaveBeenCalledWith({
363
- workspaceId: connection.workspaceId,
364
- installationId,
358
+ expect(result).toEqual({
359
+ outcome: 'inactive-connection',
360
+ installationTokenCleanup: {
361
+ workspaceId: connection.workspaceId,
362
+ installationId,
363
+ },
365
364
  });
365
+ expect(handlers.recordDeliveryOnly).toHaveBeenCalledTimes(1);
366
366
  });
367
367
 
368
368
  it('publishes a bare resource envelope when action is malformed', async () => {
@@ -30,9 +30,6 @@ export interface HandleGithubEventParams {
30
30
  publishSourcePush: PublishSourcePushFn;
31
31
  recordDeliveryOnly: RecordDeliveryOnlyFn;
32
32
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
33
- deleteInstallationTokenSecret?:
34
- | ((params: {workspaceId: string; installationId: number}) => Promise<unknown>)
35
- | undefined;
36
33
  }
37
34
 
38
35
  export type HandleGithubEventOutcome =
@@ -47,13 +44,18 @@ export type HandleGithubEventOutcome =
47
44
  | 'inactive-connection'
48
45
  | 'no-installation-id';
49
46
 
47
+ export interface HandleGithubEventResult {
48
+ outcome: HandleGithubEventOutcome;
49
+ installationTokenCleanup?: {workspaceId: string; installationId: number} | undefined;
50
+ }
51
+
50
52
  function isBranchDeletion(after: string): boolean {
51
53
  return after === DELETED_BRANCH_SHA;
52
54
  }
53
55
 
54
56
  export async function handleGithubEvent(
55
57
  params: HandleGithubEventParams,
56
- ): Promise<{outcome: HandleGithubEventOutcome}> {
58
+ ): Promise<HandleGithubEventResult> {
57
59
  const actionEnvelope = githubWebhookActionSchema.safeParse(params.payload);
58
60
  const action = actionEnvelope.success ? actionEnvelope.data.action : undefined;
59
61
  const installationEnvelope = githubWebhookInstallationSchema.safeParse(params.payload);
@@ -120,7 +122,13 @@ export async function handleGithubEvent(
120
122
  provider: GITHUB_SOURCE,
121
123
  deliveryId: params.deliveryId,
122
124
  });
123
- return {outcome: 'inactive-connection'};
125
+ return withInstallationTokenCleanup(
126
+ {outcome: 'inactive-connection'},
127
+ params.event,
128
+ action,
129
+ connection.workspaceId,
130
+ installationId,
131
+ );
124
132
  }
125
133
 
126
134
  if (params.event === 'push') {
@@ -157,51 +165,28 @@ export async function handleGithubEvent(
157
165
  connection,
158
166
  event: eventName,
159
167
  });
160
- if (result.outcome === 'published-envelope') {
161
- await deleteInstallationTokenSecretBestEffort({
162
- deleteInstallationTokenSecret: params.deleteInstallationTokenSecret,
163
- event: params.event,
164
- action,
165
- deliveryId: params.deliveryId,
166
- workspaceId: connection.workspaceId,
167
- installationId,
168
- });
169
- }
170
- return result;
168
+ return withInstallationTokenCleanup(
169
+ result,
170
+ params.event,
171
+ action,
172
+ connection.workspaceId,
173
+ installationId,
174
+ );
171
175
  }
172
176
 
173
177
  function shouldDeleteInstallationTokenSecret(event: string, action: string | undefined): boolean {
174
178
  return event === 'installation' && (action === 'deleted' || action === 'suspend');
175
179
  }
176
180
 
177
- async function deleteInstallationTokenSecretBestEffort(params: {
178
- deleteInstallationTokenSecret:
179
- | ((params: {workspaceId: string; installationId: number}) => Promise<unknown>)
180
- | undefined;
181
- event: string;
182
- action: string | undefined;
183
- deliveryId: string;
184
- workspaceId: string;
185
- installationId: number;
186
- }): Promise<void> {
187
- if (!shouldDeleteInstallationTokenSecret(params.event, params.action)) return;
188
-
189
- try {
190
- await params.deleteInstallationTokenSecret?.({
191
- workspaceId: params.workspaceId,
192
- installationId: params.installationId,
193
- });
194
- } catch (error) {
195
- logger().warn(
196
- {
197
- deliveryId: params.deliveryId,
198
- installationId: params.installationId,
199
- workspaceId: params.workspaceId,
200
- error,
201
- },
202
- 'github webhook installation token cleanup failed',
203
- );
204
- }
181
+ function withInstallationTokenCleanup(
182
+ result: HandleGithubEventResult,
183
+ event: string,
184
+ action: string | undefined,
185
+ workspaceId: string,
186
+ installationId: number,
187
+ ): HandleGithubEventResult {
188
+ if (!shouldDeleteInstallationTokenSecret(event, action)) return result;
189
+ return {...result, installationTokenCleanup: {workspaceId, installationId}};
205
190
  }
206
191
 
207
192
  async function publishGithubPush(params: {
@@ -0,0 +1,50 @@
1
+ import {githubInstallationTokenNamespace} from '#api/installation-token-envelope.js';
2
+ import {createGithubIntegrationProvider} from '#index.js';
3
+
4
+ const {createProcessor, state} = vi.hoisted(() => {
5
+ const state: {processorOptions: unknown} = {processorOptions: undefined};
6
+ return {
7
+ state,
8
+ createProcessor: vi.fn((options: unknown) => {
9
+ state.processorOptions = options;
10
+ return {process: vi.fn()};
11
+ }),
12
+ };
13
+ });
14
+
15
+ vi.mock('#core/webhook-processor.js', () => ({
16
+ createGithubWebhookProcessor: createProcessor,
17
+ }));
18
+
19
+ describe('createGithubIntegrationProvider', () => {
20
+ it('shares installation-token cleanup with the direct and composed processors', async () => {
21
+ const deleteSecrets = vi.fn(() => Promise.resolve(1));
22
+ createGithubIntegrationProvider({
23
+ github: {} as never,
24
+ getExistingGithubConnection: vi.fn(() => Promise.resolve(undefined)),
25
+ connectGithubInstallation: vi.fn() as never,
26
+ coreDb: vi.fn() as never,
27
+ publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: false})),
28
+ publishSourcePush: vi.fn(() => Promise.resolve({published: false})),
29
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
30
+ getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
31
+ deleteSecrets,
32
+ });
33
+ const processorOptions = state.processorOptions as {
34
+ deleteInstallationTokenSecret: (params: {
35
+ workspaceId: string;
36
+ installationId: number;
37
+ }) => Promise<unknown>;
38
+ };
39
+
40
+ await processorOptions.deleteInstallationTokenSecret({
41
+ workspaceId: 'workspace-1',
42
+ installationId: 123,
43
+ });
44
+
45
+ expect(deleteSecrets).toHaveBeenCalledWith({
46
+ workspaceId: 'workspace-1',
47
+ namespace: githubInstallationTokenNamespace(123),
48
+ });
49
+ });
50
+ });
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import type {GithubInstallationTokenProvider} from '#api/installation-token-prov
10
10
  import {deleteGithubInstallationTokenSecret} from '#api/installation-token-provider.js';
11
11
  import {GithubAgentToolsProvider} from '#core/agent-tools.js';
12
12
  import {GithubSourceControlProvider} from '#core/source-control.js';
13
+ import {createGithubWebhookProcessor} from '#core/webhook-processor.js';
13
14
  import {closeDb, db} from '#db/db.js';
14
15
  import {getGithubInstallationByConnectionId} from '#db/installations.js';
15
16
  import {migrationsPath} from '#db/migrations.js';
@@ -49,6 +50,11 @@ export {handleGithubCallback} from '#core/install.js';
49
50
  export {signGithubInstallState, verifyGithubInstallState} from '#core/state.js';
50
51
  export type {HandleGithubEventOutcome} from '#core/webhook.js';
51
52
  export {handleGithubEvent} from '#core/webhook.js';
53
+ export type {
54
+ CreateGithubWebhookProcessorOptions,
55
+ GithubWebhookProcessor,
56
+ } from '#core/webhook-processor.js';
57
+ export {createGithubWebhookProcessor} from '#core/webhook-processor.js';
52
58
  export type {GithubInstallation, UpsertGithubInstallationParams} from '#db/installations.js';
53
59
  export {
54
60
  getGithubInstallationByConnectionId,
@@ -77,6 +83,18 @@ export function createGithubIntegrationProvider(options: CreateGithubIntegration
77
83
  const getInstallationByConnectionId =
78
84
  options.getGithubInstallationByConnectionId ?? getGithubInstallationByConnectionId;
79
85
  const deleteSecrets = options.deleteSecrets;
86
+ const deleteInstallationTokenSecret = deleteSecrets
87
+ ? (params: {workspaceId: string; installationId: number}) =>
88
+ deleteGithubInstallationTokenSecret({
89
+ workspaceId: params.workspaceId,
90
+ installationId: params.installationId,
91
+ deleteSecrets,
92
+ })
93
+ : undefined;
94
+ const webhookProcessor = createGithubWebhookProcessor({
95
+ ...options,
96
+ deleteInstallationTokenSecret,
97
+ });
80
98
 
81
99
  return {
82
100
  provider: 'github' as const,
@@ -103,6 +121,9 @@ export function createGithubIntegrationProvider(options: CreateGithubIntegration
103
121
  github,
104
122
  getExistingGithubConnection: options.getExistingGithubConnection,
105
123
  connectGithubInstallation: options.connectGithubInstallation,
124
+ ...(options.requireActiveWorkspaceMembership
125
+ ? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
126
+ : {}),
106
127
  }),
107
128
  createGithubWebhookRoutes({
108
129
  coreDb: options.coreDb,
@@ -110,15 +131,10 @@ export function createGithubIntegrationProvider(options: CreateGithubIntegration
110
131
  publishSourcePush: options.publishSourcePush,
111
132
  recordDeliveryOnly: options.recordDeliveryOnly,
112
133
  getIntegrationConnectionById: options.getIntegrationConnectionById,
113
- deleteInstallationTokenSecret: deleteSecrets
114
- ? (params) =>
115
- deleteGithubInstallationTokenSecret({
116
- workspaceId: params.workspaceId,
117
- installationId: params.installationId,
118
- deleteSecrets,
119
- })
120
- : undefined,
134
+ deleteInstallationTokenSecret,
135
+ processor: webhookProcessor,
121
136
  }),
122
137
  ],
138
+ webhookProcessors: [{routeIds: ['github'] as const, processor: webhookProcessor}],
123
139
  };
124
140
  }
@@ -15,12 +15,7 @@ import type {ConnectGithubInstallationInput} from '#core/install.js';
15
15
  import {verifyGithubInstallState} from '#core/state.js';
16
16
  import {createGithubIntegrationProvider} from '#index.js';
17
17
 
18
- vi.mock('@shipfox/api-workspaces', () => ({
19
- requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
20
- }));
21
-
22
- const {requireWorkspaceMembership} = await import('@shipfox/api-workspaces');
23
- const requireWorkspaceMembershipMock = vi.mocked(requireWorkspaceMembership);
18
+ const requireWorkspaceMembershipMock = vi.fn(() => Promise.resolve());
24
19
  let authenticatedMemberships: UserContextMembership[] = [];
25
20
 
26
21
  const fakeUserAuth: AuthMethod = {
@@ -111,6 +106,7 @@ async function createTestApp(options: CreateTestAppOptions = {}): Promise<Fastif
111
106
  publishSourcePush: vi.fn(() => Promise.resolve({published: false})),
112
107
  recordDeliveryOnly: vi.fn(() => Promise.resolve()),
113
108
  getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
109
+ requireActiveWorkspaceMembership: requireWorkspaceMembershipMock,
114
110
  });
115
111
  const app = await createApp({
116
112
  auth: [fakeUserAuth],