@shipfox/api-integration-linear 2.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.
Files changed (151) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +89 -0
  5. package/LICENSE +21 -0
  6. package/README.md +88 -0
  7. package/dist/api/client.d.ts +29 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +189 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +8 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +22 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/agent-tools-provider.d.ts +32 -0
  16. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  17. package/dist/core/agent-tools-provider.js +63 -0
  18. package/dist/core/agent-tools-provider.js.map +1 -0
  19. package/dist/core/agent-tools.d.ts +10 -0
  20. package/dist/core/agent-tools.d.ts.map +1 -0
  21. package/dist/core/agent-tools.js +920 -0
  22. package/dist/core/agent-tools.js.map +1 -0
  23. package/dist/core/disconnect.d.ts +18 -0
  24. package/dist/core/disconnect.d.ts.map +1 -0
  25. package/dist/core/disconnect.js +23 -0
  26. package/dist/core/disconnect.js.map +1 -0
  27. package/dist/core/errors.d.ts +35 -0
  28. package/dist/core/errors.d.ts.map +1 -0
  29. package/dist/core/errors.js +59 -0
  30. package/dist/core/errors.js.map +1 -0
  31. package/dist/core/install.d.ts +47 -0
  32. package/dist/core/install.d.ts.map +1 -0
  33. package/dist/core/install.js +102 -0
  34. package/dist/core/install.js.map +1 -0
  35. package/dist/core/scopes.d.ts +4 -0
  36. package/dist/core/scopes.d.ts.map +1 -0
  37. package/dist/core/scopes.js +17 -0
  38. package/dist/core/scopes.js.map +1 -0
  39. package/dist/core/state.d.ts +12 -0
  40. package/dist/core/state.d.ts.map +1 -0
  41. package/dist/core/state.js +54 -0
  42. package/dist/core/state.js.map +1 -0
  43. package/dist/core/tokens.d.ts +39 -0
  44. package/dist/core/tokens.d.ts.map +1 -0
  45. package/dist/core/tokens.js +117 -0
  46. package/dist/core/tokens.js.map +1 -0
  47. package/dist/core/webhook.d.ts +16 -0
  48. package/dist/core/webhook.d.ts.map +1 -0
  49. package/dist/core/webhook.js +105 -0
  50. package/dist/core/webhook.js.map +1 -0
  51. package/dist/db/db.d.ts +369 -0
  52. package/dist/db/db.d.ts.map +1 -0
  53. package/dist/db/db.js +18 -0
  54. package/dist/db/db.js.map +1 -0
  55. package/dist/db/installations.d.ts +53 -0
  56. package/dist/db/installations.d.ts.map +1 -0
  57. package/dist/db/installations.js +125 -0
  58. package/dist/db/installations.js.map +1 -0
  59. package/dist/db/migrations.d.ts +2 -0
  60. package/dist/db/migrations.d.ts.map +1 -0
  61. package/dist/db/migrations.js +5 -0
  62. package/dist/db/migrations.js.map +1 -0
  63. package/dist/db/schema/common.d.ts +2 -0
  64. package/dist/db/schema/common.d.ts.map +1 -0
  65. package/dist/db/schema/common.js +4 -0
  66. package/dist/db/schema/common.js.map +1 -0
  67. package/dist/db/schema/installations.d.ts +186 -0
  68. package/dist/db/schema/installations.d.ts.map +1 -0
  69. package/dist/db/schema/installations.js +40 -0
  70. package/dist/db/schema/installations.js.map +1 -0
  71. package/dist/index.d.ts +53 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +56 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/presentation/dto/integrations.d.ts +17 -0
  76. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  77. package/dist/presentation/dto/integrations.js +6 -0
  78. package/dist/presentation/dto/integrations.js.map +1 -0
  79. package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
  80. package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
  81. package/dist/presentation/e2eRoutes/create-connection.js +64 -0
  82. package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
  83. package/dist/presentation/e2eRoutes/index.d.ts +5 -0
  84. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  85. package/dist/presentation/e2eRoutes/index.js +11 -0
  86. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  87. package/dist/presentation/routes/errors.d.ts +2 -0
  88. package/dist/presentation/routes/errors.d.ts.map +1 -0
  89. package/dist/presentation/routes/errors.js +65 -0
  90. package/dist/presentation/routes/errors.js.map +1 -0
  91. package/dist/presentation/routes/install.d.ts +19 -0
  92. package/dist/presentation/routes/install.d.ts.map +1 -0
  93. package/dist/presentation/routes/install.js +100 -0
  94. package/dist/presentation/routes/install.js.map +1 -0
  95. package/dist/presentation/routes/webhooks.d.ts +11 -0
  96. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  97. package/dist/presentation/routes/webhooks.js +136 -0
  98. package/dist/presentation/routes/webhooks.js.map +1 -0
  99. package/dist/tsconfig.test.tsbuildinfo +1 -0
  100. package/drizzle/0000_initial.sql +15 -0
  101. package/drizzle/meta/0000_snapshot.json +126 -0
  102. package/drizzle/meta/_journal.json +13 -0
  103. package/drizzle.config.ts +7 -0
  104. package/package.json +72 -0
  105. package/src/api/client.test.ts +366 -0
  106. package/src/api/client.ts +281 -0
  107. package/src/config.test.ts +34 -0
  108. package/src/config.ts +22 -0
  109. package/src/connection-external-url.test.ts +64 -0
  110. package/src/core/agent-tools-provider.test.ts +245 -0
  111. package/src/core/agent-tools-provider.ts +106 -0
  112. package/src/core/agent-tools.ts +918 -0
  113. package/src/core/disconnect.test.ts +45 -0
  114. package/src/core/disconnect.ts +27 -0
  115. package/src/core/errors.ts +71 -0
  116. package/src/core/install.ts +162 -0
  117. package/src/core/scopes.ts +18 -0
  118. package/src/core/state.test.ts +51 -0
  119. package/src/core/state.ts +85 -0
  120. package/src/core/tokens.test.ts +281 -0
  121. package/src/core/tokens.ts +219 -0
  122. package/src/core/webhook.test.ts +318 -0
  123. package/src/core/webhook.ts +146 -0
  124. package/src/db/db.ts +18 -0
  125. package/src/db/installations.test.ts +182 -0
  126. package/src/db/installations.ts +215 -0
  127. package/src/db/migrations.ts +4 -0
  128. package/src/db/schema/common.ts +3 -0
  129. package/src/db/schema/installations.ts +42 -0
  130. package/src/index.test.ts +20 -0
  131. package/src/index.ts +153 -0
  132. package/src/presentation/dto/integrations.ts +9 -0
  133. package/src/presentation/e2eRoutes/create-connection.ts +85 -0
  134. package/src/presentation/e2eRoutes/index.test.ts +193 -0
  135. package/src/presentation/e2eRoutes/index.ts +14 -0
  136. package/src/presentation/routes/errors.ts +60 -0
  137. package/src/presentation/routes/install.test.ts +460 -0
  138. package/src/presentation/routes/install.ts +132 -0
  139. package/src/presentation/routes/webhooks.test.ts +407 -0
  140. package/src/presentation/routes/webhooks.ts +154 -0
  141. package/test/api-secrets.d.ts +28 -0
  142. package/test/env.ts +13 -0
  143. package/test/factories/index.ts +1 -0
  144. package/test/globalSetup.ts +23 -0
  145. package/test/index.ts +1 -0
  146. package/test/setup.ts +17 -0
  147. package/tsconfig.build.json +9 -0
  148. package/tsconfig.build.tsbuildinfo +1 -0
  149. package/tsconfig.json +3 -0
  150. package/tsconfig.test.json +9 -0
  151. package/vitest.config.ts +12 -0
