@shipfox/api-integration-linear 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 +47 -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 +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -3
- 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 +10 -11
- 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.ts +15 -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 +1 -1
- package/test/api-secrets.d.ts +0 -28
|
@@ -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();
|