@shipfox/api-integration-linear 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 +56 -0
  3. package/dist/core/webhook-processor.d.ts +13 -0
  4. package/dist/core/webhook-processor.d.ts.map +1 -0
  5. package/dist/core/webhook-processor.js +130 -0
  6. package/dist/core/webhook-processor.js.map +1 -0
  7. package/dist/index.d.ts +28 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +19 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/presentation/routes/install.d.ts +6 -1
  12. package/dist/presentation/routes/install.d.ts.map +1 -1
  13. package/dist/presentation/routes/install.js +6 -4
  14. package/dist/presentation/routes/install.js.map +1 -1
  15. package/dist/presentation/routes/webhooks.d.ts +2 -0
  16. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  17. package/dist/presentation/routes/webhooks.js +75 -79
  18. package/dist/presentation/routes/webhooks.js.map +1 -1
  19. package/dist/tsconfig.test.tsbuildinfo +1 -1
  20. package/package.json +20 -31
  21. package/src/core/disconnect.test.ts +47 -0
  22. package/src/core/tokens.test.ts +16 -2
  23. package/src/core/webhook-processor.test.ts +154 -0
  24. package/src/core/webhook-processor.ts +127 -0
  25. package/src/index.test.ts +11 -0
  26. package/src/index.ts +26 -2
  27. package/src/presentation/routes/install.test.ts +2 -6
  28. package/src/presentation/routes/install.ts +18 -3
  29. package/src/presentation/routes/webhooks.test.ts +37 -0
  30. package/src/presentation/routes/webhooks.ts +88 -86
  31. package/test/globalSetup.ts +0 -9
  32. package/tsconfig.build.tsbuildinfo +1 -1
  33. package/turbo.json +9 -0
  34. package/test/api-secrets.d.ts +0 -28
@@ -1,5 +1,6 @@
1
1
  import {createHmac, randomUUID} from 'node:crypto';
2
2
  import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {WEBHOOK_MAX_RAW_BODY_BYTES} from '@shipfox/api-integration-core-dto';
3
4
  import {closeApp, createApp} from '@shipfox/node-fastify';
4
5
  import type {FastifyInstance} from 'fastify';
5
6
  import {db} from '#db/db.js';
@@ -267,6 +268,42 @@ describe('Linear webhook route', () => {
267
268
  expect(recordDeliveryOnly).not.toHaveBeenCalled();
268
269
  });
269
270
 
