@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.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +89 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/api/client.d.ts +29 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +189 -0
- package/dist/api/client.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +22 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-tools-provider.d.ts +32 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +63 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +10 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +920 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/disconnect.d.ts +18 -0
- package/dist/core/disconnect.d.ts.map +1 -0
- package/dist/core/disconnect.js +23 -0
- package/dist/core/disconnect.js.map +1 -0
- package/dist/core/errors.d.ts +35 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +59 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +47 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +102 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/scopes.d.ts +4 -0
- package/dist/core/scopes.d.ts.map +1 -0
- package/dist/core/scopes.js +17 -0
- package/dist/core/scopes.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +54 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/tokens.d.ts +39 -0
- package/dist/core/tokens.d.ts.map +1 -0
- package/dist/core/tokens.js +117 -0
- package/dist/core/tokens.js.map +1 -0
- package/dist/core/webhook.d.ts +16 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +105 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +369 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installations.d.ts +53 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +125 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +186 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +40 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +17 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +6 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.js +64 -0
- package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +5 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +65 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +19 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +100 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +11 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +136 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +126 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +72 -0
- package/src/api/client.test.ts +366 -0
- package/src/api/client.ts +281 -0
- package/src/config.test.ts +34 -0
- package/src/config.ts +22 -0
- package/src/connection-external-url.test.ts +64 -0
- package/src/core/agent-tools-provider.test.ts +245 -0
- package/src/core/agent-tools-provider.ts +106 -0
- package/src/core/agent-tools.ts +918 -0
- package/src/core/disconnect.test.ts +45 -0
- package/src/core/disconnect.ts +27 -0
- package/src/core/errors.ts +71 -0
- package/src/core/install.ts +162 -0
- package/src/core/scopes.ts +18 -0
- package/src/core/state.test.ts +51 -0
- package/src/core/state.ts +85 -0
- package/src/core/tokens.test.ts +281 -0
- package/src/core/tokens.ts +219 -0
- package/src/core/webhook.test.ts +318 -0
- package/src/core/webhook.ts +146 -0
- package/src/db/db.ts +18 -0
- package/src/db/installations.test.ts +182 -0
- package/src/db/installations.ts +215 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +42 -0
- package/src/index.test.ts +20 -0
- package/src/index.ts +153 -0
- package/src/presentation/dto/integrations.ts +9 -0
- package/src/presentation/e2eRoutes/create-connection.ts +85 -0
- package/src/presentation/e2eRoutes/index.test.ts +193 -0
- package/src/presentation/e2eRoutes/index.ts +14 -0
- package/src/presentation/routes/errors.ts +60 -0
- package/src/presentation/routes/install.test.ts +460 -0
- package/src/presentation/routes/install.ts +132 -0
- package/src/presentation/routes/webhooks.test.ts +407 -0
- package/src/presentation/routes/webhooks.ts +154 -0
- package/test/api-secrets.d.ts +28 -0
- package/test/env.ts +13 -0
- package/test/factories/index.ts +1 -0
- package/test/globalSetup.ts +23 -0
- package/test/index.ts +1 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import {getLinearInstallationByConnectionId, upsertLinearInstallation} from '#db/installations.js';
|
|
2
|
+
import {
|
|
3
|
+
LinearAccessTokenMissingError,
|
|
4
|
+
LinearConnectionNotFoundError,
|
|
5
|
+
LinearTokenUnrefreshableError,
|
|
6
|
+
} from './errors.js';
|
|
7
|
+
import {
|
|
8
|
+
createLinearTokenStore,
|
|
9
|
+
type LinearConnectionResolverResult,
|
|
10
|
+
type LinearSecretsStore,
|
|
11
|
+
linearSecretsNamespace,
|
|
12
|
+
} from './tokens.js';
|
|
13
|
+
|
|
14
|
+
let secrets: LinearSecretsStore;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
secrets = await import('@shipfox/api-secrets');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function createConnectionContext() {
|
|
21
|
+
const workspaceId = crypto.randomUUID();
|
|
22
|
+
const connectionId = crypto.randomUUID();
|
|
23
|
+
const resolveConnection = vi
|
|
24
|
+
.fn<(connectionId: string) => Promise<LinearConnectionResolverResult | undefined>>()
|
|
25
|
+
.mockResolvedValue({workspaceId});
|
|
26
|
+
const refreshAccessToken = vi.fn();
|
|
27
|
+
const store = createLinearTokenStore({
|
|
28
|
+
resolveConnection,
|
|
29
|
+
secrets,
|
|
30
|
+
client: {
|
|
31
|
+
exchangeAuthorizationCode: vi.fn(),
|
|
32
|
+
getIdentity: vi.fn(),
|
|
33
|
+
refreshAccessToken,
|
|
34
|
+
revokeToken: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {workspaceId, connectionId, resolveConnection, refreshAccessToken, store};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createInstallation(input: {
|
|
42
|
+
connectionId: string;
|
|
43
|
+
tokenExpiresAt?: Date | null | undefined;
|
|
44
|
+
scopes?: string[] | undefined;
|
|
45
|
+
}) {
|
|
46
|
+
return upsertLinearInstallation({
|
|
47
|
+
connectionId: input.connectionId,
|
|
48
|
+
organizationId: `org-${crypto.randomUUID()}`,
|
|
49
|
+
organizationUrlKey: 'acme',
|
|
50
|
+
appUserId: 'app-user-id',
|
|
51
|
+
scopes: input.scopes ?? ['read'],
|
|
52
|
+
tokenExpiresAt: input.tokenExpiresAt,
|
|
53
|
+
status: 'installed',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function storedToken(input: {
|
|
58
|
+
workspaceId: string;
|
|
59
|
+
connectionId: string;
|
|
60
|
+
key: 'ACCESS_TOKEN' | 'REFRESH_TOKEN';
|
|
61
|
+
}) {
|
|
62
|
+
return secrets.getSecret({
|
|
63
|
+
workspaceId: input.workspaceId,
|
|
64
|
+
namespace: linearSecretsNamespace(input.connectionId),
|
|
65
|
+
key: input.key,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('createLinearTokenStore.storeTokens', () => {
|
|
70
|
+
it('stores access and refresh tokens in the Linear system namespace', async () => {
|
|
71
|
+
const {workspaceId, connectionId, store} = createConnectionContext();
|
|
72
|
+
|
|
73
|
+
await store.storeTokens({
|
|
74
|
+
connectionId,
|
|
75
|
+
accessToken: 'access-token',
|
|
76
|
+
refreshToken: 'refresh-token',
|
|
77
|
+
editedBy: crypto.randomUUID(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await expect(storedToken({workspaceId, connectionId, key: 'ACCESS_TOKEN'})).resolves.toBe(
|
|
81
|
+
'access-token',
|
|
82
|
+
);
|
|
83
|
+
await expect(storedToken({workspaceId, connectionId, key: 'REFRESH_TOKEN'})).resolves.toBe(
|
|
84
|
+
'refresh-token',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('stores only the access token when no refresh token was issued', async () => {
|
|
89
|
+
const {workspaceId, connectionId, store} = createConnectionContext();
|
|
90
|
+
|
|
91
|
+
await store.storeTokens({connectionId, accessToken: 'access-token'});
|
|
92
|
+
|
|
93
|
+
await expect(storedToken({workspaceId, connectionId, key: 'ACCESS_TOKEN'})).resolves.toBe(
|
|
94
|
+
'access-token',
|
|
95
|
+
);
|
|
96
|
+
await expect(
|
|
97
|
+
storedToken({workspaceId, connectionId, key: 'REFRESH_TOKEN'}),
|
|
98
|
+
).resolves.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('throws a typed error when the connection cannot be resolved', async () => {
|
|
102
|
+
const {store, resolveConnection, connectionId} = createConnectionContext();
|
|
103
|
+
resolveConnection.mockResolvedValue(undefined);
|
|
104
|
+
|
|
105
|
+
const result = store.storeTokens({connectionId, accessToken: 'access-token'});
|
|
106
|
+
|
|
107
|
+
await expect(result).rejects.toBeInstanceOf(LinearConnectionNotFoundError);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('createLinearTokenStore.getAccessToken', () => {
|
|
112
|
+
it('returns the stored token when no refresh is needed', async () => {
|
|
113
|
+
const {connectionId, refreshAccessToken, store} = createConnectionContext();
|
|
114
|
+
await store.storeTokens({connectionId, accessToken: 'access-token'});
|
|
115
|
+
await createInstallation({
|
|
116
|
+
connectionId,
|
|
117
|
+
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = await store.getAccessToken({connectionId});
|
|
121
|
+
|
|
122
|
+
expect(result).toBe('access-token');
|
|
123
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refreshes an expired token and writes the new secret and metadata', async () => {
|
|
127
|
+
const {workspaceId, connectionId, refreshAccessToken, store} = createConnectionContext();
|
|
128
|
+
const expiresAt = new Date('2026-07-07T13:00:00.000Z');
|
|
129
|
+
await store.storeTokens({
|
|
130
|
+
connectionId,
|
|
131
|
+
accessToken: 'old-access-token',
|
|
132
|
+
refreshToken: 'old-refresh-token',
|
|
133
|
+
});
|
|
134
|
+
await createInstallation({
|
|
135
|
+
connectionId,
|
|
136
|
+
tokenExpiresAt: new Date('2026-07-07T11:00:00.000Z'),
|
|
137
|
+
scopes: ['read'],
|
|
138
|
+
});
|
|
139
|
+
refreshAccessToken.mockResolvedValue({
|
|
140
|
+
accessToken: 'new-access-token',
|
|
141
|
+
refreshToken: 'new-refresh-token',
|
|
142
|
+
expiresAt,
|
|
143
|
+
scopes: ['read', 'write'],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await store.getAccessToken({connectionId});
|
|
147
|
+
|
|
148
|
+
const installation = await getLinearInstallationByConnectionId(connectionId);
|
|
149
|
+
expect(result).toBe('new-access-token');
|
|
150
|
+
await expect(storedToken({workspaceId, connectionId, key: 'ACCESS_TOKEN'})).resolves.toBe(
|
|
151
|
+
'new-access-token',
|
|
152
|
+
);
|
|
153
|
+
await expect(storedToken({workspaceId, connectionId, key: 'REFRESH_TOKEN'})).resolves.toBe(
|
|
154
|
+
'new-refresh-token',
|
|
155
|
+
);
|
|
156
|
+
expect(installation?.tokenExpiresAt?.toISOString()).toBe(expiresAt.toISOString());
|
|
157
|
+
expect(installation?.scopes).toEqual(['read', 'write']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('force refreshes even when the token expiry is unknown', async () => {
|
|
161
|
+
const {connectionId, refreshAccessToken, store} = createConnectionContext();
|
|
162
|
+
await store.storeTokens({
|
|
163
|
+
connectionId,
|
|
164
|
+
accessToken: 'old-access-token',
|
|
165
|
+
refreshToken: 'refresh-token',
|
|
166
|
+
});
|
|
167
|
+
await createInstallation({connectionId, tokenExpiresAt: null});
|
|
168
|
+
refreshAccessToken.mockResolvedValue({
|
|
169
|
+
accessToken: 'new-access-token',
|
|
170
|
+
expiresAt: new Date('2026-07-07T13:00:00.000Z'),
|
|
171
|
+
scopes: [],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = await store.getAccessToken({connectionId, forceRefresh: true});
|
|
175
|
+
|
|
176
|
+
expect(result).toBe('new-access-token');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('returns the stored token for proactive refresh when no refresh token exists', async () => {
|
|
180
|
+
const {connectionId, refreshAccessToken, store} = createConnectionContext();
|
|
181
|
+
await store.storeTokens({connectionId, accessToken: 'access-token'});
|
|
182
|
+
await createInstallation({
|
|
183
|
+
connectionId,
|
|
184
|
+
tokenExpiresAt: new Date('2026-07-07T11:00:00.000Z'),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = await store.getAccessToken({connectionId});
|
|
188
|
+
|
|
189
|
+
expect(result).toBe('access-token');
|
|
190
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('preserves the stored refresh token when Linear omits a rotated replacement', async () => {
|
|
194
|
+
const {workspaceId, connectionId, refreshAccessToken, store} = createConnectionContext();
|
|
195
|
+
await store.storeTokens({
|
|
196
|
+
connectionId,
|
|
197
|
+
accessToken: 'old-access-token',
|
|
198
|
+
refreshToken: 'old-refresh-token',
|
|
199
|
+
});
|
|
200
|
+
await createInstallation({
|
|
201
|
+
connectionId,
|
|
202
|
+
tokenExpiresAt: new Date('2026-07-07T11:00:00.000Z'),
|
|
203
|
+
});
|
|
204
|
+
refreshAccessToken.mockResolvedValue({
|
|
205
|
+
accessToken: 'new-access-token',
|
|
206
|
+
expiresAt: new Date('2026-07-07T13:00:00.000Z'),
|
|
207
|
+
scopes: ['read'],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await store.getAccessToken({connectionId});
|
|
211
|
+
|
|
212
|
+
await expect(storedToken({workspaceId, connectionId, key: 'REFRESH_TOKEN'})).resolves.toBe(
|
|
213
|
+
'old-refresh-token',
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('shares one in-process refresh across concurrent callers', async () => {
|
|
218
|
+
const {connectionId, refreshAccessToken, store} = createConnectionContext();
|
|
219
|
+
await store.storeTokens({
|
|
220
|
+
connectionId,
|
|
221
|
+
accessToken: 'old-access-token',
|
|
222
|
+
refreshToken: 'refresh-token',
|
|
223
|
+
});
|
|
224
|
+
await createInstallation({
|
|
225
|
+
connectionId,
|
|
226
|
+
tokenExpiresAt: new Date('2026-07-07T11:00:00.000Z'),
|
|
227
|
+
});
|
|
228
|
+
refreshAccessToken.mockResolvedValue({
|
|
229
|
+
accessToken: 'new-access-token',
|
|
230
|
+
refreshToken: 'new-refresh-token',
|
|
231
|
+
expiresAt: new Date('2026-07-07T13:00:00.000Z'),
|
|
232
|
+
scopes: ['read'],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const results = await Promise.all([
|
|
236
|
+
store.getAccessToken({connectionId}),
|
|
237
|
+
store.getAccessToken({connectionId}),
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
expect(refreshAccessToken).toHaveBeenCalledTimes(1);
|
|
241
|
+
expect(results).toEqual(['new-access-token', 'new-access-token']);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('throws reconnect-needed when a forced refresh has no refresh token', async () => {
|
|
245
|
+
const {connectionId, store} = createConnectionContext();
|
|
246
|
+
await store.storeTokens({connectionId, accessToken: 'dead-access-token'});
|
|
247
|
+
await createInstallation({connectionId, tokenExpiresAt: null});
|
|
248
|
+
|
|
249
|
+
const result = store.getAccessToken({connectionId, forceRefresh: true});
|
|
250
|
+
|
|
251
|
+
await expect(result).rejects.toBeInstanceOf(LinearTokenUnrefreshableError);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('throws a typed error when the connection cannot be resolved', async () => {
|
|
255
|
+
const {store, resolveConnection, connectionId} = createConnectionContext();
|
|
256
|
+
resolveConnection.mockResolvedValue(undefined);
|
|
257
|
+
|
|
258
|
+
const result = store.getAccessToken({connectionId});
|
|
259
|
+
|
|
260
|
+
await expect(result).rejects.toBeInstanceOf(LinearConnectionNotFoundError);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('throws a typed error when no access token is stored', async () => {
|
|
264
|
+
const {connectionId, store} = createConnectionContext();
|
|
265
|
+
await createInstallation({connectionId, tokenExpiresAt: null});
|
|
266
|
+
|
|
267
|
+
const result = store.getAccessToken({connectionId});
|
|
268
|
+
|
|
269
|
+
await expect(result).rejects.toBeInstanceOf(LinearAccessTokenMissingError);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('skips proactive refresh when the installation row is missing', async () => {
|
|
273
|
+
const {connectionId, refreshAccessToken, store} = createConnectionContext();
|
|
274
|
+
await store.storeTokens({connectionId, accessToken: 'access-token'});
|
|
275
|
+
|
|
276
|
+
const result = await store.getAccessToken({connectionId});
|
|
277
|
+
|
|
278
|
+
expect(result).toBe('access-token');
|
|
279
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {createLinearApiClient, type LinearApiClient} from '#api/client.js';
|
|
2
|
+
import {
|
|
3
|
+
LinearAccessTokenMissingError,
|
|
4
|
+
LinearConnectionNotFoundError,
|
|
5
|
+
LinearIntegrationProviderError,
|
|
6
|
+
LinearTokenUnrefreshableError,
|
|
7
|
+
} from '#core/errors.js';
|
|
8
|
+
import {
|
|
9
|
+
getLinearInstallationByConnectionId,
|
|
10
|
+
updateLinearInstallationTokenExpiry,
|
|
11
|
+
withLinearRefreshLock,
|
|
12
|
+
} from '#db/installations.js';
|
|
13
|
+
|
|
14
|
+
const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
|
|
15
|
+
const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
|
|
16
|
+
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
17
|
+
const tokenRefreshes = new Map<string, Promise<string>>();
|
|
18
|
+
|
|
19
|
+
export interface LinearConnectionResolverResult {
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreateLinearTokenStoreParams {
|
|
24
|
+
resolveConnection(connectionId: string): Promise<LinearConnectionResolverResult | undefined>;
|
|
25
|
+
secrets: LinearSecretsStore;
|
|
26
|
+
client?: LinearApiClient | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LinearSecretsStore {
|
|
30
|
+
getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;
|
|
31
|
+
setSecrets(params: {
|
|
32
|
+
workspaceId: string;
|
|
33
|
+
namespace: string;
|
|
34
|
+
values: Record<string, string>;
|
|
35
|
+
editedBy?: string | null | undefined;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StoreLinearTokensParams {
|
|
40
|
+
connectionId: string;
|
|
41
|
+
accessToken: string;
|
|
42
|
+
refreshToken?: string | undefined;
|
|
43
|
+
editedBy?: string | null | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GetLinearAccessTokenParams {
|
|
47
|
+
connectionId: string;
|
|
48
|
+
forceRefresh?: boolean | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LinearTokenStore {
|
|
52
|
+
storeTokens(params: StoreLinearTokensParams): Promise<void>;
|
|
53
|
+
getAccessToken(params: GetLinearAccessTokenParams): Promise<string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function linearSecretsNamespace(connectionId: string): string {
|
|
57
|
+
return `system/integrations/linear/${connectionId}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createLinearTokenStore(params: CreateLinearTokenStoreParams): LinearTokenStore {
|
|
61
|
+
const client = params.client ?? createLinearApiClient();
|
|
62
|
+
|
|
63
|
+
async function resolveWorkspaceId(connectionId: string): Promise<string> {
|
|
64
|
+
const connection = await params.resolveConnection(connectionId);
|
|
65
|
+
if (!connection) throw new LinearConnectionNotFoundError(connectionId);
|
|
66
|
+
return connection.workspaceId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readSecretToken(
|
|
70
|
+
workspaceId: string,
|
|
71
|
+
connectionId: string,
|
|
72
|
+
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
73
|
+
): Promise<string | null> {
|
|
74
|
+
return params.secrets.getSecret({
|
|
75
|
+
workspaceId,
|
|
76
|
+
namespace: linearSecretsNamespace(connectionId),
|
|
77
|
+
key,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readAccessToken(workspaceId: string, connectionId: string): Promise<string> {
|
|
82
|
+
const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);
|
|
83
|
+
if (!token) throw new LinearAccessTokenMissingError(connectionId);
|
|
84
|
+
return token;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async storeTokens(input) {
|
|
89
|
+
const workspaceId = await resolveWorkspaceId(input.connectionId);
|
|
90
|
+
const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};
|
|
91
|
+
if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;
|
|
92
|
+
|
|
93
|
+
await params.secrets.setSecrets({
|
|
94
|
+
workspaceId,
|
|
95
|
+
namespace: linearSecretsNamespace(input.connectionId),
|
|
96
|
+
values,
|
|
97
|
+
editedBy: input.editedBy,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async getAccessToken(input) {
|
|
102
|
+
const forceRefresh = input.forceRefresh === true;
|
|
103
|
+
const workspaceId = await resolveWorkspaceId(input.connectionId);
|
|
104
|
+
const installation = await getLinearInstallationByConnectionId(input.connectionId);
|
|
105
|
+
const accessToken = await readAccessToken(workspaceId, input.connectionId);
|
|
106
|
+
|
|
107
|
+
if (!forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null)) {
|
|
108
|
+
return accessToken;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const inFlightRefresh = tokenRefreshes.get(input.connectionId);
|
|
112
|
+
if (inFlightRefresh) return inFlightRefresh;
|
|
113
|
+
|
|
114
|
+
const refresh = refreshAccessTokenWithLock({
|
|
115
|
+
connectionId: input.connectionId,
|
|
116
|
+
workspaceId,
|
|
117
|
+
originalAccessToken: accessToken,
|
|
118
|
+
forceRefresh,
|
|
119
|
+
client,
|
|
120
|
+
secrets: params.secrets,
|
|
121
|
+
readAccessToken,
|
|
122
|
+
readSecretToken,
|
|
123
|
+
});
|
|
124
|
+
tokenRefreshes.set(input.connectionId, refresh);
|
|
125
|
+
void refresh.then(
|
|
126
|
+
() => clearTokenRefresh(input.connectionId, refresh),
|
|
127
|
+
() => clearTokenRefresh(input.connectionId, refresh),
|
|
128
|
+
);
|
|
129
|
+
return refresh;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function shouldRefresh(expiresAt: Date | null): boolean {
|
|
135
|
+
return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function refreshAccessTokenWithLock(params: {
|
|
139
|
+
connectionId: string;
|
|
140
|
+
workspaceId: string;
|
|
141
|
+
originalAccessToken: string;
|
|
142
|
+
forceRefresh: boolean;
|
|
143
|
+
client: LinearApiClient;
|
|
144
|
+
secrets: LinearSecretsStore;
|
|
145
|
+
readAccessToken(workspaceId: string, connectionId: string): Promise<string>;
|
|
146
|
+
readSecretToken(
|
|
147
|
+
workspaceId: string,
|
|
148
|
+
connectionId: string,
|
|
149
|
+
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
150
|
+
): Promise<string | null>;
|
|
151
|
+
}): Promise<string> {
|
|
152
|
+
const lock = await withLinearRefreshLock(params.connectionId, async () =>
|
|
153
|
+
refreshAccessTokenForConnection(params),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (lock.acquired) return lock.value;
|
|
157
|
+
|
|
158
|
+
const rereadToken = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
159
|
+
if (rereadToken !== params.originalAccessToken) return rereadToken;
|
|
160
|
+
if (params.forceRefresh) {
|
|
161
|
+
throw new LinearIntegrationProviderError(
|
|
162
|
+
'provider-unavailable',
|
|
163
|
+
'Linear token refresh is already in progress',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return params.originalAccessToken;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {
|
|
170
|
+
if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function refreshAccessTokenForConnection(params: {
|
|
174
|
+
connectionId: string;
|
|
175
|
+
workspaceId: string;
|
|
176
|
+
originalAccessToken: string;
|
|
177
|
+
forceRefresh: boolean;
|
|
178
|
+
client: LinearApiClient;
|
|
179
|
+
secrets: LinearSecretsStore;
|
|
180
|
+
readAccessToken(workspaceId: string, connectionId: string): Promise<string>;
|
|
181
|
+
readSecretToken(
|
|
182
|
+
workspaceId: string,
|
|
183
|
+
connectionId: string,
|
|
184
|
+
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
185
|
+
): Promise<string | null>;
|
|
186
|
+
}): Promise<string> {
|
|
187
|
+
const currentAccessToken = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
188
|
+
const currentInstallation = await getLinearInstallationByConnectionId(params.connectionId);
|
|
189
|
+
const tokenChanged = currentAccessToken !== params.originalAccessToken;
|
|
190
|
+
const isFresh =
|
|
191
|
+
!params.forceRefresh && !shouldRefresh(currentInstallation?.tokenExpiresAt ?? null);
|
|
192
|
+
if (tokenChanged || isFresh) return currentAccessToken;
|
|
193
|
+
|
|
194
|
+
const refreshToken = await params.readSecretToken(
|
|
195
|
+
params.workspaceId,
|
|
196
|
+
params.connectionId,
|
|
197
|
+
REFRESH_TOKEN_KEY,
|
|
198
|
+
);
|
|
199
|
+
if (!refreshToken) {
|
|
200
|
+
if (params.forceRefresh) throw new LinearTokenUnrefreshableError(params.connectionId);
|
|
201
|
+
return currentAccessToken;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const refreshed = await params.client.refreshAccessToken({refreshToken});
|
|
205
|
+
const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: refreshed.accessToken};
|
|
206
|
+
if (refreshed.refreshToken) values[REFRESH_TOKEN_KEY] = refreshed.refreshToken;
|
|
207
|
+
await params.secrets.setSecrets({
|
|
208
|
+
workspaceId: params.workspaceId,
|
|
209
|
+
namespace: linearSecretsNamespace(params.connectionId),
|
|
210
|
+
values,
|
|
211
|
+
});
|
|
212
|
+
await updateLinearInstallationTokenExpiry({
|
|
213
|
+
connectionId: params.connectionId,
|
|
214
|
+
tokenExpiresAt: refreshed.expiresAt ?? null,
|
|
215
|
+
scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return refreshed.accessToken;
|
|
219
|
+
}
|