@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +48 -0
- package/dist/core/webhook-processor.d.ts +18 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +83 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook.d.ts +7 -6
- package/dist/core/webhook.d.ts.map +1 -1
- package/dist/core/webhook.js +12 -28
- package/dist/core/webhook.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -6
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +6 -1
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js +5 -3
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +2 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +73 -56
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/src/core/webhook-processor.test.ts +166 -0
- package/src/core/webhook-processor.ts +97 -0
- package/src/core/webhook.test.ts +16 -16
- package/src/core/webhook.ts +29 -44
- package/src/index.test.ts +50 -0
- package/src/index.ts +24 -8
- package/src/presentation/routes/install.test.ts +2 -6
- package/src/presentation/routes/install.ts +16 -2
- package/src/presentation/routes/webhooks.ts +81 -47
- 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": "
|
|
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": "
|
|
31
|
-
"@shipfox/api-integration-core-dto": "
|
|
32
|
-
"@shipfox/api-integration-github-dto": "
|
|
33
|
-
"@shipfox/api-workspaces": "
|
|
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.
|
|
36
|
-
"@shipfox/node-fastify": "0.2.
|
|
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
|
+
}
|
package/src/core/webhook.test.ts
CHANGED
|
@@ -299,70 +299,70 @@ describe('handleGithubEvent', () => {
|
|
|
299
299
|
});
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
-
it('
|
|
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(
|
|
317
|
+
expect(result.installationTokenCleanup).toEqual({
|
|
320
318
|
workspaceId: connection.workspaceId,
|
|
321
319
|
installationId,
|
|
322
320
|
});
|
|
323
321
|
});
|
|
324
322
|
|
|
325
|
-
it('
|
|
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(
|
|
338
|
+
expect(result.installationTokenCleanup).toEqual({
|
|
339
|
+
workspaceId: connection.workspaceId,
|
|
340
|
+
installationId,
|
|
341
|
+
});
|
|
343
342
|
});
|
|
344
343
|
|
|
345
|
-
it('
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 () => {
|
package/src/core/webhook.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
114
|
-
|
|
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.
|
|
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],
|