271
+ it('rejects a body too large for the shared webhook request before processing it', async () => {
272
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
273
+ const body = 'a'.repeat(WEBHOOK_MAX_RAW_BODY_BYTES + 1);
274
+
275
+ const res = await app.inject({
276
+ method: 'POST',
277
+ url: '/webhooks/integrations/linear',
278
+ headers: signedHeaders(body, 'Issue', randomUUID()),
279
+ payload: body,
280
+ });
281
+
282
+ expect(res.statusCode).toBe(413);
283
+ expect(res.json()).toEqual({code: 'body-too-large'});
284
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
285
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it('rejects invalid stored request metadata before processing it', async () => {
289
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
290
+ const body = JSON.stringify(linearPayload());
291
+ const headers = signedHeaders(body, 'Issue', randomUUID());
292
+ headers['linear-event'] = 'a'.repeat(8 * 1024 + 1);
293
+
294
+ const res = await app.inject({
295
+ method: 'POST',
296
+ url: '/webhooks/integrations/linear',
297
+ headers,
298
+ payload: body,
299
+ });
300
+
301
+ expect(res.statusCode).toBe(400);
302
+ expect(res.json()).toEqual({code: 'invalid-webhook-request'});
303
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
304
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
305
+ });
306
+
270
307
  it('rejects malformed JSON after signature verification', async () => {
271
308
  const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
272
309
  const body = '{"type":';
@@ -1,39 +1,42 @@
1
- import {Buffer} from 'node:buffer';
1
+ import {randomUUID} from 'node:crypto';
2
2
  import type {
3
3
  GetIntegrationConnectionByIdFn,
4
- IntegrationTx,
5
4
  PublishIntegrationEventReceivedFn,
6
5
  RecordDeliveryOnlyFn,
6
+ StoredWebhookRequest,
7
+ WebhookProcessingResult,
7
8
  } from '@shipfox/api-integration-core-dto';
8
9
  import {
9
- LINEAR_PROVIDER,
10
- linearWebhookBaseEnvelopeSchema,
11
- } from '@shipfox/api-integration-linear-dto';
10
+ createStoredWebhookRequest,
11
+ WEBHOOK_MAX_RAW_BODY_BYTES,
12
+ } from '@shipfox/api-integration-core-dto';
12
13
  import {
14
+ ClientError,
13
15
  defineRoute,
14
16
  type RouteGroup,
15
17
  rawBodyPlugin,
16
- verifyHexHmacSignature,
17
18
  WEBHOOK_BODY_LIMIT,
18
19
  } from '@shipfox/node-fastify';
19
- import {logger} from '@shipfox/node-opentelemetry';
20
20
  import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
21
- import {config} from '#config.js';
22
- import {handleLinearWebhook} from '#core/webhook.js';
21
+ import {
22
+ createLinearWebhookProcessor,
23
+ type LinearWebhookProcessor,
24
+ } from '#core/webhook-processor.js';
23
25
 
24
26
  const DELIVERY_HEADER = 'linear-delivery';
25
27
  const EVENT_HEADER = 'linear-event';
26
28
  const SIGNATURE_HEADER = 'linear-signature';
27
- const WEBHOOK_REPLAY_WINDOW_MS = 60_000;
28
29
 
29
30
  export interface CreateLinearWebhookRoutesOptions {
30
31
  coreDb: () => NodePgDatabase<Record<string, unknown>>;
31
32
  publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
32
33
  recordDeliveryOnly: RecordDeliveryOnlyFn;
33
34
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
35
+ processor?: LinearWebhookProcessor | undefined;
34
36
  }
35
37
 
36
38
  export function createLinearWebhookRoutes(options: CreateLinearWebhookRoutesOptions): RouteGroup {
39
+ const processor = options.processor ?? createLinearWebhookProcessor(options);
37
40
  const webhookRoute = defineRoute({
38
41
  method: 'POST',
39
42
  path: '/',
@@ -41,6 +44,7 @@ export function createLinearWebhookRoutes(options: CreateLinearWebhookRoutesOpti
41
44
  description: 'Linear webhook receiver.',
42
45
  options: {bodyLimit: WEBHOOK_BODY_LIMIT},
43
46
  handler: async (request, reply) => {
47
+ const receivedAt = new Date().toISOString();
44
48
  const deliveryHeader = request.headers[DELIVERY_HEADER];
45
49
  const event = request.headers[EVENT_HEADER];
46
50
  const signature = request.headers[SIGNATURE_HEADER];
@@ -59,76 +63,22 @@ export function createLinearWebhookRoutes(options: CreateLinearWebhookRoutesOpti
59
63
  }
60
64
 
61
65
  const body = request.body;
62
- if (!Buffer.isBuffer(body)) {
66
+ if (!(body instanceof Uint8Array)) {
63
67
  reply.code(400);
64
68
  return {error: 'expected raw JSON body'};
65
69
  }
66
- const rawBody = body.toString('utf8');
67
-
68
- if (
69
- !verifyHexHmacSignature({
70
- rawBody,
71
- signature,
72
- secret: config.LINEAR_WEBHOOK_SIGNING_SECRET,
73
- })
74
- ) {
75
- reply.code(401);
76
- return {error: 'invalid signature'};
77
- }
78
-
79
- let parsedJson: unknown;
80
- try {
81
- parsedJson = JSON.parse(rawBody);
82
- } catch (error) {
83
- logger().warn(
84
- {deliveryId: deliveryHeader, err: error},
85
- 'linear webhook payload JSON parse failed',
86
- );
87
- reply.code(400);
88
- return {error: 'malformed JSON'};
89
- }
90
-
91
- const payload = linearWebhookBaseEnvelopeSchema.safeParse(parsedJson);
92
- if (!payload.success) {
93
- logger().warn(
94
- {deliveryId: deliveryHeader, issues: payload.error.issues},
95
- 'linear webhook envelope failed schema validation',
96
- );
97
- await recordSignedDeliveryOnly(options, deliveryHeader);
98
- reply.code(200);
99
- return null;
100
- }
101
-
102
- if (Math.abs(Date.now() - payload.data.webhookTimestamp) > WEBHOOK_REPLAY_WINDOW_MS) {
103
- reply.code(401);
104
- return {error: 'stale webhook timestamp'};
105
- }
70
+ const result = await processor.process(
71
+ createLinearStoredWebhookRequest(
72
+ {
73
+ body,
74
+ headers: request.headers,
75
+ rawQueryString: request.raw.url?.split('?')[1] ?? '',
76
+ },
77
+ receivedAt,
78
+ ),
79
+ );
106
80
 
107
- if (event !== payload.data.type) {
108
- logger().warn(
109
- {deliveryId: deliveryHeader, event, type: payload.data.type},
110
- 'linear webhook event header did not match payload type',
111
- );
112
- await recordSignedDeliveryOnly(options, deliveryHeader);
113
- reply.code(200);
114
- return null;
115
- }
116
-
117
- await options.coreDb().transaction(async (tx) => {
118
- await handleLinearWebhook({
119
- tx,
120
- // Linear documents this header as the delivery UUID; the signed timestamp is per-send.
121
- deliveryId: deliveryHeader,
122
- payload: payload.data,
123
- rawPayload: parsedJson,
124
- publishIntegrationEventReceived: options.publishIntegrationEventReceived,
125
- recordDeliveryOnly: options.recordDeliveryOnly,
126
- getIntegrationConnectionById: options.getIntegrationConnectionById,
127
- });
128
- });
129
-
130
- reply.code(200);
131
- return null;
81
+ return sendLinearWebhookResponse(reply, result);
132
82
  },
133
83
  });
134
84
 
@@ -140,15 +90,67 @@ export function createLinearWebhookRoutes(options: CreateLinearWebhookRoutesOpti
140
90
  };
141
91
  }
142
92
 
143
- async function recordSignedDeliveryOnly(
144
- options: Pick<CreateLinearWebhookRoutesOptions, 'coreDb' | 'recordDeliveryOnly'>,
145
- deliveryId: string,
146
- ): Promise<void> {
147
- await options.coreDb().transaction(async (tx: IntegrationTx) => {
148
- await options.recordDeliveryOnly({
149
- tx,
150
- provider: LINEAR_PROVIDER,
151
- deliveryId,
93
+ function createLinearStoredWebhookRequest(
94
+ input: {
95
+ body: Uint8Array;
96
+ headers: Record<string, string | string[] | undefined>;
97
+ rawQueryString: string;
98
+ },
99
+ receivedAt: string,
100
+ ): StoredWebhookRequest {
101
+ if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
102
+ throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
103
+ }
104
+
105
+ try {
106
+ return createStoredWebhookRequest({
107
+ requestId: randomUUID(),
108
+ routeId: 'linear',
109
+ receivedAt,
110
+ rawQueryString: input.rawQueryString,
111
+ headers: linearWebhookHeaders(input.headers),
112
+ body: input.body,
152
113
  });
153
- });
114
+ } catch (error) {
115
+ throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
116
+ cause: error,
117
+ });
118
+ }
119
+ }
120
+
121
+ function linearWebhookHeaders(headers: Record<string, string | string[] | undefined>) {
122
+ return Object.fromEntries(
123
+ ['content-type', DELIVERY_HEADER, EVENT_HEADER, SIGNATURE_HEADER, 'linear-timestamp'].flatMap(
124
+ (name) => {
125
+ const value = headers[name];
126
+ return typeof value === 'string' ? [[name, value]] : [];
127
+ },
128
+ ),
129
+ );
130
+ }
131
+
132
+ function sendLinearWebhookResponse(
133
+ reply: {code(statusCode: number): void},
134
+ result: WebhookProcessingResult,
135
+ ) {
136
+ if (result.outcome !== 'discarded') {
137
+ reply.code(200);
138
+ return null;
139
+ }
140
+
141
+ if (result.reason === 'invalid_signature') {
142
+ reply.code(401);
143
+ return {error: 'invalid signature'};
144
+ }
145
+ if (result.reason === 'stale_at_receipt') {
146
+ reply.code(401);
147
+ return {error: 'stale webhook timestamp'};
148
+ }
149
+ if (result.reason === 'malformed_payload') {
150
+ reply.code(400);
151
+ return {error: 'malformed JSON'};
152
+ }
153
+
154
+ reply.code(200);
155
+ return null;
154
156
  }
@@ -8,15 +8,6 @@ export async function setup() {
8
8
  createPostgresClient();
9
9
 
10
10
  await runMigrations(db(), migrationsPath, '__drizzle_migrations_integrations_linear');
11
- const {secretsModule} = await import('@shipfox/api-secrets');
12
- if (!secretsModule.database || Array.isArray(secretsModule.database)) {
13
- throw new Error('Secrets module database is not configured');
14
- }
15
- await runMigrations(
16
- secretsModule.database.db(),
17
- secretsModule.database.migrationsPath,
18
- '__drizzle_migrations_secrets',
19
- );
20
11
 
21
12
  closeDb();
22
13
  await closePostgresClient();