@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,45 @@
|
|
|
1
|
+
import {disconnectLinearInstallation} from './disconnect.js';
|
|
2
|
+
|
|
3
|
+
vi.mock('#db/installations.js', () => ({
|
|
4
|
+
deleteLinearInstallationByConnectionId: vi.fn(() => Promise.resolve(true)),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
const {deleteLinearInstallationByConnectionId} = await import('#db/installations.js');
|
|
8
|
+
const deleteLinearInstallationByConnectionIdMock = vi.mocked(
|
|
9
|
+
deleteLinearInstallationByConnectionId,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
describe('disconnectLinearInstallation', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
deleteLinearInstallationByConnectionIdMock.mockClear();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('deletes stored tokens before deleting connection records', async () => {
|
|
18
|
+
const tx = Symbol('tx');
|
|
19
|
+
const calls: string[] = [];
|
|
20
|
+
const deleteSecrets = vi.fn(() => {
|
|
21
|
+
calls.push('secrets');
|
|
22
|
+
return Promise.resolve(2);
|
|
23
|
+
});
|
|
24
|
+
const deleteConnection = vi.fn(() => {
|
|
25
|
+
calls.push('connection');
|
|
26
|
+
return Promise.resolve(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await disconnectLinearInstallation({
|
|
30
|
+
connectionId: 'connection-1',
|
|
31
|
+
getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
|
|
32
|
+
deleteSecrets,
|
|
33
|
+
transaction: async (fn) => await fn(tx),
|
|
34
|
+
deleteConnection,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(deleteSecrets).toHaveBeenCalledWith({
|
|
38
|
+
workspaceId: 'workspace-1',
|
|
39
|
+
namespace: 'system/integrations/linear/connection-1',
|
|
40
|
+
});
|
|
41
|
+
expect(deleteLinearInstallationByConnectionIdMock).toHaveBeenCalledWith('connection-1', {tx});
|
|
42
|
+
expect(deleteConnection).toHaveBeenCalledWith({connectionId: 'connection-1'}, {tx});
|
|
43
|
+
expect(calls).toEqual(['secrets', 'connection']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {deleteLinearInstallationByConnectionId} from '#db/installations.js';
|
|
2
|
+
import {linearSecretsNamespace} from './tokens.js';
|
|
3
|
+
|
|
4
|
+
export interface DisconnectLinearInstallationParams<Tx = unknown> {
|
|
5
|
+
connectionId: string;
|
|
6
|
+
getConnection(connectionId: string): Promise<{workspaceId: string} | undefined>;
|
|
7
|
+
deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
|
|
8
|
+
transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
|
|
9
|
+
deleteConnection(params: {connectionId: string}, options: {tx: Tx}): Promise<boolean>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function disconnectLinearInstallation<Tx = unknown>(
|
|
13
|
+
params: DisconnectLinearInstallationParams<Tx>,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const connection = await params.getConnection(params.connectionId);
|
|
16
|
+
if (connection) {
|
|
17
|
+
await params.deleteSecrets({
|
|
18
|
+
workspaceId: connection.workspaceId,
|
|
19
|
+
namespace: linearSecretsNamespace(params.connectionId),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await params.transaction(async (tx) => {
|
|
24
|
+
await deleteLinearInstallationByConnectionId(params.connectionId, {tx});
|
|
25
|
+
await params.deleteConnection({connectionId: params.connectionId}, {tx});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {IntegrationProviderError} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
|
|
3
|
+
export class LinearIntegrationProviderError extends IntegrationProviderError {}
|
|
4
|
+
|
|
5
|
+
export class LinearInstallStateError extends Error {
|
|
6
|
+
constructor(message = 'Invalid Linear install state') {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'LinearInstallStateError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class LinearInstallStateActorMismatchError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super('Linear install state was created by a different user');
|
|
15
|
+
this.name = 'LinearInstallStateActorMismatchError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class LinearOAuthCallbackError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
public readonly providerError: string,
|
|
22
|
+
public readonly providerDescription: string | undefined,
|
|
23
|
+
) {
|
|
24
|
+
super(providerDescription ?? `Linear OAuth callback failed: ${providerError}`);
|
|
25
|
+
this.name = 'LinearOAuthCallbackError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class LinearAuthorizationScopeMismatchError extends Error {
|
|
30
|
+
constructor(public readonly missingScopes: string[]) {
|
|
31
|
+
super(`Linear authorization is missing required scopes: ${missingScopes.join(', ')}`);
|
|
32
|
+
this.name = 'LinearAuthorizationScopeMismatchError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class LinearInstallationAlreadyLinkedError extends Error {
|
|
37
|
+
constructor(organizationId: string) {
|
|
38
|
+
super(`Linear organization is already linked to another Shipfox workspace: ${organizationId}`);
|
|
39
|
+
this.name = 'LinearInstallationAlreadyLinkedError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class LinearConnectionAlreadyLinkedError extends Error {
|
|
44
|
+
constructor(connectionId: string) {
|
|
45
|
+
super(
|
|
46
|
+
`Integration connection is already linked to another Linear organization: ${connectionId}`,
|
|
47
|
+
);
|
|
48
|
+
this.name = 'LinearConnectionAlreadyLinkedError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class LinearConnectionNotFoundError extends Error {
|
|
53
|
+
constructor(connectionId: string) {
|
|
54
|
+
super(`Linear integration connection was not found: ${connectionId}`);
|
|
55
|
+
this.name = 'LinearConnectionNotFoundError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class LinearAccessTokenMissingError extends Error {
|
|
60
|
+
constructor(connectionId: string) {
|
|
61
|
+
super(`Linear access token is missing for connection: ${connectionId}`);
|
|
62
|
+
this.name = 'LinearAccessTokenMissingError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class LinearTokenUnrefreshableError extends Error {
|
|
67
|
+
constructor(public readonly connectionId: string) {
|
|
68
|
+
super(`Linear token cannot be refreshed; reconnect is required: ${connectionId}`);
|
|
69
|
+
this.name = 'LinearTokenUnrefreshableError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type {UserContextMembership} from '@shipfox/api-auth-context';
|
|
2
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
4
|
+
import type {LinearApiClient} from '#api/client.js';
|
|
5
|
+
import type {LinearTokenStore} from '#core/tokens.js';
|
|
6
|
+
import {
|
|
7
|
+
LinearInstallationAlreadyLinkedError,
|
|
8
|
+
LinearInstallStateActorMismatchError,
|
|
9
|
+
LinearOAuthCallbackError,
|
|
10
|
+
} from './errors.js';
|
|
11
|
+
import {assertLinearAuthorizationScopes} from './scopes.js';
|
|
12
|
+
import {verifyLinearInstallState} from './state.js';
|
|
13
|
+
|
|
14
|
+
export interface ConnectLinearInstallationInput {
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
organizationId: string;
|
|
17
|
+
organizationUrlKey: string;
|
|
18
|
+
appUserId: string;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
tokenExpiresAt: Date | null;
|
|
21
|
+
displayName: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HandleLinearCallbackParams {
|
|
25
|
+
linear: LinearApiClient;
|
|
26
|
+
tokenStore: Pick<LinearTokenStore, 'storeTokens'>;
|
|
27
|
+
code: string;
|
|
28
|
+
state: string;
|
|
29
|
+
sessionUserId: string;
|
|
30
|
+
sessionMemberships: ReadonlyArray<UserContextMembership>;
|
|
31
|
+
requireWorkspaceMembership: (params: {
|
|
32
|
+
workspaceId: string;
|
|
33
|
+
userId: string;
|
|
34
|
+
memberships: ReadonlyArray<UserContextMembership>;
|
|
35
|
+
}) => Promise<unknown>;
|
|
36
|
+
getExistingLinearConnection: (input: {
|
|
37
|
+
organizationId: string;
|
|
38
|
+
}) => Promise<IntegrationConnection<'linear'> | undefined>;
|
|
39
|
+
connectLinearInstallation: (
|
|
40
|
+
input: ConnectLinearInstallationInput,
|
|
41
|
+
) => Promise<IntegrationConnection<'linear'>>;
|
|
42
|
+
disconnectLinearInstallation: (input: {connectionId: string}) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function handleLinearCallback(
|
|
46
|
+
params: HandleLinearCallbackParams,
|
|
47
|
+
): Promise<IntegrationConnection<'linear'>> {
|
|
48
|
+
if (typeof params.disconnectLinearInstallation !== 'function') {
|
|
49
|
+
throw new Error('Linear installation cleanup is not configured');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const claims = verifyLinearInstallState(params.state);
|
|
53
|
+
if (claims.userId !== params.sessionUserId) {
|
|
54
|
+
throw new LinearInstallStateActorMismatchError();
|
|
55
|
+
}
|
|
56
|
+
await params.requireWorkspaceMembership({
|
|
57
|
+
workspaceId: claims.workspaceId,
|
|
58
|
+
userId: claims.userId,
|
|
59
|
+
memberships: params.sessionMemberships,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const authorization = await params.linear.exchangeAuthorizationCode({code: params.code});
|
|
63
|
+
let connectedConnectionId: string | undefined;
|
|
64
|
+
let shouldDisconnectConnection = false;
|
|
65
|
+
try {
|
|
66
|
+
const identity = await params.linear.getIdentity({accessToken: authorization.accessToken});
|
|
67
|
+
const existing = await params.getExistingLinearConnection({
|
|
68
|
+
organizationId: identity.organizationId,
|
|
69
|
+
});
|
|
70
|
+
if (existing && existing.workspaceId !== claims.workspaceId) {
|
|
71
|
+
throw new LinearInstallationAlreadyLinkedError(identity.organizationId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
assertLinearAuthorizationScopes(authorization.scopes);
|
|
75
|
+
|
|
76
|
+
const connection = await params.connectLinearInstallation({
|
|
77
|
+
workspaceId: claims.workspaceId,
|
|
78
|
+
organizationId: identity.organizationId,
|
|
79
|
+
organizationUrlKey: identity.organizationUrlKey,
|
|
80
|
+
appUserId: identity.appUserId,
|
|
81
|
+
scopes: authorization.scopes,
|
|
82
|
+
tokenExpiresAt: authorization.expiresAt ?? null,
|
|
83
|
+
displayName: `Linear ${identity.organizationName}`,
|
|
84
|
+
});
|
|
85
|
+
connectedConnectionId = connection.id;
|
|
86
|
+
shouldDisconnectConnection = !existing;
|
|
87
|
+
await params.tokenStore.storeTokens({
|
|
88
|
+
connectionId: connection.id,
|
|
89
|
+
accessToken: authorization.accessToken,
|
|
90
|
+
refreshToken: authorization.refreshToken,
|
|
91
|
+
editedBy: claims.userId,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return connection;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
await bestEffortRevokeAuthorization(params.linear, authorization);
|
|
97
|
+
if (connectedConnectionId && shouldDisconnectConnection) {
|
|
98
|
+
await bestEffortDisconnectLinearInstallation(params, connectedConnectionId);
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function handleLinearOAuthCallbackError(params: {
|
|
105
|
+
state: string;
|
|
106
|
+
error: string;
|
|
107
|
+
errorDescription?: string | undefined;
|
|
108
|
+
sessionUserId: string;
|
|
109
|
+
sessionMemberships: ReadonlyArray<UserContextMembership>;
|
|
110
|
+
requireWorkspaceMembership: (input: {
|
|
111
|
+
workspaceId: string;
|
|
112
|
+
userId: string;
|
|
113
|
+
memberships: ReadonlyArray<UserContextMembership>;
|
|
114
|
+
}) => Promise<unknown>;
|
|
115
|
+
}): Promise<never> {
|
|
116
|
+
const claims = verifyLinearInstallState(params.state);
|
|
117
|
+
if (claims.userId !== params.sessionUserId) throw new LinearInstallStateActorMismatchError();
|
|
118
|
+
await params.requireWorkspaceMembership({
|
|
119
|
+
workspaceId: claims.workspaceId,
|
|
120
|
+
userId: claims.userId,
|
|
121
|
+
memberships: params.sessionMemberships,
|
|
122
|
+
});
|
|
123
|
+
throw new LinearOAuthCallbackError(params.error, params.errorDescription);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function bestEffortRevokeAuthorization(
|
|
127
|
+
linear: LinearApiClient,
|
|
128
|
+
authorization: {accessToken: string; refreshToken?: string | undefined},
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
await Promise.all([
|
|
131
|
+
authorization.refreshToken
|
|
132
|
+
? revokeToken(linear, authorization.refreshToken, 'refresh_token')
|
|
133
|
+
: Promise.resolve(),
|
|
134
|
+
revokeToken(linear, authorization.accessToken, 'access_token'),
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function revokeToken(
|
|
139
|
+
linear: LinearApiClient,
|
|
140
|
+
token: string,
|
|
141
|
+
tokenTypeHint: 'access_token' | 'refresh_token',
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
await linear.revokeToken({token, tokenTypeHint});
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger().warn({err: error, tokenTypeHint}, 'Linear OAuth token revocation failed');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function bestEffortDisconnectLinearInstallation(
|
|
151
|
+
params: HandleLinearCallbackParams,
|
|
152
|
+
connectionId: string,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
try {
|
|
155
|
+
await params.disconnectLinearInstallation({connectionId});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
logger().warn(
|
|
158
|
+
{err: error, connectionId},
|
|
159
|
+
'Linear connect compensation failed after token storage rejection',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {LinearAuthorizationScopeMismatchError} from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const LINEAR_OAUTH_SCOPES = Object.freeze([
|
|
4
|
+
'read',
|
|
5
|
+
'write',
|
|
6
|
+
'app:assignable',
|
|
7
|
+
'app:mentionable',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function formatLinearOAuthScopes(scopes = LINEAR_OAUTH_SCOPES): string {
|
|
11
|
+
return scopes.join(',');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function assertLinearAuthorizationScopes(scopes: string[]): void {
|
|
15
|
+
const grantedScopes = new Set(scopes);
|
|
16
|
+
const missingScopes = LINEAR_OAUTH_SCOPES.filter((scope) => !grantedScopes.has(scope));
|
|
17
|
+
if (missingScopes.length > 0) throw new LinearAuthorizationScopeMismatchError(missingScopes);
|
|
18
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {LinearInstallStateError} from './errors.js';
|
|
2
|
+
import {signLinearInstallState, verifyLinearInstallState} from './state.js';
|
|
3
|
+
|
|
4
|
+
describe('Linear install state', () => {
|
|
5
|
+
it('round-trips signed workspace and user claims', () => {
|
|
6
|
+
const state = signLinearInstallState({
|
|
7
|
+
workspaceId: 'workspace-1',
|
|
8
|
+
userId: 'user-1',
|
|
9
|
+
nonce: 'nonce-1',
|
|
10
|
+
now: new Date('2026-07-07T12:00:00.000Z'),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const result = verifyLinearInstallState(state, new Date('2026-07-07T12:05:00.000Z'));
|
|
14
|
+
|
|
15
|
+
expect(result).toEqual({workspaceId: 'workspace-1', userId: 'user-1'});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects tampered payloads', () => {
|
|
19
|
+
const state = signLinearInstallState({
|
|
20
|
+
workspaceId: 'workspace-1',
|
|
21
|
+
userId: 'user-1',
|
|
22
|
+
nonce: 'nonce-1',
|
|
23
|
+
now: new Date('2026-07-07T12:00:00.000Z'),
|
|
24
|
+
});
|
|
25
|
+
const [encodedPayload, signature] = state.split('.');
|
|
26
|
+
const payload = JSON.parse(Buffer.from(encodedPayload ?? '', 'base64url').toString('utf8'));
|
|
27
|
+
payload.workspaceId = 'workspace-2';
|
|
28
|
+
const tamperedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
29
|
+
|
|
30
|
+
const result = () =>
|
|
31
|
+
verifyLinearInstallState(
|
|
32
|
+
`${tamperedPayload}.${signature}`,
|
|
33
|
+
new Date('2026-07-07T12:05:00.000Z'),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(result).toThrow(LinearInstallStateError);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects expired states', () => {
|
|
40
|
+
const state = signLinearInstallState({
|
|
41
|
+
workspaceId: 'workspace-1',
|
|
42
|
+
userId: 'user-1',
|
|
43
|
+
nonce: 'nonce-1',
|
|
44
|
+
now: new Date('2026-07-07T12:00:00.000Z'),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const result = () => verifyLinearInstallState(state, new Date('2026-07-07T12:31:00.000Z'));
|
|
48
|
+
|
|
49
|
+
expect(result).toThrow(LinearInstallStateError);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {createHmac, randomUUID, timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import {config} from '#config.js';
|
|
3
|
+
import {LinearInstallStateError} from './errors.js';
|
|
4
|
+
|
|
5
|
+
const STATE_TTL_SECONDS = 30 * 60;
|
|
6
|
+
|
|
7
|
+
interface LinearInstallStatePayload {
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
nonce: string;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LinearInstallStateClaims {
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function signLinearInstallState(params: {
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
nonce?: string | undefined;
|
|
23
|
+
now?: Date | undefined;
|
|
24
|
+
}): string {
|
|
25
|
+
const now = params.now ?? new Date();
|
|
26
|
+
const payload: LinearInstallStatePayload = {
|
|
27
|
+
workspaceId: params.workspaceId,
|
|
28
|
+
userId: params.userId,
|
|
29
|
+
nonce: params.nonce ?? randomUUID(),
|
|
30
|
+
expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS,
|
|
31
|
+
};
|
|
32
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
33
|
+
return `${encodedPayload}.${sign(encodedPayload)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function verifyLinearInstallState(
|
|
37
|
+
state: string,
|
|
38
|
+
now: Date = new Date(),
|
|
39
|
+
): LinearInstallStateClaims {
|
|
40
|
+
const [encodedPayload, signature, extra] = state.split('.');
|
|
41
|
+
if (!encodedPayload || !signature || extra !== undefined) {
|
|
42
|
+
throw new LinearInstallStateError('Invalid Linear install state');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!constantTimeEqual(signature, sign(encodedPayload))) {
|
|
46
|
+
throw new LinearInstallStateError('Invalid Linear install state signature');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const payload = parsePayload(encodedPayload);
|
|
50
|
+
if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {
|
|
51
|
+
throw new LinearInstallStateError('Expired Linear install state');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {workspaceId: payload.workspaceId, userId: payload.userId};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sign(encodedPayload: string): string {
|
|
58
|
+
return createHmac('sha256', config.LINEAR_OAUTH_CLIENT_SECRET)
|
|
59
|
+
.update(encodedPayload)
|
|
60
|
+
.digest('base64url');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
64
|
+
const left = Buffer.from(a);
|
|
65
|
+
const right = Buffer.from(b);
|
|
66
|
+
if (left.length !== right.length) return false;
|
|
67
|
+
return timingSafeEqual(left, right);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parsePayload(encodedPayload: string): LinearInstallStatePayload {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
|
73
|
+
if (
|
|
74
|
+
typeof parsed.workspaceId !== 'string' ||
|
|
75
|
+
typeof parsed.userId !== 'string' ||
|
|
76
|
+
typeof parsed.nonce !== 'string' ||
|
|
77
|
+
typeof parsed.expiresAt !== 'number'
|
|
78
|
+
) {
|
|
79
|
+
throw new Error('Invalid payload shape');
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
} catch (_error) {
|
|
83
|
+
throw new LinearInstallStateError('Invalid Linear install state payload');
|
|
84
|
+
}
|
|
85
|
+
}
|