@shipfox/api-integration-github 4.0.0 → 6.0.0

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 +52 -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 +19 -27
  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
@@ -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],
@@ -6,7 +6,6 @@ import {
6
6
  githubCallbackQuerySchema,
7
7
  githubCallbackResponseSchema,
8
8
  } from '@shipfox/api-integration-github-dto';
9
- import {requireWorkspaceMembership} from '@shipfox/api-workspaces';
10
9
  import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
11
10
  import type {GithubApiClient} from '#api/client.js';
12
11
  import {config} from '#config.js';
@@ -23,12 +22,18 @@ export interface CreateGithubIntegrationRoutesOptions {
23
22
  connectGithubInstallation: (
24
23
  input: ConnectGithubInstallationInput,
25
24
  ) => Promise<IntegrationConnection<'github'>>;
25
+ requireActiveWorkspaceMembership?: (input: {
26
+ workspaceId: string;
27
+ userId: string;
28
+ memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
29
+ }) => Promise<unknown>;
26
30
  }
27
31
 
28
32
  export function createGithubIntegrationRoutes({
29
33
  github,
30
34
  getExistingGithubConnection,
31
35
  connectGithubInstallation,
36
+ requireActiveWorkspaceMembership,
32
37
  }: CreateGithubIntegrationRoutesOptions): RouteGroup {
33
38
  const createInstallRoute = defineRoute({
34
39
  method: 'POST',
@@ -77,7 +82,8 @@ export function createGithubIntegrationRoutes({
77
82
  state: request.query.state,
78
83
  sessionUserId: actor.userId,
79
84
  sessionMemberships: actor.memberships,
80
- requireWorkspaceMembership,
85
+ requireWorkspaceMembership:
86
+ requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,
81
87
  getExistingGithubConnection,
82
88
  connectGithubInstallation,
83
89
  });
@@ -91,3 +97,11 @@ export function createGithubIntegrationRoutes({
91
97
  routes: [createInstallRoute, callbackApiRoute],
92
98
  };
93
99
  }
100
+
101
+ function unavailableWorkspaceMembershipCheck(_input: {
102
+ workspaceId: string;
103
+ userId: string;
104
+ memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
105
+ }): Promise<never> {
106
+ return Promise.reject(new Error('Workspaces inter-module client is not configured'));
107
+ }
@@ -1,21 +1,28 @@
1
- import {Buffer} from 'node:buffer';
2
- import {Webhooks} from '@octokit/webhooks';
1
+ import {randomUUID} from 'node:crypto';
3
2
  import type {
4
3
  GetIntegrationConnectionByIdFn,
5
4
  PublishIntegrationEventReceivedFn,
6
5
  PublishSourcePushFn,
7
6
  RecordDeliveryOnlyFn,
7
+ StoredWebhookRequest,
8
+ WebhookProcessingResult,
8
9
  } from '@shipfox/api-integration-core-dto';
9
10
  import {
11
+ createStoredWebhookRequest,
12
+ WEBHOOK_MAX_RAW_BODY_BYTES,
13
+ } from '@shipfox/api-integration-core-dto';
14
+ import {
15
+ ClientError,
10
16
  defineRoute,
11
17
  type RouteGroup,
12
18
  rawBodyPlugin,
13
19
  WEBHOOK_BODY_LIMIT,
14
20
  } from '@shipfox/node-fastify';
15
- import {logger} from '@shipfox/node-opentelemetry';
16
21
  import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
17
- import {config} from '#config.js';
18
- import {handleGithubEvent} from '#core/webhook.js';
22
+ import {
23
+ createGithubWebhookProcessor,
24
+ type GithubWebhookProcessor,
25
+ } from '#core/webhook-processor.js';
19
26
 
20
27
  const SIGNATURE_HEADER = 'x-hub-signature-256';
21
28
  const EVENT_HEADER = 'x-github-event';
@@ -30,10 +37,11 @@ export interface CreateGithubWebhookRoutesOptions {
30
37
  deleteInstallationTokenSecret?:
31
38
  | ((params: {workspaceId: string; installationId: number}) => Promise<unknown>)
32
39
  | undefined;
40
+ processor?: GithubWebhookProcessor | undefined;
33
41
  }
34
42
 
35
43
  export function createGithubWebhookRoutes(options: CreateGithubWebhookRoutesOptions): RouteGroup {
36
- const webhooks = new Webhooks({secret: config.GITHUB_APP_WEBHOOK_SECRET});
44
+ const processor = options.processor ?? createGithubWebhookProcessor(options);
37
45
 
38
46
  const pushRoute = defineRoute({
39
47
  method: 'POST',
@@ -59,50 +67,18 @@ export function createGithubWebhookRoutes(options: CreateGithubWebhookRoutesOpti
59
67
  return {error: 'missing X-GitHub-Event header'};
60
68
  }
61
69
 
62
- const body = request.body;
63
- if (!Buffer.isBuffer(body)) {
70
+ if (!(request.body instanceof Uint8Array)) {
64
71
  reply.code(400);
65
72
  return {error: 'expected raw JSON body'};
66
73
  }
67
- const rawBody = body.toString('utf8');
68
-
69
- let verified: boolean;
70
- try {
71
- verified = await webhooks.verify(rawBody, signature);
72
- } catch (error) {
73
- logger().warn({deliveryId, err: error}, 'github webhook signature verification threw');
74
- verified = false;
75
- }
76
- if (!verified) {
77
- reply.code(401);
78
- return {error: 'invalid signature'};
79
- }
80
-
81
- let parsedJson: unknown;
82
- try {
83
- parsedJson = JSON.parse(rawBody);
84
- } catch (error) {
85
- logger().warn({deliveryId, err: error}, 'github webhook payload JSON parse failed');
86
- reply.code(400);
87
- return {error: 'malformed JSON'};
88
- }
89
-
90
- await options.coreDb().transaction(async (tx) => {
91
- await handleGithubEvent({
92
- tx,
93
- deliveryId,
94
- event,
95
- payload: parsedJson,
96
- publishIntegrationEventReceived: options.publishIntegrationEventReceived,
97
- publishSourcePush: options.publishSourcePush,
98
- recordDeliveryOnly: options.recordDeliveryOnly,
99
- getIntegrationConnectionById: options.getIntegrationConnectionById,
100
- deleteInstallationTokenSecret: options.deleteInstallationTokenSecret,
101
- });
102
- });
103
-
104
- reply.code(204);
105
- return null;
74
+ const result = await processor.process(
75
+ createGithubStoredWebhookRequest({
76
+ body: request.body,
77
+ headers: request.headers,
78
+ rawQueryString: request.raw.url?.split('?')[1] ?? '',
79
+ }),
80
+ );
81
+ return sendGithubWebhookResponse(reply, result);
106
82
  },
107
83
  });
108
84
 
@@ -113,3 +89,61 @@ export function createGithubWebhookRoutes(options: CreateGithubWebhookRoutesOpti
113
89
  routes: [pushRoute],
114
90
  };
115
91
  }
92
+
93
+ function createGithubStoredWebhookRequest(input: {
94
+ body: Uint8Array;
95
+ headers: Record<string, string | string[] | undefined>;
96
+ rawQueryString: string;
97
+ }): StoredWebhookRequest {
98
+ if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
99
+ throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
100
+ }
101
+ try {
102
+ return createStoredWebhookRequest({
103
+ requestId: randomUUID(),
104
+ routeId: 'github',
105
+ receivedAt: new Date().toISOString(),
106
+ rawQueryString: input.rawQueryString,
107
+ headers: githubWebhookHeaders(input.headers),
108
+ body: input.body,
109
+ });
110
+ } catch (error) {
111
+ throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
112
+ cause: error,
113
+ });
114
+ }
115
+ }
116
+
117
+ function githubWebhookHeaders(headers: Record<string, string | string[] | undefined>) {
118
+ return Object.fromEntries(
119
+ ['content-type', DELIVERY_HEADER, EVENT_HEADER, SIGNATURE_HEADER].flatMap((name) => {
120
+ const value = headers[name];
121
+ return typeof value === 'string' ? [[name, value]] : [];
122
+ }),
123
+ );
124
+ }
125
+
126
+ function sendGithubWebhookResponse(
127
+ reply: {code(statusCode: number): void},
128
+ result: WebhookProcessingResult,
129
+ ) {
130
+ if (result.outcome !== 'discarded') {
131
+ reply.code(204);
132
+ return null;
133
+ }
134
+
135
+ switch (result.reason) {
136
+ case 'invalid_signature':
137
+ reply.code(401);
138
+ return {error: 'invalid signature'};
139
+ case 'malformed_payload':
140
+ reply.code(400);
141
+ return {error: 'malformed JSON'};
142
+ case 'connection_unavailable':
143
+ case 'missing_required_input':
144
+ case 'stale_at_receipt':
145
+ case 'unsupported_event':
146
+ reply.code(204);
147
+ return null;
148
+ }
149
+ }