@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,460 @@
1
+ import {
2
+ AUTH_USER,
3
+ buildUserContext,
4
+ setUserContext,
5
+ type UserContextMembership,
6
+ } from '@shipfox/api-auth-context';
7
+ import {
8
+ ConnectionSlugConflictError,
9
+ type IntegrationConnection,
10
+ } from '@shipfox/api-integration-core-dto';
11
+ import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
12
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
13
+ import type {LinearApiClient} from '#api/client.js';
14
+ import type {ConnectLinearInstallationInput} from '#core/install.js';
15
+ import {verifyLinearInstallState} from '#core/state.js';
16
+ import type {LinearTokenStore} from '#core/tokens.js';
17
+ import {createLinearIntegrationProvider} from '#index.js';
18
+
19
+ vi.mock('@shipfox/api-workspaces', () => ({
20
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
21
+ }));
22
+
23
+ const {requireWorkspaceMembership} = await import('@shipfox/api-workspaces');
24
+ const requireWorkspaceMembershipMock = vi.mocked(requireWorkspaceMembership);
25
+ let authenticatedMemberships: UserContextMembership[] = [];
26
+
27
+ const fakeUserAuth: AuthMethod = {
28
+ name: AUTH_USER,
29
+ authenticate: (request: FastifyRequest) => {
30
+ if (request.headers.authorization !== 'Bearer user') {
31
+ throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
32
+ }
33
+
34
+ setUserContext(
35
+ request,
36
+ buildUserContext({
37
+ userId: 'user-1',
38
+ email: 'user@example.com',
39
+ memberships: authenticatedMemberships,
40
+ }),
41
+ );
42
+ return Promise.resolve();
43
+ },
44
+ };
45
+
46
+ function linearClient(overrides: Partial<LinearApiClient> = {}): LinearApiClient {
47
+ return {
48
+ exchangeAuthorizationCode: vi.fn(() =>
49
+ Promise.resolve({
50
+ accessToken: 'linear-access-token',
51
+ refreshToken: 'linear-refresh-token',
52
+ expiresAt: new Date('2026-07-07T13:00:00.000Z'),
53
+ scopes: ['read', 'write', 'app:assignable', 'app:mentionable'],
54
+ }),
55
+ ),
56
+ refreshAccessToken: vi.fn(() => {
57
+ throw new Error('not used');
58
+ }),
59
+ revokeToken: vi.fn(() => Promise.resolve()),
60
+ getIdentity: vi.fn(() =>
61
+ Promise.resolve({
62
+ appUserId: 'app-user-id',
63
+ organizationId: 'org-id',
64
+ organizationName: 'Acme',
65
+ organizationUrlKey: 'acme',
66
+ }),
67
+ ),
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ function connection(input: Partial<IntegrationConnection<'linear'>> = {}) {
73
+ return {
74
+ id: crypto.randomUUID(),
75
+ workspaceId: crypto.randomUUID(),
76
+ provider: 'linear',
77
+ externalAccountId: 'org-id',
78
+ slug: 'linear_acme',
79
+ displayName: 'Linear Acme',
80
+ lifecycleStatus: 'active',
81
+ createdAt: new Date(),
82
+ updatedAt: new Date(),
83
+ ...input,
84
+ } satisfies IntegrationConnection<'linear'>;
85
+ }
86
+
87
+ interface CreateTestAppOptions {
88
+ linear?: LinearApiClient;
89
+ tokenStore?: Pick<LinearTokenStore, 'storeTokens'> | undefined;
90
+ agentTools?: boolean | undefined;
91
+ existingConnection?: IntegrationConnection<'linear'> | undefined;
92
+ connectLinearInstallation?:
93
+ | ((input: ConnectLinearInstallationInput) => Promise<IntegrationConnection<'linear'>>)
94
+ | undefined;
95
+ disconnectLinearInstallation?: ((input: {connectionId: string}) => Promise<void>) | undefined;
96
+ }
97
+
98
+ async function createTestApp(options: CreateTestAppOptions = {}): Promise<FastifyInstance> {
99
+ const agentTools = options.agentTools ?? true;
100
+ const provider = createLinearIntegrationProvider({
101
+ linear: options.linear ?? linearClient(),
102
+ ...(agentTools
103
+ ? {agentTools: {tokenStore: {getAccessToken: vi.fn(() => Promise.resolve('linear-token'))}}}
104
+ : {}),
105
+ routes: {
106
+ tokenStore: options.tokenStore ?? {
107
+ storeTokens: vi.fn(() => Promise.resolve()),
108
+ },
109
+ getExistingLinearConnection: vi.fn(() => Promise.resolve(options.existingConnection)),
110
+ connectLinearInstallation:
111
+ options.connectLinearInstallation ??
112
+ vi.fn((input: ConnectLinearInstallationInput) =>
113
+ Promise.resolve(
114
+ connection({
115
+ workspaceId: input.workspaceId,
116
+ externalAccountId: input.organizationId,
117
+ displayName: input.displayName,
118
+ }),
119
+ ),
120
+ ),
121
+ disconnectLinearInstallation:
122
+ options.disconnectLinearInstallation ?? vi.fn(() => Promise.resolve()),
123
+ },
124
+ });
125
+ const app = await createApp({
126
+ auth: [fakeUserAuth],
127
+ routes: provider.routes,
128
+ swagger: false,
129
+ });
130
+ await app.ready();
131
+ return app;
132
+ }
133
+
134
+ describe('Linear integration routes', () => {
135
+ beforeEach(async () => {
136
+ authenticatedMemberships = [];
137
+ requireWorkspaceMembershipMock.mockClear();
138
+ await closeApp();
139
+ });
140
+
141
+ afterEach(async () => {
142
+ await closeApp();
143
+ });
144
+
145
+ it('returns an actor=app OAuth URL with signed workspace state', async () => {
146
+ const app = await createTestApp();
147
+ const workspaceId = crypto.randomUUID();
148
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
149
+
150
+ const res = await app.inject({
151
+ method: 'POST',
152
+ url: '/integrations/linear/install',
153
+ headers: {authorization: 'Bearer user'},
154
+ payload: {workspace_id: workspaceId},
155
+ });
156
+
157
+ const installUrl = new URL(res.json().install_url);
158
+ const state = installUrl.searchParams.get('state');
159
+ const claims = verifyLinearInstallState(state ?? '');
160
+ expect(res.statusCode).toBe(200);
161
+ expect(installUrl.origin + installUrl.pathname).toBe('https://linear.app/oauth/authorize');
162
+ expect(installUrl.searchParams.get('client_id')).toBe('test-client-id');
163
+ expect(installUrl.searchParams.get('redirect_uri')).toBe(
164
+ 'https://shipfox.example.com/integrations/linear/callback',
165
+ );
166
+ expect(installUrl.searchParams.get('response_type')).toBe('code');
167
+ expect(installUrl.searchParams.get('actor')).toBe('app');
168
+ expect(installUrl.searchParams.get('scope')).toBe('read,write,app:assignable,app:mentionable');
169
+ expect(claims.workspaceId).toBe(workspaceId);
170
+ expect(claims.userId).toBe('user-1');
171
+ });
172
+
173
+ it('handles a verified Linear callback and stores tokens for the connection', async () => {
174
+ const tokenStore = {storeTokens: vi.fn(() => Promise.resolve())};
175
+ const connectLinearInstallation = vi.fn((input: ConnectLinearInstallationInput) =>
176
+ Promise.resolve(
177
+ connection({
178
+ id: '00000000-0000-4000-8000-000000000001',
179
+ workspaceId: input.workspaceId,
180
+ externalAccountId: input.organizationId,
181
+ displayName: input.displayName,
182
+ }),
183
+ ),
184
+ );
185
+ const app = await createTestApp({tokenStore, connectLinearInstallation});
186
+ const workspaceId = crypto.randomUUID();
187
+ const state = await createInstallState(app, workspaceId);
188
+
189
+ const res = await app.inject({
190
+ method: 'GET',
191
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
192
+ headers: {authorization: 'Bearer user'},
193
+ });
194
+
195
+ expect(res.statusCode).toBe(200);
196
+ expect(res.json()).toMatchObject({
197
+ id: '00000000-0000-4000-8000-000000000001',
198
+ workspace_id: workspaceId,
199
+ provider: 'linear',
200
+ external_account_id: 'org-id',
201
+ display_name: 'Linear Acme',
202
+ lifecycle_status: 'active',
203
+ capabilities: ['agent_tools'],
204
+ });
205
+ expect(connectLinearInstallation).toHaveBeenCalledWith({
206
+ workspaceId,
207
+ organizationId: 'org-id',
208
+ organizationUrlKey: 'acme',
209
+ appUserId: 'app-user-id',
210
+ scopes: ['read', 'write', 'app:assignable', 'app:mentionable'],
211
+ tokenExpiresAt: new Date('2026-07-07T13:00:00.000Z'),
212
+ displayName: 'Linear Acme',
213
+ });
214
+ expect(tokenStore.storeTokens).toHaveBeenCalledWith({
215
+ connectionId: '00000000-0000-4000-8000-000000000001',
216
+ accessToken: 'linear-access-token',
217
+ refreshToken: 'linear-refresh-token',
218
+ editedBy: 'user-1',
219
+ });
220
+ expect(requireWorkspaceMembershipMock).toHaveBeenCalledWith({
221
+ workspaceId,
222
+ userId: 'user-1',
223
+ memberships: [{workspaceId, role: 'admin'}],
224
+ });
225
+ });
226
+
227
+ it('omits agent tools capability from callback connections when the adapter is not wired', async () => {
228
+ const app = await createTestApp({agentTools: false});
229
+ const workspaceId = crypto.randomUUID();
230
+ const state = await createInstallState(app, workspaceId);
231
+
232
+ const res = await app.inject({
233
+ method: 'GET',
234
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
235
+ headers: {authorization: 'Bearer user'},
236
+ });
237
+
238
+ expect(res.statusCode).toBe(200);
239
+ expect(res.json()).toMatchObject({
240
+ provider: 'linear',
241
+ capabilities: [],
242
+ });
243
+ });
244
+
245
+ it('rejects tampered callback state', async () => {
246
+ const app = await createTestApp();
247
+ const workspaceId = crypto.randomUUID();
248
+ const state = await createInstallState(app, workspaceId);
249
+
250
+ const res = await app.inject({
251
+ method: 'GET',
252
+ url: `/integrations/linear/callback/api?code=code&state=${state}x`,
253
+ headers: {authorization: 'Bearer user'},
254
+ });
255
+
256
+ expect(res.statusCode).toBe(400);
257
+ expect(res.json().code).toBe('invalid-linear-install-state');
258
+ });
259
+
260
+ it('handles Linear OAuth error callbacks after validating state', async () => {
261
+ const app = await createTestApp();
262
+ const workspaceId = crypto.randomUUID();
263
+ const state = await createInstallState(app, workspaceId);
264
+
265
+ const res = await app.inject({
266
+ method: 'GET',
267
+ url: `/integrations/linear/callback/api?error=access_denied&error_description=Denied&state=${state}`,
268
+ headers: {authorization: 'Bearer user'},
269
+ });
270
+
271
+ expect(res.statusCode).toBe(422);
272
+ expect(res.json()).toMatchObject({
273
+ code: 'linear-oauth-callback-error',
274
+ details: {error: 'access_denied', error_description: 'Denied'},
275
+ });
276
+ expect(requireWorkspaceMembershipMock).toHaveBeenCalledWith({
277
+ workspaceId,
278
+ userId: 'user-1',
279
+ memberships: [{workspaceId, role: 'admin'}],
280
+ });
281
+ });
282
+
283
+ it('rejects callbacks for a Linear organization linked to another workspace and revokes tokens', async () => {
284
+ const revokeToken = vi.fn(() => Promise.resolve());
285
+ const app = await createTestApp({
286
+ linear: linearClient({revokeToken}),
287
+ existingConnection: connection({workspaceId: crypto.randomUUID()}),
288
+ });
289
+ const workspaceId = crypto.randomUUID();
290
+ const state = await createInstallState(app, workspaceId);
291
+
292
+ const res = await app.inject({
293
+ method: 'GET',
294
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
295
+ headers: {authorization: 'Bearer user'},
296
+ });
297
+
298
+ expect(res.statusCode).toBe(409);
299
+ expect(res.json().code).toBe('linear-installation-already-linked');
300
+ expect(revokeToken).toHaveBeenCalledWith({
301
+ token: 'linear-refresh-token',
302
+ tokenTypeHint: 'refresh_token',
303
+ });
304
+ expect(revokeToken).toHaveBeenCalledWith({
305
+ token: 'linear-access-token',
306
+ tokenTypeHint: 'access_token',
307
+ });
308
+ });
309
+
310
+ it('revokes tokens when Linear identity lookup fails after authorization', async () => {
311
+ const revokeToken = vi.fn(() => Promise.resolve());
312
+ const app = await createTestApp({
313
+ linear: linearClient({
314
+ revokeToken,
315
+ getIdentity: vi.fn(() => Promise.reject(new Error('Linear unavailable'))),
316
+ }),
317
+ });
318
+ const state = await createInstallState(app, crypto.randomUUID());
319
+
320
+ const res = await app.inject({
321
+ method: 'GET',
322
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
323
+ headers: {authorization: 'Bearer user'},
324
+ });
325
+
326
+ expect(res.statusCode).toBe(500);
327
+ expect(revokeToken).toHaveBeenCalledWith({
328
+ token: 'linear-refresh-token',
329
+ tokenTypeHint: 'refresh_token',
330
+ });
331
+ expect(revokeToken).toHaveBeenCalledWith({
332
+ token: 'linear-access-token',
333
+ tokenTypeHint: 'access_token',
334
+ });
335
+ });
336
+
337
+ it('rejects callbacks when Linear omits required scopes and revokes tokens', async () => {
338
+ const revokeToken = vi.fn(() => Promise.resolve());
339
+ const app = await createTestApp({
340
+ linear: linearClient({
341
+ revokeToken,
342
+ exchangeAuthorizationCode: vi.fn(() =>
343
+ Promise.resolve({
344
+ accessToken: 'linear-access-token',
345
+ refreshToken: 'linear-refresh-token',
346
+ expiresAt: new Date('2026-07-07T13:00:00.000Z'),
347
+ scopes: ['read'],
348
+ }),
349
+ ),
350
+ }),
351
+ });
352
+ const state = await createInstallState(app, crypto.randomUUID());
353
+
354
+ const res = await app.inject({
355
+ method: 'GET',
356
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
357
+ headers: {authorization: 'Bearer user'},
358
+ });
359
+
360
+ expect(res.statusCode).toBe(422);
361
+ expect(res.json()).toMatchObject({
362
+ code: 'linear-authorization-scope-mismatch',
363
+ details: {missing_scopes: ['write', 'app:assignable', 'app:mentionable']},
364
+ });
365
+ expect(revokeToken).toHaveBeenCalledTimes(2);
366
+ });
367
+
368
+ it('compensates a new connection when token storage fails', async () => {
369
+ const connectionId = '00000000-0000-4000-8000-000000000003';
370
+ const disconnectLinearInstallation = vi.fn(() => Promise.resolve());
371
+ const revokeToken = vi.fn(() => Promise.resolve());
372
+ const app = await createTestApp({
373
+ linear: linearClient({revokeToken}),
374
+ tokenStore: {storeTokens: vi.fn(() => Promise.reject(new Error('secret store down')))},
375
+ connectLinearInstallation: vi.fn((input: ConnectLinearInstallationInput) =>
376
+ Promise.resolve(
377
+ connection({
378
+ id: connectionId,
379
+ workspaceId: input.workspaceId,
380
+ externalAccountId: input.organizationId,
381
+ displayName: input.displayName,
382
+ }),
383
+ ),
384
+ ),
385
+ disconnectLinearInstallation,
386
+ });
387
+ const state = await createInstallState(app, crypto.randomUUID());
388
+
389
+ const res = await app.inject({
390
+ method: 'GET',
391
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
392
+ headers: {authorization: 'Bearer user'},
393
+ });
394
+
395
+ expect(res.statusCode).toBe(500);
396
+ expect(revokeToken).toHaveBeenCalledTimes(2);
397
+ expect(disconnectLinearInstallation).toHaveBeenCalledWith({connectionId});
398
+ });
399
+
400
+ it('reconnects an existing workspace connection and refreshes stored tokens', async () => {
401
+ const workspaceId = crypto.randomUUID();
402
+ const existingConnection = connection({
403
+ id: '00000000-0000-4000-8000-000000000002',
404
+ workspaceId,
405
+ });
406
+ const tokenStore = {storeTokens: vi.fn(() => Promise.resolve())};
407
+ const connectLinearInstallation = vi.fn(() => Promise.resolve(existingConnection));
408
+ const app = await createTestApp({
409
+ tokenStore,
410
+ existingConnection,
411
+ connectLinearInstallation,
412
+ });
413
+ const state = await createInstallState(app, workspaceId);
414
+
415
+ const res = await app.inject({
416
+ method: 'GET',
417
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
418
+ headers: {authorization: 'Bearer user'},
419
+ });
420
+
421
+ expect(res.statusCode).toBe(200);
422
+ expect(res.json().id).toBe(existingConnection.id);
423
+ expect(connectLinearInstallation).toHaveBeenCalledTimes(1);
424
+ expect(tokenStore.storeTokens).toHaveBeenCalledWith(
425
+ expect.objectContaining({connectionId: existingConnection.id}),
426
+ );
427
+ });
428
+
429
+ it('returns 409 when connection slug allocation conflicts repeatedly', async () => {
430
+ const app = await createTestApp({
431
+ connectLinearInstallation: vi.fn(() =>
432
+ Promise.reject(new ConnectionSlugConflictError(new Error('duplicate slug'))),
433
+ ),
434
+ });
435
+ const state = await createInstallState(app, crypto.randomUUID());
436
+
437
+ const res = await app.inject({
438
+ method: 'GET',
439
+ url: `/integrations/linear/callback/api?code=code&state=${state}`,
440
+ headers: {authorization: 'Bearer user'},
441
+ });
442
+
443
+ expect(res.statusCode).toBe(409);
444
+ expect(res.json().code).toBe('slug-conflict');
445
+ });
446
+ });
447
+
448
+ async function createInstallState(app: FastifyInstance, workspaceId: string): Promise<string> {
449
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
450
+ const res = await app.inject({
451
+ method: 'POST',
452
+ url: '/integrations/linear/install',
453
+ headers: {authorization: 'Bearer user'},
454
+ payload: {workspace_id: workspaceId},
455
+ });
456
+ const installUrl = new URL(res.json().install_url);
457
+ const state = installUrl.searchParams.get('state');
458
+ if (!state) throw new Error('Install URL did not include state');
459
+ return encodeURIComponent(state);
460
+ }
@@ -0,0 +1,132 @@
1
+ import {AUTH_USER, requireUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {
4
+ createLinearInstallBodySchema,
5
+ createLinearInstallResponseSchema,
6
+ type LinearCallbackQueryDto,
7
+ linearCallbackQuerySchema,
8
+ linearCallbackResponseSchema,
9
+ } from '@shipfox/api-integration-linear-dto';
10
+ import {requireWorkspaceMembership} from '@shipfox/api-workspaces';
11
+ import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
12
+ import type {LinearApiClient} from '#api/client.js';
13
+ import {config} from '#config.js';
14
+ import {
15
+ type ConnectLinearInstallationInput,
16
+ handleLinearCallback,
17
+ handleLinearOAuthCallbackError,
18
+ } from '#core/install.js';
19
+ import {formatLinearOAuthScopes} from '#core/scopes.js';
20
+ import {signLinearInstallState} from '#core/state.js';
21
+ import type {LinearTokenStore} from '#core/tokens.js';
22
+ import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
23
+ import {linearRouteErrorHandler} from './errors.js';
24
+
25
+ export interface CreateLinearIntegrationRoutesOptions {
26
+ linear: LinearApiClient;
27
+ tokenStore: Pick<LinearTokenStore, 'storeTokens'>;
28
+ getExistingLinearConnection: (input: {
29
+ organizationId: string;
30
+ }) => Promise<IntegrationConnection<'linear'> | undefined>;
31
+ connectLinearInstallation: (
32
+ input: ConnectLinearInstallationInput,
33
+ ) => Promise<IntegrationConnection<'linear'>>;
34
+ disconnectLinearInstallation: (input: {connectionId: string}) => Promise<void>;
35
+ connectionCapabilities: IntegrationCapability[];
36
+ }
37
+
38
+ export function createLinearIntegrationRoutes({
39
+ linear,
40
+ tokenStore,
41
+ getExistingLinearConnection,
42
+ connectLinearInstallation,
43
+ disconnectLinearInstallation,
44
+ connectionCapabilities,
45
+ }: CreateLinearIntegrationRoutesOptions): RouteGroup {
46
+ const createInstallRoute = defineRoute({
47
+ method: 'POST',
48
+ path: '/install',
49
+ auth: AUTH_USER,
50
+ description: 'Create a Linear OAuth authorization URL for a workspace.',
51
+ schema: {
52
+ body: createLinearInstallBodySchema,
53
+ response: {
54
+ 200: createLinearInstallResponseSchema,
55
+ },
56
+ },
57
+ handler: (request) => {
58
+ const {workspace_id: workspaceId} = request.body;
59
+ const actor = requireUserContext(request);
60
+
61
+ requireWorkspaceAccess({request, workspaceId});
62
+
63
+ const state = signLinearInstallState({workspaceId, userId: actor.userId});
64
+ const installUrl = new URL('https://linear.app/oauth/authorize');
65
+ installUrl.searchParams.set('client_id', config.LINEAR_OAUTH_CLIENT_ID);
66
+ installUrl.searchParams.set('redirect_uri', config.LINEAR_OAUTH_REDIRECT_URL);
67
+ installUrl.searchParams.set('response_type', 'code');
68
+ installUrl.searchParams.set('state', state);
69
+ installUrl.searchParams.set('actor', 'app');
70
+ installUrl.searchParams.set('scope', formatLinearOAuthScopes());
71
+
72
+ return {install_url: installUrl.toString()};
73
+ },
74
+ });
75
+
76
+ const callbackApiRoute = defineRoute({
77
+ method: 'GET',
78
+ path: '/callback/api',
79
+ auth: AUTH_USER,
80
+ description: 'Handle the Linear OAuth callback.',
81
+ schema: {
82
+ querystring: linearCallbackQuerySchema,
83
+ response: {
84
+ 200: linearCallbackResponseSchema,
85
+ },
86
+ },
87
+ errorHandler: linearRouteErrorHandler,
88
+ handler: async (request) => {
89
+ const actor = requireUserContext(request);
90
+ const query = request.query;
91
+ if (isLinearOAuthErrorCallback(query)) {
92
+ return await handleLinearOAuthCallbackError({
93
+ state: query.state,
94
+ error: query.error,
95
+ errorDescription: query.error_description,
96
+ sessionUserId: actor.userId,
97
+ sessionMemberships: actor.memberships,
98
+ requireWorkspaceMembership,
99
+ });
100
+ }
101
+ const connection = await handleLinearCallback({
102
+ linear,
103
+ tokenStore,
104
+ code: query.code,
105
+ state: query.state,
106
+ sessionUserId: actor.userId,
107
+ sessionMemberships: actor.memberships,
108
+ requireWorkspaceMembership,
109
+ getExistingLinearConnection,
110
+ connectLinearInstallation,
111
+ disconnectLinearInstallation,
112
+ });
113
+
114
+ return toIntegrationConnectionDto(connection, {capabilities: connectionCapabilities});
115
+ },
116
+ });
117
+
118
+ return {
119
+ prefix: '/integrations/linear',
120
+ routes: [createInstallRoute, callbackApiRoute],
121
+ };
122
+ }
123
+
124
+ function isLinearOAuthErrorCallback(
125
+ query: LinearCallbackQueryDto,
126
+ ): query is LinearCallbackQueryDto & {
127
+ error: string;
128
+ error_description?: string | undefined;
129
+ state: string;
130
+ } {
131
+ return 'error' in query && typeof query.error === 'string';
132
+ }