@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-linear",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "7.0.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -26,34 +26,33 @@
|
|
|
26
26
|
"drizzle-orm": "^0.45.2",
|
|
27
27
|
"ky": "^2.0.0",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
|
-
"@shipfox/api-auth-context": "
|
|
30
|
-
"@shipfox/api-integration-core-dto": "
|
|
31
|
-
"@shipfox/api-
|
|
29
|
+
"@shipfox/api-auth-context": "6.0.0",
|
|
30
|
+
"@shipfox/api-integration-core-dto": "6.0.0",
|
|
31
|
+
"@shipfox/api-integration-linear-dto": "6.0.0",
|
|
32
|
+
"@shipfox/api-workspaces": "7.0.1",
|
|
32
33
|
"@shipfox/config": "1.2.2",
|
|
33
|
-
"@shipfox/
|
|
34
|
-
"@shipfox/node-
|
|
35
|
-
"@shipfox/node-fastify": "0.2.3",
|
|
34
|
+
"@shipfox/node-drizzle": "0.3.2",
|
|
35
|
+
"@shipfox/node-fastify": "0.2.4",
|
|
36
36
|
"@shipfox/node-opentelemetry": "0.5.2",
|
|
37
37
|
"@shipfox/node-postgres": "0.4.2"
|
|
38
38
|
},
|
|
39
|
-
"peerDependencies": {
|
|
40
|
-
"@shipfox/api-secrets": "5.0.0"
|
|
41
|
-
},
|
|
42
39
|
"devDependencies": {
|
|
43
40
|
"@types/pg": "^8.15.5",
|
|
44
41
|
"drizzle-kit": "^0.31.10",
|
|
45
42
|
"fastify": "^5.3.3",
|
|
46
43
|
"fishery": "^2.4.0",
|
|
47
44
|
"@shipfox/biome": "1.8.2",
|
|
45
|
+
"@shipfox/depcruise": "1.0.2",
|
|
48
46
|
"@shipfox/swc": "1.2.6",
|
|
49
|
-
"@shipfox/typescript": "1.1.7",
|
|
50
47
|
"@shipfox/ts-config": "1.3.8",
|
|
48
|
+
"@shipfox/typescript": "1.1.7",
|
|
51
49
|
"@shipfox/vitest": "1.2.3"
|
|
52
50
|
},
|
|
53
51
|
"scripts": {
|
|
54
52
|
"build": "shipfox-swc",
|
|
55
53
|
"check": "shipfox-biome-check",
|
|
56
54
|
"check:fix": "shipfox-biome-check --write",
|
|
55
|
+
"depcruise": "shipfox-depcruise",
|
|
57
56
|
"test": "shipfox-vitest-run",
|
|
58
57
|
"test:watch": "shipfox-vitest-watch",
|
|
59
58
|
"type": "shipfox-tsc-check",
|
|
@@ -12,6 +12,7 @@ const deleteLinearInstallationByConnectionIdMock = vi.mocked(
|
|
|
12
12
|
describe('disconnectLinearInstallation', () => {
|
|
13
13
|
beforeEach(() => {
|
|
14
14
|
deleteLinearInstallationByConnectionIdMock.mockClear();
|
|
15
|
+
deleteLinearInstallationByConnectionIdMock.mockResolvedValue(true);
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
it('deletes stored tokens before deleting connection records', async () => {
|
|
@@ -42,4 +43,50 @@ describe('disconnectLinearInstallation', () => {
|
|
|
42
43
|
expect(deleteConnection).toHaveBeenCalledWith({connectionId: 'connection-1'}, {tx});
|
|
43
44
|
expect(calls).toEqual(['secrets', 'connection']);
|
|
44
45
|
});
|
|
46
|
+
|
|
47
|
+
it('keeps connection records when secret deletion fails', async () => {
|
|
48
|
+
const transaction = vi.fn();
|
|
49
|
+
const deleteConnection = vi.fn();
|
|
50
|
+
|
|
51
|
+
const run = disconnectLinearInstallation({
|
|
52
|
+
connectionId: 'connection-1',
|
|
53
|
+
getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
|
|
54
|
+
deleteSecrets: vi.fn(() => Promise.reject(new Error('secret store unavailable'))),
|
|
55
|
+
transaction,
|
|
56
|
+
deleteConnection,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await expect(run).rejects.toThrow('secret store unavailable');
|
|
60
|
+
expect(transaction).not.toHaveBeenCalled();
|
|
61
|
+
expect(deleteLinearInstallationByConnectionIdMock).not.toHaveBeenCalled();
|
|
62
|
+
expect(deleteConnection).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('retries idempotent secret deletion after the records transaction fails', async () => {
|
|
66
|
+
const tx = Symbol('tx');
|
|
67
|
+
const deleteSecrets = vi.fn(() => Promise.resolve(0));
|
|
68
|
+
const deleteConnection = vi.fn(() => Promise.resolve(true));
|
|
69
|
+
const transaction = vi
|
|
70
|
+
.fn()
|
|
71
|
+
.mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => {
|
|
72
|
+
await fn(tx);
|
|
73
|
+
throw new Error('database commit failed');
|
|
74
|
+
})
|
|
75
|
+
.mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => await fn(tx));
|
|
76
|
+
const params = {
|
|
77
|
+
connectionId: 'connection-1',
|
|
78
|
+
getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
|
|
79
|
+
deleteSecrets,
|
|
80
|
+
transaction,
|
|
81
|
+
deleteConnection,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const firstAttempt = disconnectLinearInstallation(params);
|
|
85
|
+
await expect(firstAttempt).rejects.toThrow('database commit failed');
|
|
86
|
+
await disconnectLinearInstallation(params);
|
|
87
|
+
|
|
88
|
+
expect(deleteSecrets).toHaveBeenCalledTimes(2);
|
|
89
|
+
expect(deleteLinearInstallationByConnectionIdMock).toHaveBeenCalledTimes(2);
|
|
90
|
+
expect(deleteConnection).toHaveBeenCalledTimes(2);
|
|
91
|
+
});
|
|
45
92
|
});
|
package/src/core/tokens.test.ts
CHANGED
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
|
|
14
14
|
let secrets: LinearSecretsStore;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
secrets =
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
secrets = createInMemorySecretsStore();
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
function createConnectionContext() {
|
|
@@ -66,6 +66,20 @@ function storedToken(input: {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function createInMemorySecretsStore(): LinearSecretsStore {
|
|
70
|
+
const values = new Map<string, string>();
|
|
71
|
+
const id = (params: {workspaceId: string; namespace: string; key: string}) =>
|
|
72
|
+
`${params.workspaceId}\0${params.namespace}\0${params.key}`;
|
|
73
|
+
return {
|
|
74
|
+
getSecret: async (params) => values.get(id(params)) ?? null,
|
|
75
|
+
setSecrets: async (params) => {
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
for (const [key, value] of Object.entries(params.values))
|
|
78
|
+
values.set(id({...params, key}), value);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
describe('createLinearTokenStore.storeTokens', () => {
|
|
70
84
|
it('stores access and refresh tokens in the Linear system namespace', async () => {
|
|
71
85
|
const {workspaceId, connectionId, store} = createConnectionContext();
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {createHmac, randomUUID} from 'node:crypto';
|
|
2
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import {createStoredWebhookRequest} from '@shipfox/api-integration-core-dto';
|
|
4
|
+
import {db} from '#db/db.js';
|
|
5
|
+
import {upsertLinearInstallation} from '#db/installations.js';
|
|
6
|
+
import {linearInstallations} from '#db/schema/installations.js';
|
|
7
|
+
import {createLinearWebhookProcessor} from './webhook-processor.js';
|
|
8
|
+
|
|
9
|
+
const WEBHOOK_SECRET = 'test-webhook-secret';
|
|
10
|
+
|
|
11
|
+
function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
|
|
12
|
+
return {
|
|
13
|
+
id: randomUUID(),
|
|
14
|
+
workspaceId: randomUUID(),
|
|
15
|
+
provider: 'linear',
|
|
16
|
+
externalAccountId: 'org-1',
|
|
17
|
+
slug: 'Linear_Acme',
|
|
18
|
+
displayName: 'Linear Acme',
|
|
19
|
+
lifecycleStatus: 'active',
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
updatedAt: new Date(),
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function signedRequest(input: {
|
|
27
|
+
body: Record<string, unknown>;
|
|
28
|
+
deliveryId?: string | undefined;
|
|
29
|
+
receivedAt: string;
|
|
30
|
+
}) {
|
|
31
|
+
const rawBody = Buffer.from(JSON.stringify(input.body));
|
|
32
|
+
const signature = createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
|
|
33
|
+
|
|
34
|
+
return createStoredWebhookRequest({
|
|
35
|
+
requestId: randomUUID(),
|
|
36
|
+
routeId: 'linear',
|
|
37
|
+
receivedAt: input.receivedAt,
|
|
38
|
+
rawQueryString: '',
|
|
39
|
+
headers: {
|
|
40
|
+
'content-type': 'application/json',
|
|
41
|
+
'linear-delivery': input.deliveryId ?? randomUUID(),
|
|
42
|
+
'linear-event': String(input.body.type),
|
|
43
|
+
'linear-signature': signature,
|
|
44
|
+
},
|
|
45
|
+
body: rawBody,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function seedInstallation(input: {
|
|
50
|
+
connectionId: string;
|
|
51
|
+
organizationId: string;
|
|
52
|
+
}): Promise<void> {
|
|
53
|
+
await upsertLinearInstallation({
|
|
54
|
+
connectionId: input.connectionId,
|
|
55
|
+
organizationId: input.organizationId,
|
|
56
|
+
organizationUrlKey: 'acme',
|
|
57
|
+
appUserId: 'app-user-1',
|
|
58
|
+
scopes: ['read', 'write'],
|
|
59
|
+
status: 'installed',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Linear webhook processor', () => {
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
await db().delete(linearInstallations);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('accepts a delayed request that was valid when the adapter received it', async () => {
|
|
69
|
+
const connection = fakeConnection();
|
|
70
|
+
const receivedAt = new Date(Date.now() - 10 * 60_000).toISOString();
|
|
71
|
+
const webhookTimestamp = new Date(receivedAt).getTime();
|
|
72
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
73
|
+
const processor = createLinearWebhookProcessor({
|
|
74
|
+
coreDb: db,
|
|
75
|
+
publishIntegrationEventReceived,
|
|
76
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
77
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
|
|
78
|
+
});
|
|
79
|
+
await seedInstallation({connectionId: connection.id, organizationId: 'org-delayed'});
|
|
80
|
+
const request = signedRequest({
|
|
81
|
+
receivedAt,
|
|
82
|
+
body: {
|
|
83
|
+
action: 'create',
|
|
84
|
+
type: 'Issue',
|
|
85
|
+
organizationId: 'org-delayed',
|
|
86
|
+
webhookTimestamp,
|
|
87
|
+
data: {id: 'issue-1'},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await processor.process(request);
|
|
92
|
+
|
|
93
|
+
expect(result).toMatchObject({outcome: 'processed'});
|
|
94
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('discards a request that was stale when the adapter received it', async () => {
|
|
98
|
+
const receivedAt = new Date().toISOString();
|
|
99
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
100
|
+
const processor = createLinearWebhookProcessor({
|
|
101
|
+
coreDb: db,
|
|
102
|
+
publishIntegrationEventReceived,
|
|
103
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
104
|
+
getIntegrationConnectionById: vi.fn(),
|
|
105
|
+
});
|
|
106
|
+
const request = signedRequest({
|
|
107
|
+
receivedAt,
|
|
108
|
+
body: {
|
|
109
|
+
action: 'create',
|
|
110
|
+
type: 'Issue',
|
|
111
|
+
organizationId: 'org-stale',
|
|
112
|
+
webhookTimestamp: new Date(receivedAt).getTime() - 60_001,
|
|
113
|
+
data: {id: 'issue-1'},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await processor.process(request);
|
|
118
|
+
|
|
119
|
+
expect(result).toMatchObject({outcome: 'discarded', reason: 'stale_at_receipt'});
|
|
120
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('records and drops an unsupported signed event', async () => {
|
|
124
|
+
const connection = fakeConnection();
|
|
125
|
+
const receivedAt = new Date().toISOString();
|
|
126
|
+
const deliveryId = randomUUID();
|
|
127
|
+
const recordDeliveryOnly = vi.fn(() => Promise.resolve());
|
|
128
|
+
const processor = createLinearWebhookProcessor({
|
|
129
|
+
coreDb: db,
|
|
130
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
|
|
131
|
+
recordDeliveryOnly,
|
|
132
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
|
|
133
|
+
});
|
|
134
|
+
await seedInstallation({connectionId: connection.id, organizationId: 'org-unsupported'});
|
|
135
|
+
const request = signedRequest({
|
|
136
|
+
deliveryId,
|
|
137
|
+
receivedAt,
|
|
138
|
+
body: {
|
|
139
|
+
action: 'create',
|
|
140
|
+
type: 'Reaction',
|
|
141
|
+
organizationId: 'org-unsupported',
|
|
142
|
+
webhookTimestamp: new Date(receivedAt).getTime(),
|
|
143
|
+
data: {id: 'reaction-1'},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const result = await processor.process(request);
|
|
148
|
+
|
|
149
|
+
expect(result).toEqual({outcome: 'discarded', reason: 'unsupported_event', deliveryId});
|
|
150
|
+
expect(recordDeliveryOnly).toHaveBeenCalledWith(
|
|
151
|
+
expect.objectContaining({provider: 'linear', deliveryId}),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
decodeWebhookBody,
|
|
4
|
+
type GetIntegrationConnectionByIdFn,
|
|
5
|
+
type PublishIntegrationEventReceivedFn,
|
|
6
|
+
type RecordDeliveryOnlyFn,
|
|
7
|
+
type StoredWebhookRequest,
|
|
8
|
+
type WebhookProcessingResult,
|
|
9
|
+
} from '@shipfox/api-integration-core-dto';
|
|
10
|
+
import {
|
|
11
|
+
LINEAR_PROVIDER,
|
|
12
|
+
linearWebhookBaseEnvelopeSchema,
|
|
13
|
+
} from '@shipfox/api-integration-linear-dto';
|
|
14
|
+
import {verifyHexHmacSignature} from '@shipfox/node-fastify';
|
|
15
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
16
|
+
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
|
|
17
|
+
import {config} from '#config.js';
|
|
18
|
+
import {handleLinearWebhook} from '#core/webhook.js';
|
|
19
|
+
|
|
20
|
+
const DELIVERY_HEADER = 'linear-delivery';
|
|
21
|
+
const EVENT_HEADER = 'linear-event';
|
|
22
|
+
const SIGNATURE_HEADER = 'linear-signature';
|
|
23
|
+
const WEBHOOK_REPLAY_WINDOW_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
export interface CreateLinearWebhookProcessorOptions {
|
|
26
|
+
coreDb: () => NodePgDatabase<Record<string, unknown>>;
|
|
27
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
28
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
29
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LinearWebhookProcessor {
|
|
33
|
+
process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createLinearWebhookProcessor(
|
|
37
|
+
options: CreateLinearWebhookProcessorOptions,
|
|
38
|
+
): LinearWebhookProcessor {
|
|
39
|
+
return {process: (request) => processLinearWebhookRequest(options, request)};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function processLinearWebhookRequest(
|
|
43
|
+
options: CreateLinearWebhookProcessorOptions,
|
|
44
|
+
request: StoredWebhookRequest,
|
|
45
|
+
): Promise<WebhookProcessingResult> {
|
|
46
|
+
if (request.route_id !== 'linear') {
|
|
47
|
+
throw new Error(`Linear processor cannot process ${request.route_id} requests`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const deliveryId = request.headers[DELIVERY_HEADER];
|
|
51
|
+
const event = request.headers[EVENT_HEADER];
|
|
52
|
+
const signature = request.headers[SIGNATURE_HEADER];
|
|
53
|
+
if (!deliveryId || !event || !signature) {
|
|
54
|
+
return {outcome: 'discarded', reason: 'missing_required_input'};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rawBody = Buffer.from(decodeWebhookBody(request.body));
|
|
58
|
+
if (
|
|
59
|
+
!verifyHexHmacSignature({
|
|
60
|
+
rawBody,
|
|
61
|
+
signature,
|
|
62
|
+
secret: config.LINEAR_WEBHOOK_SIGNING_SECRET,
|
|
63
|
+
})
|
|
64
|
+
) {
|
|
65
|
+
return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let rawPayload: unknown;
|
|
69
|
+
try {
|
|
70
|
+
rawPayload = JSON.parse(rawBody.toString('utf8'));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger().warn({deliveryId, err: error}, 'linear webhook payload JSON parse failed');
|
|
73
|
+
return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const payload = linearWebhookBaseEnvelopeSchema.safeParse(rawPayload);
|
|
77
|
+
if (!payload.success) {
|
|
78
|
+
logger().warn(
|
|
79
|
+
{deliveryId, issues: payload.error.issues},
|
|
80
|
+
'linear webhook envelope failed schema validation',
|
|
81
|
+
);
|
|
82
|
+
await recordSignedDeliveryOnly(options, deliveryId);
|
|
83
|
+
return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const receivedAt = new Date(request.received_at).getTime();
|
|
87
|
+
if (Math.abs(receivedAt - payload.data.webhookTimestamp) > WEBHOOK_REPLAY_WINDOW_MS) {
|
|
88
|
+
return {outcome: 'discarded', reason: 'stale_at_receipt', deliveryId};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (event !== payload.data.type) {
|
|
92
|
+
logger().warn(
|
|
93
|
+
{deliveryId, event, type: payload.data.type},
|
|
94
|
+
'linear webhook event header did not match payload type',
|
|
95
|
+
);
|
|
96
|
+
await recordSignedDeliveryOnly(options, deliveryId);
|
|
97
|
+
return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const result = await options.coreDb().transaction(async (tx) =>
|
|
101
|
+
handleLinearWebhook({
|
|
102
|
+
tx,
|
|
103
|
+
deliveryId,
|
|
104
|
+
payload: payload.data,
|
|
105
|
+
rawPayload,
|
|
106
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
107
|
+
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
108
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (result.outcome === 'published') return {outcome: 'processed', deliveryId};
|
|
113
|
+
if (result.outcome === 'duplicate') return {outcome: 'duplicate', deliveryId};
|
|
114
|
+
if (result.outcome === 'unsupported-event') {
|
|
115
|
+
return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
|
|
116
|
+
}
|
|
117
|
+
return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function recordSignedDeliveryOnly(
|
|
121
|
+
options: Pick<CreateLinearWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,
|
|
122
|
+
deliveryId: string,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
await options.coreDb().transaction(async (tx) => {
|
|
125
|
+
await options.recordDeliveryOnly({tx, provider: LINEAR_PROVIDER, deliveryId});
|
|
126
|
+
});
|
|
127
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {createLinearApiClient, type LinearApiClient} from '#api/client.js';
|
|
|
3
3
|
import {config} from '#config.js';
|
|
4
4
|
import {LinearAgentToolsProvider} from '#core/agent-tools-provider.js';
|
|
5
5
|
import type {LinearTokenStore} from '#core/tokens.js';
|
|
6
|
+
import {createLinearWebhookProcessor} from '#core/webhook-processor.js';
|
|
6
7
|
import {closeDb, db} from '#db/db.js';
|
|
7
8
|
import {getLinearInstallationByConnectionId} from '#db/installations.js';
|
|
8
9
|
import {migrationsPath} from '#db/migrations.js';
|
|
@@ -67,6 +68,11 @@ export {
|
|
|
67
68
|
} from '#core/tokens.js';
|
|
68
69
|
export type {HandleLinearWebhookOutcome, HandleLinearWebhookParams} from '#core/webhook.js';
|
|
69
70
|
export {handleLinearWebhook} from '#core/webhook.js';
|
|
71
|
+
export type {
|
|
72
|
+
CreateLinearWebhookProcessorOptions,
|
|
73
|
+
LinearWebhookProcessor,
|
|
74
|
+
} from '#core/webhook-processor.js';
|
|
75
|
+
export {createLinearWebhookProcessor} from '#core/webhook-processor.js';
|
|
70
76
|
export type {
|
|
71
77
|
LinearInstallation,
|
|
72
78
|
LinearInstallationStatus,
|
|
@@ -124,6 +130,10 @@ export function createLinearIntegrationProvider(
|
|
|
124
130
|
agent_tools: new LinearAgentToolsProvider(options.agentTools),
|
|
125
131
|
}
|
|
126
132
|
: {};
|
|
133
|
+
const webhookProcessor =
|
|
134
|
+
options.routes && hasLinearWebhookRoutesOptions(options.routes)
|
|
135
|
+
? createLinearWebhookProcessor(options.routes)
|
|
136
|
+
: undefined;
|
|
127
137
|
|
|
128
138
|
const routes = options.routes
|
|
129
139
|
? [
|
|
@@ -134,8 +144,8 @@ export function createLinearIntegrationProvider(
|
|
|
134
144
|
}),
|
|
135
145
|
]
|
|
136
146
|
: [];
|
|
137
|
-
if (options.routes && hasLinearWebhookRoutesOptions(options.routes)) {
|
|
138
|
-
routes.push(createLinearWebhookRoutes(options.routes));
|
|
147
|
+
if (options.routes && hasLinearWebhookRoutesOptions(options.routes) && webhookProcessor) {
|
|
148
|
+
routes.push(createLinearWebhookRoutes({...options.routes, processor: webhookProcessor}));
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
return {
|
|
@@ -149,6 +159,9 @@ export function createLinearIntegrationProvider(
|
|
|
149
159
|
},
|
|
150
160
|
...options.cleanup,
|
|
151
161
|
routes,
|
|
162
|
+
webhookProcessors: webhookProcessor
|
|
163
|
+
? [{routeIds: ['linear'] as const, processor: webhookProcessor}]
|
|
164
|
+
: undefined,
|
|
152
165
|
};
|
|
153
166
|
}
|
|
154
167
|
|
|
@@ -16,12 +16,7 @@ import {verifyLinearInstallState} from '#core/state.js';
|
|
|
16
16
|
import type {LinearTokenStore} from '#core/tokens.js';
|
|
17
17
|
import {createLinearIntegrationProvider} from '#index.js';
|
|
18
18
|
|
|
19
|
-
vi.
|
|
20
|
-
requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
const {requireWorkspaceMembership} = await import('@shipfox/api-workspaces');
|
|
24
|
-
const requireWorkspaceMembershipMock = vi.mocked(requireWorkspaceMembership);
|
|
19
|
+
const requireWorkspaceMembershipMock = vi.fn(() => Promise.resolve());
|
|
25
20
|
let authenticatedMemberships: UserContextMembership[] = [];
|
|
26
21
|
|
|
27
22
|
const fakeUserAuth: AuthMethod = {
|
|
@@ -120,6 +115,7 @@ async function createTestApp(options: CreateTestAppOptions = {}): Promise<Fastif
|
|
|
120
115
|
),
|
|
121
116
|
disconnectLinearInstallation:
|
|
122
117
|
options.disconnectLinearInstallation ?? vi.fn(() => Promise.resolve()),
|
|
118
|
+
requireActiveWorkspaceMembership: requireWorkspaceMembershipMock,
|
|
123
119
|
},
|
|
124
120
|
});
|
|
125
121
|
const app = await createApp({
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
linearCallbackQuerySchema,
|
|
8
8
|
linearCallbackResponseSchema,
|
|
9
9
|
} from '@shipfox/api-integration-linear-dto';
|
|
10
|
-
import {requireWorkspaceMembership} from '@shipfox/api-workspaces';
|
|
11
10
|
import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
|
|
12
11
|
import type {LinearApiClient} from '#api/client.js';
|
|
13
12
|
import {config} from '#config.js';
|
|
@@ -33,6 +32,11 @@ export interface CreateLinearIntegrationRoutesOptions {
|
|
|
33
32
|
) => Promise<IntegrationConnection<'linear'>>;
|
|
34
33
|
disconnectLinearInstallation: (input: {connectionId: string}) => Promise<void>;
|
|
35
34
|
connectionCapabilities: IntegrationCapability[];
|
|
35
|
+
requireActiveWorkspaceMembership?: (input: {
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
|
|
39
|
+
}) => Promise<unknown>;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export function createLinearIntegrationRoutes({
|
|
@@ -42,6 +46,7 @@ export function createLinearIntegrationRoutes({
|
|
|
42
46
|
connectLinearInstallation,
|
|
43
47
|
disconnectLinearInstallation,
|
|
44
48
|
connectionCapabilities,
|
|
49
|
+
requireActiveWorkspaceMembership,
|
|
45
50
|
}: CreateLinearIntegrationRoutesOptions): RouteGroup {
|
|
46
51
|
const createInstallRoute = defineRoute({
|
|
47
52
|
method: 'POST',
|
|
@@ -95,7 +100,8 @@ export function createLinearIntegrationRoutes({
|
|
|
95
100
|
errorDescription: query.error_description,
|
|
96
101
|
sessionUserId: actor.userId,
|
|
97
102
|
sessionMemberships: actor.memberships,
|
|
98
|
-
requireWorkspaceMembership
|
|
103
|
+
requireWorkspaceMembership:
|
|
104
|
+
requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,
|
|
99
105
|
});
|
|
100
106
|
}
|
|
101
107
|
const connection = await handleLinearCallback({
|
|
@@ -105,7 +111,8 @@ export function createLinearIntegrationRoutes({
|
|
|
105
111
|
state: query.state,
|
|
106
112
|
sessionUserId: actor.userId,
|
|
107
113
|
sessionMemberships: actor.memberships,
|
|
108
|
-
requireWorkspaceMembership
|
|
114
|
+
requireWorkspaceMembership:
|
|
115
|
+
requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,
|
|
109
116
|
getExistingLinearConnection,
|
|
110
117
|
connectLinearInstallation,
|
|
111
118
|
disconnectLinearInstallation,
|
|
@@ -121,6 +128,14 @@ export function createLinearIntegrationRoutes({
|
|
|
121
128
|
};
|
|
122
129
|
}
|
|
123
130
|
|
|
131
|
+
function unavailableWorkspaceMembershipCheck(_input: {
|
|
132
|
+
workspaceId: string;
|
|
133
|
+
userId: string;
|
|
134
|
+
memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
|
|
135
|
+
}): Promise<never> {
|
|
136
|
+
return Promise.reject(new Error('Workspaces inter-module client is not configured'));
|
|
137
|
+
}
|
|
138
|
+
|
|
124
139
|
function isLinearOAuthErrorCallback(
|
|
125
140
|
query: LinearCallbackQueryDto,
|
|
126
141
|
): query is LinearCallbackQueryDto & {
|
|
@@ -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":';
|