@@ -0,0 +1,407 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {closeApp, createApp} from '@shipfox/node-fastify';
4
+ import type {FastifyInstance} from 'fastify';
5
+ import {db} from '#db/db.js';
6
+ import {upsertLinearInstallation} from '#db/installations.js';
7
+ import {linearInstallations} from '#db/schema/installations.js';
8
+ import {createLinearWebhookRoutes} from './webhooks.js';
9
+
10
+ const WEBHOOK_SECRET = 'test-webhook-secret';
11
+
12
+ function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
13
+ return {
14
+ id: randomUUID(),
15
+ workspaceId: randomUUID(),
16
+ provider: 'linear',
17
+ externalAccountId: 'org-1',
18
+ slug: 'Linear_Acme',
19
+ displayName: 'Linear Acme',
20
+ lifecycleStatus: 'active',
21
+ createdAt: new Date(),
22
+ updatedAt: new Date(),
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function linearPayload(overrides: Record<string, unknown> = {}) {
28
+ return {
29
+ action: 'create',
30
+ type: 'Issue',
31
+ organizationId: `org-${randomUUID()}`,
32
+ webhookTimestamp: Date.now(),
33
+ data: {id: 'issue-1'},
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function agentSessionPayload(overrides: Record<string, unknown> = {}) {
39
+ return {
40
+ action: 'created',
41
+ type: 'AgentSessionEvent',
42
+ organizationId: `org-${randomUUID()}`,
43
+ appUserId: 'app-user-1',
44
+ webhookTimestamp: Date.now(),
45
+ agentSession: {id: 'session-1'},
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function signedHeaders(rawBody: string, event: string, deliveryId: string) {
51
+ const signature = createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
52
+ return {
53
+ 'content-type': 'application/json',
54
+ 'linear-delivery': deliveryId,
55
+ 'linear-event': event,
56
+ 'linear-signature': signature,
57
+ };
58
+ }
59
+
60
+ interface TestApp {
61
+ app: FastifyInstance;
62
+ publishIntegrationEventReceived: ReturnType<typeof vi.fn>;
63
+ recordDeliveryOnly: ReturnType<typeof vi.fn>;
64
+ getIntegrationConnectionById: ReturnType<typeof vi.fn>;
65
+ }
66
+
67
+ async function createTestApp(
68
+ options: {connection?: IntegrationConnection | undefined} = {},
69
+ ): Promise<TestApp> {
70
+ const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
71
+ const recordDeliveryOnly = vi.fn(() => Promise.resolve());
72
+ const getIntegrationConnectionById = vi.fn(() =>
73
+ Promise.resolve(options.connection ?? fakeConnection()),
74
+ );
75
+ const routes = createLinearWebhookRoutes({
76
+ coreDb: db,
77
+ publishIntegrationEventReceived,
78
+ recordDeliveryOnly,
79
+ getIntegrationConnectionById,
80
+ });
81
+ const app = await createApp({routes: [routes], swagger: false});
82
+ await app.ready();
83
+ return {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById};
84
+ }
85
+
86
+ async function seedInstallation(input: {
87
+ connectionId: string;
88
+ organizationId: string;
89
+ }): Promise<void> {
90
+ await upsertLinearInstallation({
91
+ connectionId: input.connectionId,
92
+ organizationId: input.organizationId,
93
+ organizationUrlKey: 'acme',
94
+ appUserId: 'app-user-1',
95
+ scopes: ['read', 'write'],
96
+ status: 'installed',
97
+ });
98
+ }
99
+
100
+ describe('Linear webhook route', () => {
101
+ beforeEach(async () => {
102
+ await closeApp();
103
+ await db().delete(linearInstallations);
104
+ });
105
+
106
+ afterEach(async () => {
107
+ await closeApp();
108
+ });
109
+
110
+ it('publishes a supported signed data webhook', async () => {
111
+ const connection = fakeConnection();
112
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp({
113
+ connection,
114
+ });
115
+ const deliveryId = randomUUID();
116
+ const rawPayload = linearPayload({organizationId: 'org-active'});
117
+ await seedInstallation({connectionId: connection.id, organizationId: 'org-active'});
118
+ const body = JSON.stringify(rawPayload);
119
+
120
+ const res = await app.inject({
121
+ method: 'POST',
122
+ url: '/webhooks/integrations/linear',
123
+ headers: signedHeaders(body, 'Issue', deliveryId),
124
+ payload: body,
125
+ });
126
+
127
+ expect(res.statusCode).toBe(200);
128
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
129
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
130
+ expect(publishIntegrationEventReceived.mock.calls[0]?.[0]).toMatchObject({
131
+ event: {
132
+ provider: 'linear',
133
+ source: connection.slug,
134
+ event: 'Issue.create',
135
+ workspaceId: connection.workspaceId,
136
+ connectionId: connection.id,
137
+ connectionName: connection.displayName,
138
+ deliveryId,
139
+ payload: rawPayload,
140
+ },
141
+ });
142
+ });
143
+
144
+ it('uses the Linear-Delivery header as the delivery dedup key', async () => {
145
+ const connection = fakeConnection();
146
+ const {app, publishIntegrationEventReceived} = await createTestApp({connection});
147
+ const rawPayload = linearPayload({organizationId: 'org-dedup'});
148
+ await seedInstallation({connectionId: connection.id, organizationId: 'org-dedup'});
149
+ const body = JSON.stringify(rawPayload);
150
+
151
+ const first = await app.inject({
152
+ method: 'POST',
153
+ url: '/webhooks/integrations/linear',
154
+ headers: signedHeaders(body, 'Issue', 'linear-delivery-a'),
155
+ payload: body,
156
+ });
157
+ const second = await app.inject({
158
+ method: 'POST',
159
+ url: '/webhooks/integrations/linear',
160
+ headers: signedHeaders(body, 'Issue', 'linear-delivery-b'),
161
+ payload: body,
162
+ });
163
+
164
+ expect(first.statusCode).toBe(200);
165
+ expect(second.statusCode).toBe(200);
166
+ expect(
167
+ publishIntegrationEventReceived.mock.calls.map(([call]) => call.event.deliveryId),
168
+ ).toEqual(['linear-delivery-a', 'linear-delivery-b']);
169
+ });
170
+
171
+ it.each([
172
+ ['an assignment', {agentSession: {id: 'session-1', issueId: 'issue-1'}}],
173
+ [
174
+ 'a comment mention',
175
+ {agentSession: {id: 'session-1', commentId: 'comment-1', sourceCommentId: 'comment-1'}},
176
+ ],
177
+ ])('publishes %s as agentSession.created', async (_context, details) => {
178
+ const connection = fakeConnection();
179
+ const {app, publishIntegrationEventReceived} = await createTestApp({connection});
180
+ const deliveryId = randomUUID();
181
+ const rawPayload = agentSessionPayload({
182
+ organizationId: `org-${deliveryId}`,
183
+ promptContext: '<issue identifier="ENG-879">Route the complete payload</issue>',
184
+ ...details,
185
+ });
186
+ await seedInstallation({connectionId: connection.id, organizationId: `org-${deliveryId}`});
187
+ const body = JSON.stringify(rawPayload);
188
+
189
+ const res = await app.inject({
190
+ method: 'POST',
191
+ url: '/webhooks/integrations/linear',
192
+ headers: signedHeaders(body, 'AgentSessionEvent', deliveryId),
193
+ payload: body,
194
+ });
195
+
196
+ expect(res.statusCode).toBe(200);
197
+ expect(publishIntegrationEventReceived).toHaveBeenCalledWith(
198
+ expect.objectContaining({
199
+ event: expect.objectContaining({event: 'agentSession.created', payload: rawPayload}),
200
+ }),
201
+ );
202
+ });
203
+
204
+ it('publishes prompted AgentSessionEvent deliveries without an agent response', async () => {
205
+ const connection = fakeConnection();
206
+ const {app, publishIntegrationEventReceived} = await createTestApp({connection});
207
+ const deliveryId = randomUUID();
208
+ const rawPayload = agentSessionPayload({
209
+ action: 'prompted',
210
+ organizationId: `org-${deliveryId}`,
211
+ agentSession: {id: 'session-1', issueId: 'issue-1'},
212
+ });
213
+ await seedInstallation({connectionId: connection.id, organizationId: `org-${deliveryId}`});
214
+ const body = JSON.stringify(rawPayload);
215
+
216
+ const res = await app.inject({
217
+ method: 'POST',
218
+ url: '/webhooks/integrations/linear',
219
+ headers: signedHeaders(body, 'AgentSessionEvent', deliveryId),
220
+ payload: body,
221
+ });
222
+
223
+ expect(res.statusCode).toBe(200);
224
+ expect(publishIntegrationEventReceived).toHaveBeenCalledWith(
225
+ expect.objectContaining({event: expect.objectContaining({event: 'agentSession.prompted'})}),
226
+ );
227
+ });
228
+
229
+ it.each([
230
+ ['linear-delivery', 'missing Linear-Delivery header'],
231
+ ['linear-event', 'missing Linear-Event header'],
232
+ ['linear-signature', 'missing Linear-Signature header'],
233
+ ])('rejects requests missing the %s header', async (headerName, error) => {
234
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
235
+ const body = JSON.stringify(linearPayload());
236
+ const headers = signedHeaders(body, 'Issue', randomUUID());
237
+ delete headers[headerName as keyof typeof headers];
238
+
239
+ const res = await app.inject({
240
+ method: 'POST',
241
+ url: '/webhooks/integrations/linear',
242
+ headers,
243
+ payload: body,
244
+ });
245
+
246
+ expect(res.statusCode).toBe(headerName === 'linear-signature' ? 401 : 400);
247
+ expect(res.json()).toEqual({error});
248
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
249
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it('rejects an invalid signature before publishing or recording', async () => {
253
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
254
+ const body = JSON.stringify(linearPayload());
255
+ const headers = signedHeaders(body, 'Issue', randomUUID());
256
+ headers['linear-signature'] = 'bad-signature';
257
+
258
+ const res = await app.inject({
259
+ method: 'POST',
260
+ url: '/webhooks/integrations/linear',
261
+ headers,
262
+ payload: body,
263
+ });
264
+
265
+ expect(res.statusCode).toBe(401);
266
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
267
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it('rejects malformed JSON after signature verification', async () => {
271
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
272
+ const body = '{"type":';
273
+
274
+ const res = await app.inject({
275
+ method: 'POST',
276
+ url: '/webhooks/integrations/linear',
277
+ headers: signedHeaders(body, 'Issue', randomUUID()),
278
+ payload: body,
279
+ });
280
+
281
+ expect(res.statusCode).toBe(400);
282
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
283
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
284
+ });
285
+
286
+ it('records and drops signed JSON that does not match the base webhook envelope', async () => {
287
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById} =
288
+ await createTestApp();
289
+ const deliveryId = randomUUID();
290
+ const body = JSON.stringify({
291
+ action: 'create',
292
+ type: 'AppUserNotification',
293
+ organizationId: 'org-notification',
294
+ webhookTimestamp: Date.now(),
295
+ notification: {id: 'notification-1'},
296
+ });
297
+
298
+ const res = await app.inject({
299
+ method: 'POST',
300
+ url: '/webhooks/integrations/linear',
301
+ headers: signedHeaders(body, 'AppUserNotification', deliveryId),
302
+ payload: body,
303
+ });
304
+
305
+ expect(res.statusCode).toBe(200);
306
+ expect(getIntegrationConnectionById).not.toHaveBeenCalled();
307
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
308
+ expect(recordDeliveryOnly).toHaveBeenCalledWith(
309
+ expect.objectContaining({provider: 'linear', deliveryId}),
310
+ );
311
+ });
312
+
313
+ it('rejects stale webhook timestamps before publishing or recording', async () => {
314
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
315
+ const body = JSON.stringify(linearPayload({webhookTimestamp: Date.now() - 61_000}));
316
+
317
+ const res = await app.inject({
318
+ method: 'POST',
319
+ url: '/webhooks/integrations/linear',
320
+ headers: signedHeaders(body, 'Issue', randomUUID()),
321
+ payload: body,
322
+ });
323
+
324
+ expect(res.statusCode).toBe(401);
325
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
326
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
327
+ });
328
+
329
+ it('rejects future webhook timestamps before publishing or recording', async () => {
330
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
331
+ const body = JSON.stringify(linearPayload({webhookTimestamp: Date.now() + 61_000}));
332
+
333
+ const res = await app.inject({
334
+ method: 'POST',
335
+ url: '/webhooks/integrations/linear',
336
+ headers: signedHeaders(body, 'Issue', randomUUID()),
337
+ payload: body,
338
+ });
339
+
340
+ expect(res.statusCode).toBe(401);
341
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
342
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
343
+ });
344
+
345
+ it('records and drops when the Linear-Event header disagrees with the signed payload type', async () => {
346
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
347
+ const deliveryId = randomUUID();
348
+ const body = JSON.stringify(linearPayload({type: 'Issue'}));
349
+
350
+ const res = await app.inject({
351
+ method: 'POST',
352
+ url: '/webhooks/integrations/linear',
353
+ headers: signedHeaders(body, 'Comment', deliveryId),
354
+ payload: body,
355
+ });
356
+
357
+ expect(res.statusCode).toBe(200);
358
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
359
+ expect(recordDeliveryOnly).toHaveBeenCalledWith(
360
+ expect.objectContaining({provider: 'linear', deliveryId}),
361
+ );
362
+ });
363
+
364
+ it('records and drops a signed webhook for an unknown organization', async () => {
365
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById} =
366
+ await createTestApp();
367
+ const deliveryId = randomUUID();
368
+ const body = JSON.stringify(linearPayload({organizationId: 'org-missing'}));
369
+
370
+ const res = await app.inject({
371
+ method: 'POST',
372
+ url: '/webhooks/integrations/linear',
373
+ headers: signedHeaders(body, 'Issue', deliveryId),
374
+ payload: body,
375
+ });
376
+
377
+ expect(res.statusCode).toBe(200);
378
+ expect(getIntegrationConnectionById).not.toHaveBeenCalled();
379
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
380
+ expect(recordDeliveryOnly).toHaveBeenCalledWith(
381
+ expect.objectContaining({provider: 'linear', deliveryId}),
382
+ );
383
+ });
384
+
385
+ it('records and drops signed unsupported resource events', async () => {
386
+ const connection = fakeConnection();
387
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp({
388
+ connection,
389
+ });
390
+ const deliveryId = randomUUID();
391
+ await seedInstallation({connectionId: connection.id, organizationId: 'org-reaction'});
392
+ const body = JSON.stringify(linearPayload({organizationId: 'org-reaction', type: 'Reaction'}));
393
+
394
+ const res = await app.inject({
395
+ method: 'POST',
396
+ url: '/webhooks/integrations/linear',
397
+ headers: signedHeaders(body, 'Reaction', deliveryId),
398
+ payload: body,
399
+ });
400
+
401
+ expect(res.statusCode).toBe(200);
402
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
403
+ expect(recordDeliveryOnly).toHaveBeenCalledWith(
404
+ expect.objectContaining({provider: 'linear', deliveryId}),
405
+ );
406
+ });
407
+ });
@@ -0,0 +1,154 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import type {
3
+ GetIntegrationConnectionByIdFn,
4
+ IntegrationTx,
5
+ PublishIntegrationEventReceivedFn,
6
+ RecordDeliveryOnlyFn,
7
+ } from '@shipfox/api-integration-core-dto';
8
+ import {
9
+ LINEAR_PROVIDER,
10
+ linearWebhookBaseEnvelopeSchema,
11
+ } from '@shipfox/api-integration-linear-dto';
12
+ import {
13
+ defineRoute,
14
+ type RouteGroup,
15
+ rawBodyPlugin,
16
+ verifyHexHmacSignature,
17
+ WEBHOOK_BODY_LIMIT,
18
+ } from '@shipfox/node-fastify';
19
+ import {logger} from '@shipfox/node-opentelemetry';
20
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
21
+ import {config} from '#config.js';
22
+ import {handleLinearWebhook} from '#core/webhook.js';
23
+
24
+ const DELIVERY_HEADER = 'linear-delivery';
25
+ const EVENT_HEADER = 'linear-event';
26
+ const SIGNATURE_HEADER = 'linear-signature';
27
+ const WEBHOOK_REPLAY_WINDOW_MS = 60_000;
28
+
29
+ export interface CreateLinearWebhookRoutesOptions {
30
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
31
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
32
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
33
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
34
+ }
35
+
36
+ export function createLinearWebhookRoutes(options: CreateLinearWebhookRoutesOptions): RouteGroup {
37
+ const webhookRoute = defineRoute({
38
+ method: 'POST',
39
+ path: '/',
40
+ auth: [],
41
+ description: 'Linear webhook receiver.',
42
+ options: {bodyLimit: WEBHOOK_BODY_LIMIT},
43
+ handler: async (request, reply) => {
44
+ const deliveryHeader = request.headers[DELIVERY_HEADER];
45
+ const event = request.headers[EVENT_HEADER];
46
+ const signature = request.headers[SIGNATURE_HEADER];
47
+
48
+ if (typeof deliveryHeader !== 'string' || !deliveryHeader) {
49
+ reply.code(400);
50
+ return {error: 'missing Linear-Delivery header'};
51
+ }
52
+ if (typeof event !== 'string' || !event) {
53
+ reply.code(400);
54
+ return {error: 'missing Linear-Event header'};
55
+ }
56
+ if (typeof signature !== 'string' || !signature) {
57
+ reply.code(401);
58
+ return {error: 'missing Linear-Signature header'};
59
+ }
60
+
61
+ const body = request.body;
62
+ if (!Buffer.isBuffer(body)) {
63
+ reply.code(400);
64
+ return {error: 'expected raw JSON body'};
65
+ }
66
+ const rawBody = body.toString('utf8');
67
+
68
+ if (
69
+ !verifyHexHmacSignature({
70
+ rawBody,
71
+ signature,
72
+ secret: config.LINEAR_WEBHOOK_SIGNING_SECRET,
73
+ })
74
+ ) {
75
+ reply.code(401);
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
+ }
106
+
107
+ if (event !== payload.data.type) {
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;
132
+ },
133
+ });
134
+
135
+ return {
136
+ prefix: '/webhooks/integrations/linear',
137
+ auth: [],
138
+ plugins: [rawBodyPlugin],
139
+ routes: [webhookRoute],
140
+ };
141
+ }
142
+
143
+ async function recordSignedDeliveryOnly(
144
+ options: Pick<CreateLinearWebhookRoutesOptions, 'coreDb' | 'recordDeliveryOnly'>,
145
+ deliveryId: string,
146
+ ): Promise<void> {
147
+ await options.coreDb().transaction(async (tx: IntegrationTx) => {
148
+ await options.recordDeliveryOnly({
149
+ tx,
150
+ provider: LINEAR_PROVIDER,
151
+ deliveryId,
152
+ });
153
+ });
154
+ }
@@ -0,0 +1,28 @@
1
+ declare module '@shipfox/api-secrets' {
2
+ import type {NodePgDatabase} from '@shipfox/node-drizzle';
3
+
4
+ export function getSecret(params: {
5
+ workspaceId: string;
6
+ namespace: string;
7
+ key: string;
8
+ }): Promise<string | null>;
9
+
10
+ export function setSecrets(params: {
11
+ workspaceId: string;
12
+ namespace: string;
13
+ values: Record<string, string>;
14
+ editedBy?: string | null | undefined;
15
+ }): Promise<void>;
16
+
17
+ export function deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
18
+
19
+ export const secretsModule: {
20
+ database?:
21
+ | {
22
+ db(): NodePgDatabase<Record<string, unknown>>;
23
+ migrationsPath: string;
24
+ }
25
+ | unknown[]
26
+ | undefined;
27
+ };
28
+ }
package/test/env.ts ADDED
@@ -0,0 +1,13 @@
1
+ process.env.POSTGRES_HOST ??= 'localhost';
2
+ process.env.POSTGRES_PORT ??= '5432';
3
+ process.env.POSTGRES_USERNAME ??= 'shipfox';
4
+ process.env.POSTGRES_PASSWORD ??= 'password';
5
+ process.env.POSTGRES_DATABASE = 'api_test';
6
+ process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
7
+ process.env.TZ = 'UTC';
8
+ process.env.LINEAR_OAUTH_CLIENT_ID = 'test-client-id';
9
+ process.env.LINEAR_OAUTH_CLIENT_SECRET = 'test-client-secret';
10
+ process.env.LINEAR_WEBHOOK_SIGNING_SECRET = 'test-webhook-secret';
11
+ process.env.LINEAR_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/linear/callback';
12
+ process.env.LINEAR_MCP_ENDPOINT = 'http://127.0.0.1:0/mcp';
13
+ process.env.SECRETS_ENCRYPTION_KEK = 'ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import './env.js';
2
+ import {runMigrations} from '@shipfox/node-drizzle';
3
+ import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
4
+ import {closeDb, db} from '#db/db.js';
5
+ import {migrationsPath} from '#db/migrations.js';
6
+
7
+ export async function setup() {
8
+ createPostgresClient();
9
+
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
+
21
+ closeDb();
22
+ await closePostgresClient();
23
+ }
package/test/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './factories/index.js';
package/test/setup.ts ADDED
@@ -0,0 +1,17 @@
1
+ import './env.js';
2
+ import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
3
+ import {afterAll, afterEach, beforeAll, vi} from '@shipfox/vitest/vi';
4
+ import {closeDb} from '#db/db.js';
5
+
6
+ beforeAll(() => {
7
+ createPostgresClient();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ closeDb();
16
+ await closePostgresClient();
17
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@shipfox/ts-config/node",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["**/*.test.tsx", "**/*.test.ts"]
9
+ }