@relayfile/adapter-linear 0.1.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/package.json +45 -0
- package/src/__tests__/linear-adapter.test.ts +345 -0
- package/src/__tests__/types.test.ts +27 -0
- package/src/__tests__/webhook-normalizer.test.ts +77 -0
- package/src/index.ts +5 -0
- package/src/linear-adapter.ts +680 -0
- package/src/path-mapper.ts +69 -0
- package/src/types.ts +164 -0
- package/src/webhook-normalizer.ts +721 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { NormalizedWebhook } from './linear-adapter.ts';
|
|
4
|
+
|
|
5
|
+
export const LINEAR_PROVIDER = 'linear';
|
|
6
|
+
export const LINEAR_SIGNATURE_HEADER = 'linear-signature';
|
|
7
|
+
export const LINEAR_EVENT_HEADER = 'linear-event';
|
|
8
|
+
export const LINEAR_DELIVERY_HEADER = 'linear-delivery';
|
|
9
|
+
|
|
10
|
+
const CONNECTION_ID_HEADER_KEYS = [
|
|
11
|
+
'x-relay-connection-id',
|
|
12
|
+
'x-connection-id',
|
|
13
|
+
'x-linear-connection-id',
|
|
14
|
+
'linear-connection-id',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
const PROVIDER_HEADER_KEYS = [
|
|
18
|
+
'x-relay-provider',
|
|
19
|
+
'x-provider',
|
|
20
|
+
'x-linear-provider',
|
|
21
|
+
'linear-provider',
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
const PROVIDER_CONFIG_KEY_HEADER_KEYS = [
|
|
25
|
+
'x-relay-provider-config-key',
|
|
26
|
+
'x-provider-config-key',
|
|
27
|
+
'x-linear-provider-config-key',
|
|
28
|
+
'linear-provider-config-key',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const REQUEST_ID_HEADER_KEYS = ['x-request-id', 'x-correlation-id', 'x-relay-request-id'] as const;
|
|
32
|
+
|
|
33
|
+
const OBJECT_TYPE_ALIASES: Readonly<Record<string, string>> = {
|
|
34
|
+
comment: 'comment',
|
|
35
|
+
comments: 'comment',
|
|
36
|
+
cycle: 'cycle',
|
|
37
|
+
cycles: 'cycle',
|
|
38
|
+
customer: 'customer',
|
|
39
|
+
customers: 'customer',
|
|
40
|
+
document: 'document',
|
|
41
|
+
documents: 'document',
|
|
42
|
+
initiative: 'initiative',
|
|
43
|
+
initiatives: 'initiative',
|
|
44
|
+
issue: 'issue',
|
|
45
|
+
issues: 'issue',
|
|
46
|
+
label: 'label',
|
|
47
|
+
labels: 'label',
|
|
48
|
+
project: 'project',
|
|
49
|
+
projects: 'project',
|
|
50
|
+
reaction: 'reaction',
|
|
51
|
+
reactions: 'reaction',
|
|
52
|
+
user: 'user',
|
|
53
|
+
users: 'user',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type LinearRecord = Record<string, unknown>;
|
|
57
|
+
type HeaderValue = boolean | number | readonly string[] | string | null | undefined;
|
|
58
|
+
|
|
59
|
+
export type LinearWebhookHeaders =
|
|
60
|
+
| Headers
|
|
61
|
+
| Iterable<readonly [string, string]>
|
|
62
|
+
| Record<string, HeaderValue>;
|
|
63
|
+
|
|
64
|
+
export interface LinearWebhookConnectionMetadata {
|
|
65
|
+
connectionId?: string;
|
|
66
|
+
deliveryId?: string;
|
|
67
|
+
provider: string;
|
|
68
|
+
providerConfigKey?: string;
|
|
69
|
+
requestId?: string;
|
|
70
|
+
signature?: string;
|
|
71
|
+
webhookId?: string;
|
|
72
|
+
webhookTimestamp?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface LinearWebhookSignatureValidationResult {
|
|
76
|
+
expectedSignature?: string;
|
|
77
|
+
ok: boolean;
|
|
78
|
+
reason?: 'invalid-signature' | 'malformed-signature' | 'missing-secret' | 'missing-signature';
|
|
79
|
+
receivedSignature?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface LinearWebhookTimestampValidationResult {
|
|
83
|
+
driftMs?: number;
|
|
84
|
+
ok: boolean;
|
|
85
|
+
reason?: 'missing-timestamp' | 'stale-timestamp';
|
|
86
|
+
webhookTimestamp?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function normalizeLinearWebhook(
|
|
90
|
+
rawPayload: unknown,
|
|
91
|
+
headers: LinearWebhookHeaders = {},
|
|
92
|
+
): NormalizedWebhook {
|
|
93
|
+
const payload = parseLinearWebhookPayload(rawPayload);
|
|
94
|
+
const normalizedHeaders = normalizeHeaders(headers);
|
|
95
|
+
const action = extractLinearAction(payload, rawPayload).toLowerCase();
|
|
96
|
+
const objectType = extractLinearObjectType(payload, normalizedHeaders);
|
|
97
|
+
const objectId = extractLinearObjectId(payload);
|
|
98
|
+
const eventType = extractLinearEventType(payload, normalizedHeaders, objectType, action);
|
|
99
|
+
const connection = extractLinearConnectionMetadata(payload, normalizedHeaders);
|
|
100
|
+
|
|
101
|
+
const normalized: NormalizedWebhook = {
|
|
102
|
+
provider: connection.provider,
|
|
103
|
+
eventType,
|
|
104
|
+
objectType,
|
|
105
|
+
objectId,
|
|
106
|
+
payload: buildNormalizedPayload(payload, normalizedHeaders, connection, {
|
|
107
|
+
action,
|
|
108
|
+
eventType,
|
|
109
|
+
objectId,
|
|
110
|
+
objectType,
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (connection.connectionId) {
|
|
115
|
+
normalized.connectionId = connection.connectionId;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function parseLinearWebhookPayload(rawPayload: unknown): LinearRecord {
|
|
122
|
+
const decoded = decodeWebhookPayload(rawPayload);
|
|
123
|
+
if (!isRecord(decoded)) {
|
|
124
|
+
throw new Error('Linear webhook payload must be a JSON object.');
|
|
125
|
+
}
|
|
126
|
+
return decoded;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function extractLinearConnectionMetadata(
|
|
130
|
+
payload: unknown,
|
|
131
|
+
headers: LinearWebhookHeaders = {},
|
|
132
|
+
): LinearWebhookConnectionMetadata {
|
|
133
|
+
const normalizedHeaders = normalizeHeaders(headers);
|
|
134
|
+
const record = parseLinearWebhookPayload(payload);
|
|
135
|
+
const metadata = getRecord(record.metadata);
|
|
136
|
+
const connection = getRecord(record.connection);
|
|
137
|
+
const normalizedConnection = getRecord(record._connection);
|
|
138
|
+
const webhook = getRecord(record._webhook);
|
|
139
|
+
|
|
140
|
+
const result: LinearWebhookConnectionMetadata = {
|
|
141
|
+
provider:
|
|
142
|
+
readHeaderValue(normalizedHeaders, PROVIDER_HEADER_KEYS) ??
|
|
143
|
+
readOptionalString(record.provider) ??
|
|
144
|
+
readOptionalString(metadata?.provider) ??
|
|
145
|
+
readOptionalString(normalizedConnection?.provider) ??
|
|
146
|
+
LINEAR_PROVIDER,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const connectionId =
|
|
150
|
+
readHeaderValue(normalizedHeaders, CONNECTION_ID_HEADER_KEYS) ??
|
|
151
|
+
readOptionalString(record.connectionId) ??
|
|
152
|
+
readOptionalString(record.connection_id) ??
|
|
153
|
+
readOptionalString(metadata?.connectionId) ??
|
|
154
|
+
readOptionalString(metadata?.connection_id) ??
|
|
155
|
+
readOptionalString(normalizedConnection?.connectionId) ??
|
|
156
|
+
readOptionalString(normalizedConnection?.connection_id) ??
|
|
157
|
+
readOptionalString(connection?.id);
|
|
158
|
+
if (connectionId) {
|
|
159
|
+
result.connectionId = connectionId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const providerConfigKey =
|
|
163
|
+
readHeaderValue(normalizedHeaders, PROVIDER_CONFIG_KEY_HEADER_KEYS) ??
|
|
164
|
+
readOptionalString(record.providerConfigKey) ??
|
|
165
|
+
readOptionalString(record.provider_config_key) ??
|
|
166
|
+
readOptionalString(metadata?.providerConfigKey) ??
|
|
167
|
+
readOptionalString(metadata?.provider_config_key) ??
|
|
168
|
+
readOptionalString(normalizedConnection?.providerConfigKey) ??
|
|
169
|
+
readOptionalString(normalizedConnection?.provider_config_key);
|
|
170
|
+
if (providerConfigKey) {
|
|
171
|
+
result.providerConfigKey = providerConfigKey;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const deliveryId =
|
|
175
|
+
readOptionalString(normalizedHeaders[LINEAR_DELIVERY_HEADER]) ??
|
|
176
|
+
readOptionalString(record.deliveryId) ??
|
|
177
|
+
readOptionalString(record.delivery_id) ??
|
|
178
|
+
readOptionalString(metadata?.deliveryId) ??
|
|
179
|
+
readOptionalString(metadata?.delivery_id) ??
|
|
180
|
+
readOptionalString(normalizedConnection?.deliveryId) ??
|
|
181
|
+
readOptionalString(normalizedConnection?.delivery_id) ??
|
|
182
|
+
readOptionalString(webhook?.deliveryId) ??
|
|
183
|
+
readOptionalString(webhook?.delivery_id);
|
|
184
|
+
if (deliveryId) {
|
|
185
|
+
result.deliveryId = deliveryId;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const signature =
|
|
189
|
+
readOptionalString(normalizedHeaders[LINEAR_SIGNATURE_HEADER]) ??
|
|
190
|
+
readOptionalString(record.signature) ??
|
|
191
|
+
readOptionalString(metadata?.signature) ??
|
|
192
|
+
readOptionalString(webhook?.signature);
|
|
193
|
+
if (signature) {
|
|
194
|
+
result.signature = signature;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const requestId =
|
|
198
|
+
readHeaderValue(normalizedHeaders, REQUEST_ID_HEADER_KEYS) ??
|
|
199
|
+
readOptionalString(record.requestId) ??
|
|
200
|
+
readOptionalString(record.request_id) ??
|
|
201
|
+
readOptionalString(metadata?.requestId) ??
|
|
202
|
+
readOptionalString(metadata?.request_id) ??
|
|
203
|
+
readOptionalString(normalizedConnection?.requestId) ??
|
|
204
|
+
readOptionalString(normalizedConnection?.request_id);
|
|
205
|
+
if (requestId) {
|
|
206
|
+
result.requestId = requestId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const webhookId =
|
|
210
|
+
readOptionalString(record.webhookId) ??
|
|
211
|
+
readOptionalString(record.webhook_id) ??
|
|
212
|
+
readOptionalString(metadata?.webhookId) ??
|
|
213
|
+
readOptionalString(metadata?.webhook_id) ??
|
|
214
|
+
readOptionalString(webhook?.webhookId) ??
|
|
215
|
+
readOptionalString(webhook?.webhook_id);
|
|
216
|
+
if (webhookId) {
|
|
217
|
+
result.webhookId = webhookId;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const webhookTimestamp =
|
|
221
|
+
readOptionalTimestamp(record.webhookTimestamp) ??
|
|
222
|
+
readOptionalTimestamp(record.webhook_timestamp) ??
|
|
223
|
+
readOptionalTimestamp(metadata?.webhookTimestamp) ??
|
|
224
|
+
readOptionalTimestamp(metadata?.webhook_timestamp) ??
|
|
225
|
+
readOptionalTimestamp(webhook?.webhookTimestamp) ??
|
|
226
|
+
readOptionalTimestamp(webhook?.webhook_timestamp);
|
|
227
|
+
if (webhookTimestamp !== undefined) {
|
|
228
|
+
result.webhookTimestamp = webhookTimestamp;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function extractLinearEventType(
|
|
235
|
+
payload: unknown,
|
|
236
|
+
headers: LinearWebhookHeaders = {},
|
|
237
|
+
objectType?: string,
|
|
238
|
+
action?: string,
|
|
239
|
+
): string {
|
|
240
|
+
const normalizedHeaders = normalizeHeaders(headers);
|
|
241
|
+
const record = parseLinearWebhookPayload(payload);
|
|
242
|
+
const metadata = getRecord(record.metadata);
|
|
243
|
+
const webhook = getRecord(record._webhook);
|
|
244
|
+
const resolvedObjectType = objectType ?? extractLinearObjectType(record, normalizedHeaders);
|
|
245
|
+
const resolvedAction = action ?? extractLinearAction(record, payload).toLowerCase();
|
|
246
|
+
|
|
247
|
+
const explicitEventType =
|
|
248
|
+
readOptionalString(record.eventType) ??
|
|
249
|
+
readOptionalString(record.event_type) ??
|
|
250
|
+
readOptionalString(metadata?.eventType) ??
|
|
251
|
+
readOptionalString(metadata?.event_type) ??
|
|
252
|
+
readOptionalString(webhook?.eventType) ??
|
|
253
|
+
readOptionalString(webhook?.event_type);
|
|
254
|
+
if (explicitEventType) {
|
|
255
|
+
return canonicalizeEventType(explicitEventType, resolvedObjectType, resolvedAction);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return `${resolvedObjectType}.${resolvedAction}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function extractLinearObjectType(
|
|
262
|
+
payload: unknown,
|
|
263
|
+
headers: LinearWebhookHeaders = {},
|
|
264
|
+
): string {
|
|
265
|
+
const normalizedHeaders = normalizeHeaders(headers);
|
|
266
|
+
const record = parseLinearWebhookPayload(payload);
|
|
267
|
+
const metadata = getRecord(record.metadata);
|
|
268
|
+
const webhook = getRecord(record._webhook);
|
|
269
|
+
const rawType =
|
|
270
|
+
readOptionalString(record.type) ??
|
|
271
|
+
readOptionalString(record.objectType) ??
|
|
272
|
+
readOptionalString(record.object_type) ??
|
|
273
|
+
readOptionalString(normalizedHeaders[LINEAR_EVENT_HEADER]) ??
|
|
274
|
+
readOptionalString(metadata?.type) ??
|
|
275
|
+
readOptionalString(metadata?.objectType) ??
|
|
276
|
+
readOptionalString(metadata?.object_type) ??
|
|
277
|
+
readOptionalString(webhook?.objectType) ??
|
|
278
|
+
readOptionalString(webhook?.object_type);
|
|
279
|
+
|
|
280
|
+
if (!rawType) {
|
|
281
|
+
throw new Error('Linear webhook payload is missing type metadata.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return canonicalizeObjectType(rawType);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function extractLinearObjectId(payload: unknown): string {
|
|
288
|
+
const record = parseLinearWebhookPayload(payload);
|
|
289
|
+
const data = getRecord(record.data);
|
|
290
|
+
const issueData = getRecord(record.issueData);
|
|
291
|
+
const metadata = getRecord(record.metadata);
|
|
292
|
+
const webhook = getRecord(record._webhook);
|
|
293
|
+
|
|
294
|
+
const objectId =
|
|
295
|
+
readOptionalString(data?.id) ??
|
|
296
|
+
readOptionalString(issueData?.id) ??
|
|
297
|
+
readOptionalString(record.objectId) ??
|
|
298
|
+
readOptionalString(record.object_id) ??
|
|
299
|
+
readOptionalString(metadata?.objectId) ??
|
|
300
|
+
readOptionalString(metadata?.object_id) ??
|
|
301
|
+
readOptionalString(webhook?.objectId) ??
|
|
302
|
+
readOptionalString(webhook?.object_id) ??
|
|
303
|
+
readOptionalString(record.oauthClientId) ??
|
|
304
|
+
readOptionalString(record.id);
|
|
305
|
+
|
|
306
|
+
if (!objectId) {
|
|
307
|
+
throw new Error('Linear webhook payload is missing an object identifier.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return objectId;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function computeLinearWebhookSignature(
|
|
314
|
+
rawPayload: unknown,
|
|
315
|
+
secret: string,
|
|
316
|
+
): string {
|
|
317
|
+
const normalizedSecret = secret.trim();
|
|
318
|
+
if (!normalizedSecret) {
|
|
319
|
+
throw new Error('Linear webhook secret must be a non-empty string.');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return createHmac('sha256', normalizedSecret)
|
|
323
|
+
.update(toRawBodyBuffer(rawPayload))
|
|
324
|
+
.digest('hex');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function validateLinearWebhookSignature(
|
|
328
|
+
rawPayload: unknown,
|
|
329
|
+
headers: LinearWebhookHeaders,
|
|
330
|
+
secret: string,
|
|
331
|
+
): LinearWebhookSignatureValidationResult {
|
|
332
|
+
const normalizedSecret = secret.trim();
|
|
333
|
+
if (!normalizedSecret) {
|
|
334
|
+
return { ok: false, reason: 'missing-secret' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const normalizedHeaders = normalizeHeaders(headers);
|
|
338
|
+
const receivedSignature = readOptionalString(normalizedHeaders[LINEAR_SIGNATURE_HEADER]);
|
|
339
|
+
if (!receivedSignature) {
|
|
340
|
+
return { ok: false, reason: 'missing-signature' };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const normalizedSignature = normalizeSignatureDigest(receivedSignature);
|
|
344
|
+
if (!normalizedSignature) {
|
|
345
|
+
return { ok: false, reason: 'malformed-signature', receivedSignature };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const headerBuffer = Buffer.from(normalizedSignature, 'hex');
|
|
349
|
+
const expectedSignature = computeLinearWebhookSignature(rawPayload, normalizedSecret);
|
|
350
|
+
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
|
|
351
|
+
|
|
352
|
+
if (headerBuffer.length === 0 || headerBuffer.length !== expectedBuffer.length) {
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
reason: 'invalid-signature',
|
|
356
|
+
expectedSignature,
|
|
357
|
+
receivedSignature,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const ok = timingSafeEqual(expectedBuffer, headerBuffer);
|
|
362
|
+
return {
|
|
363
|
+
ok,
|
|
364
|
+
...(ok
|
|
365
|
+
? { expectedSignature, receivedSignature }
|
|
366
|
+
: { reason: 'invalid-signature', expectedSignature, receivedSignature }),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function assertValidLinearWebhookSignature(
|
|
371
|
+
rawPayload: unknown,
|
|
372
|
+
headers: LinearWebhookHeaders,
|
|
373
|
+
secret: string,
|
|
374
|
+
): void {
|
|
375
|
+
const result = validateLinearWebhookSignature(rawPayload, headers, secret);
|
|
376
|
+
if (!result.ok) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Invalid Linear webhook signature${result.reason ? ` (${result.reason})` : ''}.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function validateLinearWebhookTimestamp(
|
|
384
|
+
payload: unknown,
|
|
385
|
+
toleranceMs = 60_000,
|
|
386
|
+
now = Date.now(),
|
|
387
|
+
): LinearWebhookTimestampValidationResult {
|
|
388
|
+
const record = parseLinearWebhookPayload(payload);
|
|
389
|
+
const metadata = getRecord(record.metadata);
|
|
390
|
+
const webhook = getRecord(record._webhook);
|
|
391
|
+
const webhookTimestamp =
|
|
392
|
+
readOptionalTimestamp(record.webhookTimestamp) ??
|
|
393
|
+
readOptionalTimestamp(record.webhook_timestamp) ??
|
|
394
|
+
readOptionalTimestamp(metadata?.webhookTimestamp) ??
|
|
395
|
+
readOptionalTimestamp(metadata?.webhook_timestamp) ??
|
|
396
|
+
readOptionalTimestamp(webhook?.webhookTimestamp) ??
|
|
397
|
+
readOptionalTimestamp(webhook?.webhook_timestamp);
|
|
398
|
+
if (webhookTimestamp === undefined) {
|
|
399
|
+
return { ok: false, reason: 'missing-timestamp' };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const driftMs = Math.abs(now - webhookTimestamp);
|
|
403
|
+
if (driftMs > toleranceMs) {
|
|
404
|
+
return { ok: false, reason: 'stale-timestamp', webhookTimestamp, driftMs };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { ok: true, webhookTimestamp, driftMs };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function assertValidLinearWebhookTimestamp(
|
|
411
|
+
payload: unknown,
|
|
412
|
+
toleranceMs = 60_000,
|
|
413
|
+
now = Date.now(),
|
|
414
|
+
): void {
|
|
415
|
+
const result = validateLinearWebhookTimestamp(payload, toleranceMs, now);
|
|
416
|
+
if (!result.ok) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`Invalid Linear webhook timestamp${result.reason ? ` (${result.reason})` : ''}.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function buildNormalizedPayload(
|
|
424
|
+
payload: LinearRecord,
|
|
425
|
+
headers: Record<string, string>,
|
|
426
|
+
connection: LinearWebhookConnectionMetadata,
|
|
427
|
+
normalized: {
|
|
428
|
+
action: string;
|
|
429
|
+
eventType: string;
|
|
430
|
+
objectId: string;
|
|
431
|
+
objectType: string;
|
|
432
|
+
},
|
|
433
|
+
): LinearRecord {
|
|
434
|
+
const existingConnection = getRecord(payload._connection);
|
|
435
|
+
const existingWebhook = getRecord(payload._webhook);
|
|
436
|
+
|
|
437
|
+
const normalizedPayload: LinearRecord = { ...payload };
|
|
438
|
+
|
|
439
|
+
normalizedPayload._connection = compactObject({
|
|
440
|
+
...existingConnection,
|
|
441
|
+
connectionId: connection.connectionId,
|
|
442
|
+
deliveryId: connection.deliveryId,
|
|
443
|
+
provider: connection.provider,
|
|
444
|
+
providerConfigKey: connection.providerConfigKey,
|
|
445
|
+
requestId: connection.requestId,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
normalizedPayload._webhook = compactObject({
|
|
449
|
+
...existingWebhook,
|
|
450
|
+
action: normalized.action,
|
|
451
|
+
actor: getRecord(payload.actor) ?? existingWebhook?.actor,
|
|
452
|
+
createdAt: readOptionalString(payload.createdAt) ?? readOptionalString(existingWebhook?.createdAt),
|
|
453
|
+
deliveryId: connection.deliveryId ?? readOptionalString(existingWebhook?.deliveryId),
|
|
454
|
+
eventHeader: readOptionalString(headers[LINEAR_EVENT_HEADER]) ?? readOptionalString(existingWebhook?.eventHeader),
|
|
455
|
+
eventType: normalized.eventType,
|
|
456
|
+
objectId: normalized.objectId,
|
|
457
|
+
objectType: normalized.objectType,
|
|
458
|
+
organizationId:
|
|
459
|
+
readOptionalString(payload.organizationId) ?? readOptionalString(existingWebhook?.organizationId),
|
|
460
|
+
previousData:
|
|
461
|
+
getRecord(payload.updatedFrom) ??
|
|
462
|
+
getRecord(payload.previousData) ??
|
|
463
|
+
getRecord(existingWebhook?.previousData),
|
|
464
|
+
signature: connection.signature ?? readOptionalString(existingWebhook?.signature),
|
|
465
|
+
url: readOptionalString(payload.url) ?? readOptionalString(existingWebhook?.url),
|
|
466
|
+
webhookId: connection.webhookId ?? readOptionalString(existingWebhook?.webhookId),
|
|
467
|
+
webhookTimestamp:
|
|
468
|
+
connection.webhookTimestamp ?? readOptionalNumber(payload.webhookTimestamp) ?? readOptionalNumber(existingWebhook?.webhookTimestamp),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return normalizedPayload;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function decodeWebhookPayload(rawPayload: unknown): unknown {
|
|
475
|
+
if (typeof rawPayload === 'string') {
|
|
476
|
+
return JSON.parse(rawPayload);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (Buffer.isBuffer(rawPayload)) {
|
|
480
|
+
return JSON.parse(rawPayload.toString('utf8'));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (rawPayload instanceof Uint8Array) {
|
|
484
|
+
return JSON.parse(Buffer.from(rawPayload).toString('utf8'));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (rawPayload instanceof ArrayBuffer) {
|
|
488
|
+
return JSON.parse(Buffer.from(rawPayload).toString('utf8'));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return rawPayload;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function toRawBodyBuffer(rawPayload: unknown): Buffer {
|
|
495
|
+
if (typeof rawPayload === 'string') {
|
|
496
|
+
return Buffer.from(rawPayload, 'utf8');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (Buffer.isBuffer(rawPayload)) {
|
|
500
|
+
return rawPayload;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (rawPayload instanceof Uint8Array) {
|
|
504
|
+
return Buffer.from(rawPayload);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (rawPayload instanceof ArrayBuffer) {
|
|
508
|
+
return Buffer.from(rawPayload);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return Buffer.from(JSON.stringify(rawPayload), 'utf8');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function normalizeHeaders(headers: LinearWebhookHeaders): Record<string, string> {
|
|
515
|
+
const normalized: Record<string, string> = {};
|
|
516
|
+
|
|
517
|
+
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
|
518
|
+
for (const [key, value] of headers.entries()) {
|
|
519
|
+
const normalizedValue = readOptionalString(value);
|
|
520
|
+
if (normalizedValue) {
|
|
521
|
+
normalized[key.toLowerCase()] = normalizedValue;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return normalized;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (isIterableEntries(headers)) {
|
|
528
|
+
for (const entry of headers) {
|
|
529
|
+
const key = readOptionalString(entry[0]);
|
|
530
|
+
const value = readOptionalString(entry[1]);
|
|
531
|
+
if (key && value) {
|
|
532
|
+
normalized[key.toLowerCase()] = value;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return normalized;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
539
|
+
const normalizedKey = readOptionalString(key);
|
|
540
|
+
const normalizedValue = normalizeHeaderValue(value);
|
|
541
|
+
if (normalizedKey && normalizedValue) {
|
|
542
|
+
normalized[normalizedKey.toLowerCase()] = normalizedValue;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return normalized;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function readHeaderValue(
|
|
550
|
+
headers: Record<string, string>,
|
|
551
|
+
keys: readonly string[],
|
|
552
|
+
): string | undefined {
|
|
553
|
+
for (const key of keys) {
|
|
554
|
+
const value = readOptionalString(headers[key]);
|
|
555
|
+
if (value) {
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function extractLinearAction(record: LinearRecord, context: unknown): string {
|
|
563
|
+
const metadata = getRecord(record.metadata);
|
|
564
|
+
const webhook = getRecord(record._webhook);
|
|
565
|
+
const action =
|
|
566
|
+
readOptionalString(record.action) ??
|
|
567
|
+
readOptionalString(metadata?.action) ??
|
|
568
|
+
readOptionalString(webhook?.action);
|
|
569
|
+
|
|
570
|
+
if (!action) {
|
|
571
|
+
throw new Error('Linear webhook payload is missing action.');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return action;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function canonicalizeEventType(
|
|
578
|
+
value: string,
|
|
579
|
+
objectType: string,
|
|
580
|
+
action: string,
|
|
581
|
+
): string {
|
|
582
|
+
const normalized = value.trim().toLowerCase();
|
|
583
|
+
if (!normalized) {
|
|
584
|
+
return `${objectType}.${action}`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (normalized === objectType) {
|
|
588
|
+
return `${objectType}.${action}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return normalized;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function canonicalizeObjectType(value: string): string {
|
|
595
|
+
const normalized = value.trim().toLowerCase();
|
|
596
|
+
const mapped = OBJECT_TYPE_ALIASES[normalized];
|
|
597
|
+
if (mapped) {
|
|
598
|
+
return mapped;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
for (const token of normalized.split(/[^a-z]+/)) {
|
|
602
|
+
const tokenMatch = OBJECT_TYPE_ALIASES[token];
|
|
603
|
+
if (tokenMatch) {
|
|
604
|
+
return tokenMatch;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return normalized;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function normalizeHeaderValue(value: HeaderValue): string | undefined {
|
|
612
|
+
if (Array.isArray(value)) {
|
|
613
|
+
const normalizedValues = value.map((entry) => readOptionalString(entry)).filter(isDefined);
|
|
614
|
+
return normalizedValues.length > 0 ? normalizedValues.join(', ') : undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
618
|
+
return String(value);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return readOptionalString(value);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function getRecord(value: unknown): LinearRecord | undefined {
|
|
625
|
+
return isRecord(value) ? value : undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function isRecord(value: unknown): value is LinearRecord {
|
|
629
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function isIterableEntries(value: unknown): value is Iterable<readonly [string, string]> {
|
|
633
|
+
if (!value || typeof value !== 'object') {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (typeof Headers !== 'undefined' && value instanceof Headers) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return Symbol.iterator in value;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function compactObject(value: LinearRecord): LinearRecord {
|
|
645
|
+
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined);
|
|
646
|
+
return Object.fromEntries(entries);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function normalizeSignatureDigest(value: string): string | undefined {
|
|
650
|
+
const trimmed = value.trim();
|
|
651
|
+
if (!trimmed) {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const unprefixed = trimmed.replace(/^sha256=/i, '');
|
|
656
|
+
return isHexDigest(unprefixed) ? unprefixed.toLowerCase() : undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function isHexDigest(value: string): boolean {
|
|
660
|
+
return value.length > 0 && value.length % 2 === 0 && /^[0-9a-f]+$/i.test(value);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function readRequiredString(record: LinearRecord, key: string, context: unknown): string {
|
|
664
|
+
const value = readOptionalString(record[key]);
|
|
665
|
+
if (!value) {
|
|
666
|
+
throw new Error(`Linear webhook payload is missing ${key}.`);
|
|
667
|
+
}
|
|
668
|
+
return value;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function readOptionalString(value: unknown): string | undefined {
|
|
672
|
+
if (typeof value !== 'string') {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const trimmed = value.trim();
|
|
677
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function readOptionalNumber(value: unknown): number | undefined {
|
|
681
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
682
|
+
return value;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (typeof value === 'string') {
|
|
686
|
+
const trimmed = value.trim();
|
|
687
|
+
if (!trimmed) {
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const parsed = Number(trimmed);
|
|
692
|
+
if (Number.isFinite(parsed)) {
|
|
693
|
+
return parsed;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function readOptionalTimestamp(value: unknown): number | undefined {
|
|
701
|
+
const numericValue = readOptionalNumber(value);
|
|
702
|
+
if (numericValue !== undefined) {
|
|
703
|
+
return numericValue;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (typeof value !== 'string') {
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const trimmed = value.trim();
|
|
711
|
+
if (!trimmed) {
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const parsed = Date.parse(trimmed);
|
|
716
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function isDefined<T>(value: T | undefined): value is T {
|
|
720
|
+
return value !== undefined;
|
|
721
|
+
}
|