@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,14 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { defineRoute, rawBodyPlugin,
|
|
4
|
-
import {
|
|
5
|
-
import { config } from '#config.js';
|
|
6
|
-
import { handleLinearWebhook } from '#core/webhook.js';
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createStoredWebhookRequest, WEBHOOK_MAX_RAW_BODY_BYTES } from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import { ClientError, defineRoute, rawBodyPlugin, WEBHOOK_BODY_LIMIT } from '@shipfox/node-fastify';
|
|
4
|
+
import { createLinearWebhookProcessor } from '#core/webhook-processor.js';
|
|
7
5
|
const DELIVERY_HEADER = 'linear-delivery';
|
|
8
6
|
const EVENT_HEADER = 'linear-event';
|
|
9
7
|
const SIGNATURE_HEADER = 'linear-signature';
|
|
10
|
-
const WEBHOOK_REPLAY_WINDOW_MS = 60_000;
|
|
11
8
|
export function createLinearWebhookRoutes(options) {
|
|
9
|
+
const processor = options.processor ?? createLinearWebhookProcessor(options);
|
|
12
10
|
const webhookRoute = defineRoute({
|
|
13
11
|
method: 'POST',
|
|
14
12
|
path: '/',
|
|
@@ -18,6 +16,7 @@ export function createLinearWebhookRoutes(options) {
|
|
|
18
16
|
bodyLimit: WEBHOOK_BODY_LIMIT
|
|
19
17
|
},
|
|
20
18
|
handler: async (request, reply)=>{
|
|
19
|
+
const receivedAt = new Date().toISOString();
|
|
21
20
|
const deliveryHeader = request.headers[DELIVERY_HEADER];
|
|
22
21
|
const event = request.headers[EVENT_HEADER];
|
|
23
22
|
const signature = request.headers[SIGNATURE_HEADER];
|
|
@@ -40,76 +39,18 @@ export function createLinearWebhookRoutes(options) {
|
|
|
40
39
|
};
|
|
41
40
|
}
|
|
42
41
|
const body = request.body;
|
|
43
|
-
if (!
|
|
42
|
+
if (!(body instanceof Uint8Array)) {
|
|
44
43
|
reply.code(400);
|
|
45
44
|
return {
|
|
46
45
|
error: 'expected raw JSON body'
|
|
47
46
|
};
|
|
48
47
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
reply.code(401);
|
|
56
|
-
return {
|
|
57
|
-
error: 'invalid signature'
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
let parsedJson;
|
|
61
|
-
try {
|
|
62
|
-
parsedJson = JSON.parse(rawBody);
|
|
63
|
-
} catch (error) {
|
|
64
|
-
logger().warn({
|
|
65
|
-
deliveryId: deliveryHeader,
|
|
66
|
-
err: error
|
|
67
|
-
}, 'linear webhook payload JSON parse failed');
|
|
68
|
-
reply.code(400);
|
|
69
|
-
return {
|
|
70
|
-
error: 'malformed JSON'
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
const payload = linearWebhookBaseEnvelopeSchema.safeParse(parsedJson);
|
|
74
|
-
if (!payload.success) {
|
|
75
|
-
logger().warn({
|
|
76
|
-
deliveryId: deliveryHeader,
|
|
77
|
-
issues: payload.error.issues
|
|
78
|
-
}, 'linear webhook envelope failed schema validation');
|
|
79
|
-
await recordSignedDeliveryOnly(options, deliveryHeader);
|
|
80
|
-
reply.code(200);
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
if (Math.abs(Date.now() - payload.data.webhookTimestamp) > WEBHOOK_REPLAY_WINDOW_MS) {
|
|
84
|
-
reply.code(401);
|
|
85
|
-
return {
|
|
86
|
-
error: 'stale webhook timestamp'
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
if (event !== payload.data.type) {
|
|
90
|
-
logger().warn({
|
|
91
|
-
deliveryId: deliveryHeader,
|
|
92
|
-
event,
|
|
93
|
-
type: payload.data.type
|
|
94
|
-
}, 'linear webhook event header did not match payload type');
|
|
95
|
-
await recordSignedDeliveryOnly(options, deliveryHeader);
|
|
96
|
-
reply.code(200);
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
await options.coreDb().transaction(async (tx)=>{
|
|
100
|
-
await handleLinearWebhook({
|
|
101
|
-
tx,
|
|
102
|
-
// Linear documents this header as the delivery UUID; the signed timestamp is per-send.
|
|
103
|
-
deliveryId: deliveryHeader,
|
|
104
|
-
payload: payload.data,
|
|
105
|
-
rawPayload: parsedJson,
|
|
106
|
-
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
107
|
-
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
108
|
-
getIntegrationConnectionById: options.getIntegrationConnectionById
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
reply.code(200);
|
|
112
|
-
return null;
|
|
48
|
+
const result = await processor.process(createLinearStoredWebhookRequest({
|
|
49
|
+
body,
|
|
50
|
+
headers: request.headers,
|
|
51
|
+
rawQueryString: request.raw.url?.split('?')[1] ?? ''
|
|
52
|
+
}, receivedAt));
|
|
53
|
+
return sendLinearWebhookResponse(reply, result);
|
|
113
54
|
}
|
|
114
55
|
});
|
|
115
56
|
return {
|
|
@@ -123,14 +64,69 @@ export function createLinearWebhookRoutes(options) {
|
|
|
123
64
|
]
|
|
124
65
|
};
|
|
125
66
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
provider: LINEAR_PROVIDER,
|
|
131
|
-
deliveryId
|
|
67
|
+
function createLinearStoredWebhookRequest(input, receivedAt) {
|
|
68
|
+
if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
|
|
69
|
+
throw new ClientError('Webhook request body is too large', 'body-too-large', {
|
|
70
|
+
status: 413
|
|
132
71
|
});
|
|
133
|
-
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return createStoredWebhookRequest({
|
|
75
|
+
requestId: randomUUID(),
|
|
76
|
+
routeId: 'linear',
|
|
77
|
+
receivedAt,
|
|
78
|
+
rawQueryString: input.rawQueryString,
|
|
79
|
+
headers: linearWebhookHeaders(input.headers),
|
|
80
|
+
body: input.body
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
|
|
84
|
+
cause: error
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function linearWebhookHeaders(headers) {
|
|
89
|
+
return Object.fromEntries([
|
|
90
|
+
'content-type',
|
|
91
|
+
DELIVERY_HEADER,
|
|
92
|
+
EVENT_HEADER,
|
|
93
|
+
SIGNATURE_HEADER,
|
|
94
|
+
'linear-timestamp'
|
|
95
|
+
].flatMap((name)=>{
|
|
96
|
+
const value = headers[name];
|
|
97
|
+
return typeof value === 'string' ? [
|
|
98
|
+
[
|
|
99
|
+
name,
|
|
100
|
+
value
|
|
101
|
+
]
|
|
102
|
+
] : [];
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
function sendLinearWebhookResponse(reply, result) {
|
|
106
|
+
if (result.outcome !== 'discarded') {
|
|
107
|
+
reply.code(200);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (result.reason === 'invalid_signature') {
|
|
111
|
+
reply.code(401);
|
|
112
|
+
return {
|
|
113
|
+
error: 'invalid signature'
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (result.reason === 'stale_at_receipt') {
|
|
117
|
+
reply.code(401);
|
|
118
|
+
return {
|
|
119
|
+
error: 'stale webhook timestamp'
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (result.reason === 'malformed_payload') {
|
|
123
|
+
reply.code(400);
|
|
124
|
+
return {
|
|
125
|
+
error: 'malformed JSON'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
reply.code(200);
|
|
129
|
+
return null;
|
|
134
130
|
}
|
|
135
131
|
|
|
136
132
|
//# sourceMappingURL=webhooks.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/presentation/routes/webhooks.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"sources":["../../../src/presentation/routes/webhooks.ts"],"sourcesContent":["import {randomUUID} from 'node:crypto';\nimport type {\n GetIntegrationConnectionByIdFn,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n StoredWebhookRequest,\n WebhookProcessingResult,\n} from '@shipfox/api-integration-core-dto';\nimport {\n createStoredWebhookRequest,\n WEBHOOK_MAX_RAW_BODY_BYTES,\n} from '@shipfox/api-integration-core-dto';\nimport {\n ClientError,\n defineRoute,\n type RouteGroup,\n rawBodyPlugin,\n WEBHOOK_BODY_LIMIT,\n} from '@shipfox/node-fastify';\nimport type {NodePgDatabase} from 'drizzle-orm/node-postgres';\nimport {\n createLinearWebhookProcessor,\n type LinearWebhookProcessor,\n} from '#core/webhook-processor.js';\n\nconst DELIVERY_HEADER = 'linear-delivery';\nconst EVENT_HEADER = 'linear-event';\nconst SIGNATURE_HEADER = 'linear-signature';\n\nexport interface CreateLinearWebhookRoutesOptions {\n coreDb: () => NodePgDatabase<Record<string, unknown>>;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n processor?: LinearWebhookProcessor | undefined;\n}\n\nexport function createLinearWebhookRoutes(options: CreateLinearWebhookRoutesOptions): RouteGroup {\n const processor = options.processor ?? createLinearWebhookProcessor(options);\n const webhookRoute = defineRoute({\n method: 'POST',\n path: '/',\n auth: [],\n description: 'Linear webhook receiver.',\n options: {bodyLimit: WEBHOOK_BODY_LIMIT},\n handler: async (request, reply) => {\n const receivedAt = new Date().toISOString();\n const deliveryHeader = request.headers[DELIVERY_HEADER];\n const event = request.headers[EVENT_HEADER];\n const signature = request.headers[SIGNATURE_HEADER];\n\n if (typeof deliveryHeader !== 'string' || !deliveryHeader) {\n reply.code(400);\n return {error: 'missing Linear-Delivery header'};\n }\n if (typeof event !== 'string' || !event) {\n reply.code(400);\n return {error: 'missing Linear-Event header'};\n }\n if (typeof signature !== 'string' || !signature) {\n reply.code(401);\n return {error: 'missing Linear-Signature header'};\n }\n\n const body = request.body;\n if (!(body instanceof Uint8Array)) {\n reply.code(400);\n return {error: 'expected raw JSON body'};\n }\n const result = await processor.process(\n createLinearStoredWebhookRequest(\n {\n body,\n headers: request.headers,\n rawQueryString: request.raw.url?.split('?')[1] ?? '',\n },\n receivedAt,\n ),\n );\n\n return sendLinearWebhookResponse(reply, result);\n },\n });\n\n return {\n prefix: '/webhooks/integrations/linear',\n auth: [],\n plugins: [rawBodyPlugin],\n routes: [webhookRoute],\n };\n}\n\nfunction createLinearStoredWebhookRequest(\n input: {\n body: Uint8Array;\n headers: Record<string, string | string[] | undefined>;\n rawQueryString: string;\n },\n receivedAt: string,\n): StoredWebhookRequest {\n if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {\n throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});\n }\n\n try {\n return createStoredWebhookRequest({\n requestId: randomUUID(),\n routeId: 'linear',\n receivedAt,\n rawQueryString: input.rawQueryString,\n headers: linearWebhookHeaders(input.headers),\n body: input.body,\n });\n } catch (error) {\n throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {\n cause: error,\n });\n }\n}\n\nfunction linearWebhookHeaders(headers: Record<string, string | string[] | undefined>) {\n return Object.fromEntries(\n ['content-type', DELIVERY_HEADER, EVENT_HEADER, SIGNATURE_HEADER, 'linear-timestamp'].flatMap(\n (name) => {\n const value = headers[name];\n return typeof value === 'string' ? [[name, value]] : [];\n },\n ),\n );\n}\n\nfunction sendLinearWebhookResponse(\n reply: {code(statusCode: number): void},\n result: WebhookProcessingResult,\n) {\n if (result.outcome !== 'discarded') {\n reply.code(200);\n return null;\n }\n\n if (result.reason === 'invalid_signature') {\n reply.code(401);\n return {error: 'invalid signature'};\n }\n if (result.reason === 'stale_at_receipt') {\n reply.code(401);\n return {error: 'stale webhook timestamp'};\n }\n if (result.reason === 'malformed_payload') {\n reply.code(400);\n return {error: 'malformed JSON'};\n }\n\n reply.code(200);\n return null;\n}\n"],"names":["randomUUID","createStoredWebhookRequest","WEBHOOK_MAX_RAW_BODY_BYTES","ClientError","defineRoute","rawBodyPlugin","WEBHOOK_BODY_LIMIT","createLinearWebhookProcessor","DELIVERY_HEADER","EVENT_HEADER","SIGNATURE_HEADER","createLinearWebhookRoutes","options","processor","webhookRoute","method","path","auth","description","bodyLimit","handler","request","reply","receivedAt","Date","toISOString","deliveryHeader","headers","event","signature","code","error","body","Uint8Array","result","process","createLinearStoredWebhookRequest","rawQueryString","raw","url","split","sendLinearWebhookResponse","prefix","plugins","routes","input","byteLength","status","requestId","routeId","linearWebhookHeaders","cause","Object","fromEntries","flatMap","name","value","outcome","reason"],"mappings":"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"}
|