@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,193 @@
1
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import {closeApp, createApp} from '@shipfox/node-fastify';
3
+ import {createLinearE2eRoutes} from './index.js';
4
+
5
+ function connection(
6
+ overrides: Partial<IntegrationConnection<'linear'>> = {},
7
+ ): IntegrationConnection<'linear'> {
8
+ return {
9
+ id: '00000000-0000-4000-8000-000000000001',
10
+ workspaceId: '00000000-0000-4000-8000-000000000002',
11
+ provider: 'linear',
12
+ externalAccountId: 'linear-org',
13
+ slug: 'linear_acme',
14
+ displayName: 'Linear Acme',
15
+ lifecycleStatus: 'active',
16
+ createdAt: new Date(),
17
+ updatedAt: new Date(),
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe('Linear E2E routes', () => {
23
+ afterEach(async () => {
24
+ await closeApp();
25
+ });
26
+
27
+ it('creates a connection and stores its token without returning it', async () => {
28
+ const tokenStore = {storeTokens: vi.fn(() => Promise.resolve())};
29
+ const app = await createApp({
30
+ routes: [
31
+ createLinearE2eRoutes({
32
+ tokenStore,
33
+ getExistingLinearConnection: vi.fn(() => Promise.resolve(undefined)),
34
+ connectLinearInstallation: vi.fn(() => Promise.resolve(connection())),
35
+ disconnectLinearInstallation: vi.fn(() => Promise.resolve()),
36
+ connectionCapabilities: ['agent_tools'],
37
+ }),
38
+ ],
39
+ swagger: false,
40
+ });
41
+
42
+ const res = await app.inject({
43
+ method: 'POST',
44
+ url: '/integrations/linear-connections',
45
+ payload: {
46
+ workspace_id: '00000000-0000-4000-8000-000000000002',
47
+ organization_id: 'linear-org',
48
+ organization_url_key: 'acme',
49
+ app_user_id: 'linear-app-user',
50
+ display_name: 'Linear Acme',
51
+ access_token: 'linear-e2e-token',
52
+ },
53
+ });
54
+
55
+ expect(res.statusCode).toBe(201);
56
+ expect(res.json()).toMatchObject({
57
+ id: '00000000-0000-4000-8000-000000000001',
58
+ provider: 'linear',
59
+ capabilities: ['agent_tools'],
60
+ });
61
+ expect(res.body).not.toContain('linear-e2e-token');
62
+ expect(tokenStore.storeTokens).toHaveBeenCalledWith({
63
+ connectionId: '00000000-0000-4000-8000-000000000001',
64
+ accessToken: 'linear-e2e-token',
65
+ });
66
+ });
67
+
68
+ it('rejects malformed connection bodies', async () => {
69
+ const app = await createApp({
70
+ routes: [
71
+ createLinearE2eRoutes({
72
+ tokenStore: {storeTokens: vi.fn(() => Promise.resolve())},
73
+ getExistingLinearConnection: vi.fn(() => Promise.resolve(undefined)),
74
+ connectLinearInstallation: vi.fn(() => Promise.resolve(connection())),
75
+ disconnectLinearInstallation: vi.fn(() => Promise.resolve()),
76
+ connectionCapabilities: ['agent_tools'],
77
+ }),
78
+ ],
79
+ swagger: false,
80
+ });
81
+
82
+ const res = await app.inject({
83
+ method: 'POST',
84
+ url: '/integrations/linear-connections',
85
+ payload: {workspace_id: 'not-a-uuid'},
86
+ });
87
+
88
+ expect(res.statusCode).toBe(400);
89
+ expect(res.json()).toMatchObject({code: 'validation-error'});
90
+ });
91
+
92
+ it('preserves the token storage error when cleanup of a new connection fails', async () => {
93
+ const disconnectLinearInstallation = vi.fn(() => Promise.reject(new Error('cleanup failed')));
94
+ const app = await createApp({
95
+ routes: [
96
+ createLinearE2eRoutes({
97
+ tokenStore: {storeTokens: vi.fn(() => Promise.reject(new Error('token storage failed')))},
98
+ getExistingLinearConnection: vi.fn(() => Promise.resolve(undefined)),
99
+ connectLinearInstallation: vi.fn(() => Promise.resolve(connection())),
100
+ disconnectLinearInstallation,
101
+ connectionCapabilities: ['agent_tools'],
102
+ }),
103
+ ],
104
+ swagger: false,
105
+ });
106
+
107
+ const res = await app.inject({
108
+ method: 'POST',
109
+ url: '/integrations/linear-connections',
110
+ payload: {
111
+ workspace_id: '00000000-0000-4000-8000-000000000002',
112
+ organization_id: 'linear-org',
113
+ organization_url_key: 'acme',
114
+ app_user_id: 'linear-app-user',
115
+ display_name: 'Linear Acme',
116
+ access_token: 'linear-e2e-token',
117
+ },
118
+ });
119
+
120
+ expect(res.statusCode).toBe(500);
121
+ expect(disconnectLinearInstallation).toHaveBeenCalledWith({
122
+ connectionId: '00000000-0000-4000-8000-000000000001',
123
+ });
124
+ });
125
+
126
+ it('leaves an existing connection unchanged when token storage fails', async () => {
127
+ const connectLinearInstallation = vi.fn(() => Promise.resolve(connection()));
128
+ const disconnectLinearInstallation = vi.fn(() => Promise.resolve());
129
+ const app = await createApp({
130
+ routes: [
131
+ createLinearE2eRoutes({
132
+ tokenStore: {storeTokens: vi.fn(() => Promise.reject(new Error('token storage failed')))},
133
+ getExistingLinearConnection: vi.fn(() => Promise.resolve(connection())),
134
+ connectLinearInstallation,
135
+ disconnectLinearInstallation,
136
+ connectionCapabilities: ['agent_tools'],
137
+ }),
138
+ ],
139
+ swagger: false,
140
+ });
141
+
142
+ const res = await app.inject({
143
+ method: 'POST',
144
+ url: '/integrations/linear-connections',
145
+ payload: {
146
+ workspace_id: '00000000-0000-4000-8000-000000000002',
147
+ organization_id: 'linear-org',
148
+ organization_url_key: 'changed-acme',
149
+ app_user_id: 'changed-linear-app-user',
150
+ display_name: 'Changed Linear Acme',
151
+ access_token: 'linear-e2e-token',
152
+ },
153
+ });
154
+
155
+ expect(res.statusCode).toBe(500);
156
+ expect(connectLinearInstallation).not.toHaveBeenCalled();
157
+ expect(disconnectLinearInstallation).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('rejects a Linear organization already connected to another workspace', async () => {
161
+ const existing = connection({workspaceId: '00000000-0000-4000-8000-000000000003'});
162
+ const connectLinearInstallation = vi.fn(() => Promise.resolve(connection()));
163
+ const app = await createApp({
164
+ routes: [
165
+ createLinearE2eRoutes({
166
+ tokenStore: {storeTokens: vi.fn(() => Promise.resolve())},
167
+ getExistingLinearConnection: vi.fn(() => Promise.resolve(existing)),
168
+ connectLinearInstallation,
169
+ disconnectLinearInstallation: vi.fn(() => Promise.resolve()),
170
+ connectionCapabilities: ['agent_tools'],
171
+ }),
172
+ ],
173
+ swagger: false,
174
+ });
175
+
176
+ const res = await app.inject({
177
+ method: 'POST',
178
+ url: '/integrations/linear-connections',
179
+ payload: {
180
+ workspace_id: '00000000-0000-4000-8000-000000000002',
181
+ organization_id: 'linear-org',
182
+ organization_url_key: 'acme',
183
+ app_user_id: 'linear-app-user',
184
+ display_name: 'Linear Acme',
185
+ access_token: 'linear-e2e-token',
186
+ },
187
+ });
188
+
189
+ expect(res.statusCode).toBe(409);
190
+ expect(res.json()).toMatchObject({code: 'linear-connection-workspace-mismatch'});
191
+ expect(connectLinearInstallation).not.toHaveBeenCalled();
192
+ });
193
+ });
@@ -0,0 +1,14 @@
1
+ import type {RouteGroup} from '@shipfox/node-fastify';
2
+ import {
3
+ type CreateE2eLinearConnectionRouteOptions,
4
+ createE2eLinearConnectionRoute,
5
+ } from './create-connection.js';
6
+
7
+ export type CreateLinearE2eRoutesOptions = CreateE2eLinearConnectionRouteOptions;
8
+
9
+ export function createLinearE2eRoutes(options: CreateLinearE2eRoutesOptions): RouteGroup {
10
+ return {
11
+ prefix: '/integrations',
12
+ routes: [createE2eLinearConnectionRoute(options)],
13
+ };
14
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ ConnectionSlugConflictError,
3
+ type IntegrationProviderErrorReason,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import {ClientError} from '@shipfox/node-fastify';
6
+ import {
7
+ LinearAuthorizationScopeMismatchError,
8
+ LinearConnectionAlreadyLinkedError,
9
+ LinearInstallationAlreadyLinkedError,
10
+ LinearInstallStateActorMismatchError,
11
+ LinearInstallStateError,
12
+ LinearIntegrationProviderError,
13
+ LinearOAuthCallbackError,
14
+ } from '#core/errors.js';
15
+
16
+ function providerStatus(reason: IntegrationProviderErrorReason): number {
17
+ if (reason === 'rate-limited') return 429;
18
+ if (reason === 'timeout' || reason === 'provider-unavailable') return 503;
19
+ return 422;
20
+ }
21
+
22
+ export function linearRouteErrorHandler(error: unknown): never {
23
+ if (error instanceof LinearInstallStateError) {
24
+ throw new ClientError(error.message, 'invalid-linear-install-state', {status: 400});
25
+ }
26
+ if (error instanceof LinearInstallStateActorMismatchError) {
27
+ throw new ClientError(error.message, 'linear-install-state-actor-mismatch', {status: 403});
28
+ }
29
+ if (error instanceof LinearInstallationAlreadyLinkedError) {
30
+ throw new ClientError(error.message, 'linear-installation-already-linked', {status: 409});
31
+ }
32
+ if (error instanceof LinearConnectionAlreadyLinkedError) {
33
+ throw new ClientError(error.message, 'linear-connection-already-linked', {status: 409});
34
+ }
35
+ if (error instanceof LinearAuthorizationScopeMismatchError) {
36
+ throw new ClientError(error.message, 'linear-authorization-scope-mismatch', {
37
+ status: 422,
38
+ details: {missing_scopes: error.missingScopes},
39
+ });
40
+ }
41
+ if (error instanceof LinearOAuthCallbackError) {
42
+ throw new ClientError(error.message, 'linear-oauth-callback-error', {
43
+ status: 422,
44
+ details: {
45
+ error: error.providerError,
46
+ ...(error.providerDescription ? {error_description: error.providerDescription} : {}),
47
+ },
48
+ });
49
+ }
50
+ if (error instanceof ConnectionSlugConflictError) {
51
+ throw new ClientError(error.message, 'slug-conflict', {status: 409});
52
+ }
53
+ if (error instanceof LinearIntegrationProviderError) {
54
+ throw new ClientError(error.message, error.reason, {
55
+ details: {retry_after_seconds: error.retryAfterSeconds},
56
+ status: providerStatus(error.reason),
57
+ });
58
+ }
59
+ throw error;
60
+ }