@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +56 -0
- package/dist/core/webhook-processor.d.ts +13 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +130 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -4
- 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 +6 -4
- 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 +75 -79
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +20 -31
- package/src/core/disconnect.test.ts +47 -0
- package/src/core/tokens.test.ts +16 -2
- package/src/core/webhook-processor.test.ts +154 -0
- package/src/core/webhook-processor.ts +127 -0
- package/src/index.test.ts +11 -0
- package/src/index.ts +26 -2
- package/src/presentation/routes/install.test.ts +2 -6
- package/src/presentation/routes/install.ts +18 -3
- package/src/presentation/routes/webhooks.test.ts +37 -0
- package/src/presentation/routes/webhooks.ts +88 -86
- package/test/globalSetup.ts +0 -9
- package/tsconfig.build.tsbuildinfo +1 -1
- package/turbo.json +9 -0
- 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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
} from '@shipfox/api-integration-
|
|
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 {
|
|
22
|
-
|
|
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 (!
|
|
66
|
+
if (!(body instanceof Uint8Array)) {
|
|
63
67
|
reply.code(400);
|
|
64
68
|
return {error: 'expected raw JSON body'};
|
|
65
69
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
}
|
package/test/globalSetup.ts
CHANGED
|
@@ -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();
|