@shipfox/api-integration-linear 4.0.0 → 6.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 (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +56 -0
  3. package/dist/core/webhook-processor.d.ts +13 -0
  4. package/dist/core/webhook-processor.d.ts.map +1 -0
  5. package/dist/core/webhook-processor.js +130 -0
  6. package/dist/core/webhook-processor.js.map +1 -0
  7. package/dist/index.d.ts +28 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +19 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/presentation/routes/install.d.ts +6 -1
  12. package/dist/presentation/routes/install.d.ts.map +1 -1
  13. package/dist/presentation/routes/install.js +6 -4
  14. package/dist/presentation/routes/install.js.map +1 -1
  15. package/dist/presentation/routes/webhooks.d.ts +2 -0
  16. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  17. package/dist/presentation/routes/webhooks.js +75 -79
  18. package/dist/presentation/routes/webhooks.js.map +1 -1
  19. package/dist/tsconfig.test.tsbuildinfo +1 -1
  20. package/package.json +20 -31
  21. package/src/core/disconnect.test.ts +47 -0
  22. package/src/core/tokens.test.ts +16 -2
  23. package/src/core/webhook-processor.test.ts +154 -0
  24. package/src/core/webhook-processor.ts +127 -0
  25. package/src/index.test.ts +11 -0
  26. package/src/index.ts +26 -2
  27. package/src/presentation/routes/install.test.ts +2 -6
  28. package/src/presentation/routes/install.ts +18 -3
  29. package/src/presentation/routes/webhooks.test.ts +37 -0
  30. package/src/presentation/routes/webhooks.ts +88 -86
  31. package/test/globalSetup.ts +0 -9
  32. package/tsconfig.build.tsbuildinfo +1 -1
  33. package/turbo.json +9 -0
  34. package/test/api-secrets.d.ts +0 -28
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-linear",
3
3
  "license": "MIT",
4
- "version": "4.0.0",
4
+ "version": "6.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -13,22 +13,12 @@
13
13
  "types": "dist/index.d.ts",
14
14
  "imports": {
15
15
  "#test/*": "./test/*",
16
- "#*": {
17
- "workspace-source": "./src/*",
18
- "development": "./src/*",
19
- "default": "./dist/*"
20
- }
16
+ "#*": "./dist/*"
21
17
  },
22
18
  "exports": {
23
19
  ".": {
24
- "development": {
25
- "types": "./src/index.ts",
26
- "default": "./src/index.ts"
27
- },
28
- "default": {
29
- "types": "./dist/index.d.ts",
30
- "default": "./dist/index.js"
31
- }
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
32
22
  }
33
23
  },
34
24
  "dependencies": {
@@ -36,34 +26,33 @@
36
26
  "drizzle-orm": "^0.45.2",
37
27
  "ky": "^2.0.0",
38
28
  "zod": "^4.4.3",
39
- "@shipfox/api-auth-context": "3.0.0",
40
- "@shipfox/api-integration-core-dto": "3.0.0",
41
- "@shipfox/api-integration-linear-dto": "3.0.0",
42
- "@shipfox/api-workspaces": "4.0.0",
43
- "@shipfox/config": "1.2.1",
44
- "@shipfox/node-drizzle": "0.3.0",
45
- "@shipfox/node-fastify": "0.2.2",
46
- "@shipfox/node-opentelemetry": "0.5.1",
47
- "@shipfox/node-postgres": "0.4.1"
48
- },
49
- "peerDependencies": {
50
- "@shipfox/api-secrets": "4.0.0"
29
+ "@shipfox/api-integration-linear-dto": "6.0.0",
30
+ "@shipfox/config": "1.2.2",
31
+ "@shipfox/api-integration-core-dto": "6.0.0",
32
+ "@shipfox/api-workspaces": "6.0.0",
33
+ "@shipfox/api-auth-context": "6.0.0",
34
+ "@shipfox/node-drizzle": "0.3.2",
35
+ "@shipfox/node-fastify": "0.2.4",
36
+ "@shipfox/node-opentelemetry": "0.5.2",
37
+ "@shipfox/node-postgres": "0.4.2"
51
38
  },
52
39
  "devDependencies": {
53
40
  "@types/pg": "^8.15.5",
54
41
  "drizzle-kit": "^0.31.10",
55
42
  "fastify": "^5.3.3",
56
43
  "fishery": "^2.4.0",
57
- "@shipfox/biome": "1.8.1",
58
- "@shipfox/swc": "1.2.5",
59
- "@shipfox/ts-config": "1.3.8",
60
- "@shipfox/vitest": "1.2.2",
61
- "@shipfox/typescript": "1.1.6"
44
+ "@shipfox/depcruise": "1.0.2",
45
+ "@shipfox/biome": "1.8.2",
46
+ "@shipfox/swc": "1.2.6",
47
+ "@shipfox/typescript": "1.1.7",
48
+ "@shipfox/vitest": "1.2.3",
49
+ "@shipfox/ts-config": "1.3.8"
62
50
  },
63
51
  "scripts": {
64
52
  "build": "shipfox-swc",
65
53
  "check": "shipfox-biome-check",
66
54
  "check:fix": "shipfox-biome-check --write",
55
+ "depcruise": "shipfox-depcruise",
67
56
  "test": "shipfox-vitest-run",
68
57
  "test:watch": "shipfox-vitest-watch",
69
58
  "type": "shipfox-tsc-check",
@@ -12,6 +12,7 @@ const deleteLinearInstallationByConnectionIdMock = vi.mocked(
12
12
  describe('disconnectLinearInstallation', () => {
13
13
  beforeEach(() => {
14
14
  deleteLinearInstallationByConnectionIdMock.mockClear();
15
+ deleteLinearInstallationByConnectionIdMock.mockResolvedValue(true);
15
16
  });
16
17
 
17
18
  it('deletes stored tokens before deleting connection records', async () => {
@@ -42,4 +43,50 @@ describe('disconnectLinearInstallation', () => {
42
43
  expect(deleteConnection).toHaveBeenCalledWith({connectionId: 'connection-1'}, {tx});
43
44
  expect(calls).toEqual(['secrets', 'connection']);
44
45
  });
46
+
47
+ it('keeps connection records when secret deletion fails', async () => {
48
+ const transaction = vi.fn();
49
+ const deleteConnection = vi.fn();
50
+
51
+ const run = disconnectLinearInstallation({
52
+ connectionId: 'connection-1',
53
+ getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
54
+ deleteSecrets: vi.fn(() => Promise.reject(new Error('secret store unavailable'))),
55
+ transaction,
56
+ deleteConnection,
57
+ });
58
+
59
+ await expect(run).rejects.toThrow('secret store unavailable');
60
+ expect(transaction).not.toHaveBeenCalled();
61
+ expect(deleteLinearInstallationByConnectionIdMock).not.toHaveBeenCalled();
62
+ expect(deleteConnection).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('retries idempotent secret deletion after the records transaction fails', async () => {
66
+ const tx = Symbol('tx');
67
+ const deleteSecrets = vi.fn(() => Promise.resolve(0));
68
+ const deleteConnection = vi.fn(() => Promise.resolve(true));
69
+ const transaction = vi
70
+ .fn()
71
+ .mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => {
72
+ await fn(tx);
73
+ throw new Error('database commit failed');
74
+ })
75
+ .mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => await fn(tx));
76
+ const params = {
77
+ connectionId: 'connection-1',
78
+ getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
79
+ deleteSecrets,
80
+ transaction,
81
+ deleteConnection,
82
+ };
83
+
84
+ const firstAttempt = disconnectLinearInstallation(params);
85
+ await expect(firstAttempt).rejects.toThrow('database commit failed');
86
+ await disconnectLinearInstallation(params);
87
+
88
+ expect(deleteSecrets).toHaveBeenCalledTimes(2);
89
+ expect(deleteLinearInstallationByConnectionIdMock).toHaveBeenCalledTimes(2);
90
+ expect(deleteConnection).toHaveBeenCalledTimes(2);
91
+ });
45
92
  });
@@ -13,8 +13,8 @@ import {
13
13
 
14
14
  let secrets: LinearSecretsStore;
15
15
 
16
- beforeAll(async () => {
17
- secrets = await import('@shipfox/api-secrets');
16
+ beforeEach(() => {
17
+ secrets = createInMemorySecretsStore();
18
18
  });
19
19
 
20
20
  function createConnectionContext() {
@@ -66,6 +66,20 @@ function storedToken(input: {
66
66
  });
67
67
  }
68
68
 
69
+ function createInMemorySecretsStore(): LinearSecretsStore {
70
+ const values = new Map<string, string>();
71
+ const id = (params: {workspaceId: string; namespace: string; key: string}) =>
72
+ `${params.workspaceId}\0${params.namespace}\0${params.key}`;
73
+ return {
74
+ getSecret: async (params) => values.get(id(params)) ?? null,
75
+ setSecrets: async (params) => {
76
+ await Promise.resolve();
77
+ for (const [key, value] of Object.entries(params.values))
78
+ values.set(id({...params, key}), value);
79
+ },
80
+ };
81
+ }
82
+
69
83
  describe('createLinearTokenStore.storeTokens', () => {
70
84
  it('stores access and refresh tokens in the Linear system namespace', async () => {
71
85
  const {workspaceId, connectionId, store} = createConnectionContext();
@@ -0,0 +1,154 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {createStoredWebhookRequest} from '@shipfox/api-integration-core-dto';
4
+ import {db} from '#db/db.js';
5
+ import {upsertLinearInstallation} from '#db/installations.js';
6
+ import {linearInstallations} from '#db/schema/installations.js';
7
+ import {createLinearWebhookProcessor} from './webhook-processor.js';
8
+
9
+ const WEBHOOK_SECRET = 'test-webhook-secret';
10
+
11
+ function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
12
+ return {
13
+ id: randomUUID(),
14
+ workspaceId: randomUUID(),
15
+ provider: 'linear',
16
+ externalAccountId: 'org-1',
17
+ slug: 'Linear_Acme',
18
+ displayName: 'Linear Acme',
19
+ lifecycleStatus: 'active',
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function signedRequest(input: {
27
+ body: Record<string, unknown>;
28
+ deliveryId?: string | undefined;
29
+ receivedAt: string;
30
+ }) {
31
+ const rawBody = Buffer.from(JSON.stringify(input.body));
32
+ const signature = createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
33
+
34
+ return createStoredWebhookRequest({
35
+ requestId: randomUUID(),
36
+ routeId: 'linear',
37
+ receivedAt: input.receivedAt,
38
+ rawQueryString: '',
39
+ headers: {
40
+ 'content-type': 'application/json',
41
+ 'linear-delivery': input.deliveryId ?? randomUUID(),
42
+ 'linear-event': String(input.body.type),
43
+ 'linear-signature': signature,
44
+ },
45
+ body: rawBody,
46
+ });
47
+ }
48
+
49
+ async function seedInstallation(input: {
50
+ connectionId: string;
51
+ organizationId: string;
52
+ }): Promise<void> {
53
+ await upsertLinearInstallation({
54
+ connectionId: input.connectionId,
55
+ organizationId: input.organizationId,
56
+ organizationUrlKey: 'acme',
57
+ appUserId: 'app-user-1',
58
+ scopes: ['read', 'write'],
59
+ status: 'installed',
60
+ });
61
+ }
62
+
63
+ describe('Linear webhook processor', () => {
64
+ beforeEach(async () => {
65
+ await db().delete(linearInstallations);
66
+ });
67
+
68
+ it('accepts a delayed request that was valid when the adapter received it', async () => {
69
+ const connection = fakeConnection();
70
+ const receivedAt = new Date(Date.now() - 10 * 60_000).toISOString();
71
+ const webhookTimestamp = new Date(receivedAt).getTime();
72
+ const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
73
+ const processor = createLinearWebhookProcessor({
74
+ coreDb: db,
75
+ publishIntegrationEventReceived,
76
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
77
+ getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
78
+ });
79
+ await seedInstallation({connectionId: connection.id, organizationId: 'org-delayed'});
80
+ const request = signedRequest({
81
+ receivedAt,
82
+ body: {
83
+ action: 'create',
84
+ type: 'Issue',
85
+ organizationId: 'org-delayed',
86
+ webhookTimestamp,
87
+ data: {id: 'issue-1'},
88
+ },
89
+ });
90
+
91
+ const result = await processor.process(request);
92
+
93
+ expect(result).toMatchObject({outcome: 'processed'});
94
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it('discards a request that was stale when the adapter received it', async () => {
98
+ const receivedAt = new Date().toISOString();
99
+ const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
100
+ const processor = createLinearWebhookProcessor({
101
+ coreDb: db,
102
+ publishIntegrationEventReceived,
103
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
104
+ getIntegrationConnectionById: vi.fn(),
105
+ });
106
+ const request = signedRequest({
107
+ receivedAt,
108
+ body: {
109
+ action: 'create',
110
+ type: 'Issue',
111
+ organizationId: 'org-stale',
112
+ webhookTimestamp: new Date(receivedAt).getTime() - 60_001,
113
+ data: {id: 'issue-1'},
114
+ },
115
+ });
116
+
117
+ const result = await processor.process(request);
118
+
119
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'stale_at_receipt'});
120
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('records and drops an unsupported signed event', async () => {
124
+ const connection = fakeConnection();
125
+ const receivedAt = new Date().toISOString();
126
+ const deliveryId = randomUUID();
127
+ const recordDeliveryOnly = vi.fn(() => Promise.resolve());
128
+ const processor = createLinearWebhookProcessor({
129
+ coreDb: db,
130
+ publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
131
+ recordDeliveryOnly,
132
+ getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
133
+ });
134
+ await seedInstallation({connectionId: connection.id, organizationId: 'org-unsupported'});
135
+ const request = signedRequest({
136
+ deliveryId,
137
+ receivedAt,
138
+ body: {
139
+ action: 'create',
140
+ type: 'Reaction',
141
+ organizationId: 'org-unsupported',
142
+ webhookTimestamp: new Date(receivedAt).getTime(),
143
+ data: {id: 'reaction-1'},
144
+ },
145
+ });
146
+
147
+ const result = await processor.process(request);
148
+
149
+ expect(result).toEqual({outcome: 'discarded', reason: 'unsupported_event', deliveryId});
150
+ expect(recordDeliveryOnly).toHaveBeenCalledWith(
151
+ expect.objectContaining({provider: 'linear', deliveryId}),
152
+ );
153
+ });
154
+ });
@@ -0,0 +1,127 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {
3
+ decodeWebhookBody,
4
+ type GetIntegrationConnectionByIdFn,
5
+ type PublishIntegrationEventReceivedFn,
6
+ type RecordDeliveryOnlyFn,
7
+ type StoredWebhookRequest,
8
+ type WebhookProcessingResult,
9
+ } from '@shipfox/api-integration-core-dto';
10
+ import {
11
+ LINEAR_PROVIDER,
12
+ linearWebhookBaseEnvelopeSchema,
13
+ } from '@shipfox/api-integration-linear-dto';
14
+ import {verifyHexHmacSignature} from '@shipfox/node-fastify';
15
+ import {logger} from '@shipfox/node-opentelemetry';
16
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
17
+ import {config} from '#config.js';
18
+ import {handleLinearWebhook} from '#core/webhook.js';
19
+
20
+ const DELIVERY_HEADER = 'linear-delivery';
21
+ const EVENT_HEADER = 'linear-event';
22
+ const SIGNATURE_HEADER = 'linear-signature';
23
+ const WEBHOOK_REPLAY_WINDOW_MS = 60_000;
24
+
25
+ export interface CreateLinearWebhookProcessorOptions {
26
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
27
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
28
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
29
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
30
+ }
31
+
32
+ export interface LinearWebhookProcessor {
33
+ process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
34
+ }
35
+
36
+ export function createLinearWebhookProcessor(
37
+ options: CreateLinearWebhookProcessorOptions,
38
+ ): LinearWebhookProcessor {
39
+ return {process: (request) => processLinearWebhookRequest(options, request)};
40
+ }
41
+
42
+ async function processLinearWebhookRequest(
43
+ options: CreateLinearWebhookProcessorOptions,
44
+ request: StoredWebhookRequest,
45
+ ): Promise<WebhookProcessingResult> {
46
+ if (request.route_id !== 'linear') {
47
+ throw new Error(`Linear processor cannot process ${request.route_id} requests`);
48
+ }
49
+
50
+ const deliveryId = request.headers[DELIVERY_HEADER];
51
+ const event = request.headers[EVENT_HEADER];
52
+ const signature = request.headers[SIGNATURE_HEADER];
53
+ if (!deliveryId || !event || !signature) {
54
+ return {outcome: 'discarded', reason: 'missing_required_input'};
55
+ }
56
+
57
+ const rawBody = Buffer.from(decodeWebhookBody(request.body));
58
+ if (
59
+ !verifyHexHmacSignature({
60
+ rawBody,
61
+ signature,
62
+ secret: config.LINEAR_WEBHOOK_SIGNING_SECRET,
63
+ })
64
+ ) {
65
+ return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};
66
+ }
67
+
68
+ let rawPayload: unknown;
69
+ try {
70
+ rawPayload = JSON.parse(rawBody.toString('utf8'));
71
+ } catch (error) {
72
+ logger().warn({deliveryId, err: error}, 'linear webhook payload JSON parse failed');
73
+ return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};
74
+ }
75
+
76
+ const payload = linearWebhookBaseEnvelopeSchema.safeParse(rawPayload);
77
+ if (!payload.success) {
78
+ logger().warn(
79
+ {deliveryId, issues: payload.error.issues},
80
+ 'linear webhook envelope failed schema validation',
81
+ );
82
+ await recordSignedDeliveryOnly(options, deliveryId);
83
+ return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
84
+ }
85
+
86
+ const receivedAt = new Date(request.received_at).getTime();
87
+ if (Math.abs(receivedAt - payload.data.webhookTimestamp) > WEBHOOK_REPLAY_WINDOW_MS) {
88
+ return {outcome: 'discarded', reason: 'stale_at_receipt', deliveryId};
89
+ }
90
+
91
+ if (event !== payload.data.type) {
92
+ logger().warn(
93
+ {deliveryId, event, type: payload.data.type},
94
+ 'linear webhook event header did not match payload type',
95
+ );
96
+ await recordSignedDeliveryOnly(options, deliveryId);
97
+ return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
98
+ }
99
+
100
+ const result = await options.coreDb().transaction(async (tx) =>
101
+ handleLinearWebhook({
102
+ tx,
103
+ deliveryId,
104
+ payload: payload.data,
105
+ rawPayload,
106
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
107
+ recordDeliveryOnly: options.recordDeliveryOnly,
108
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
109
+ }),
110
+ );
111
+
112
+ if (result.outcome === 'published') return {outcome: 'processed', deliveryId};
113
+ if (result.outcome === 'duplicate') return {outcome: 'duplicate', deliveryId};
114
+ if (result.outcome === 'unsupported-event') {
115
+ return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
116
+ }
117
+ return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};
118
+ }
119
+
120
+ async function recordSignedDeliveryOnly(
121
+ options: Pick<CreateLinearWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,
122
+ deliveryId: string,
123
+ ): Promise<void> {
124
+ await options.coreDb().transaction(async (tx) => {
125
+ await options.recordDeliveryOnly({tx, provider: LINEAR_PROVIDER, deliveryId});
126
+ });
127
+ }
package/src/index.test.ts CHANGED
@@ -17,4 +17,15 @@ describe('createLinearIntegrationProvider', () => {
17
17
 
18
18
  expect(provider.adapters.agent_tools?.catalog()).toBe(linearAgentToolCatalog);
19
19
  });
20
+
21
+ it('exposes explicit connection cleanup without requiring routes', () => {
22
+ const deleteConnectionRecords = vi.fn(() => Promise.resolve());
23
+ const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
24
+ const provider = createLinearIntegrationProvider({
25
+ cleanup: {deleteConnectionRecords, deleteConnectionSecrets},
26
+ });
27
+
28
+ expect(provider.deleteConnectionRecords).toBe(deleteConnectionRecords);
29
+ expect(provider.deleteConnectionSecrets).toBe(deleteConnectionSecrets);
30
+ });
20
31
  });
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import {createLinearApiClient, type LinearApiClient} from '#api/client.js';
3
3
  import {config} from '#config.js';
4
4
  import {LinearAgentToolsProvider} from '#core/agent-tools-provider.js';
5
5
  import type {LinearTokenStore} from '#core/tokens.js';
6
+ import {createLinearWebhookProcessor} from '#core/webhook-processor.js';
6
7
  import {closeDb, db} from '#db/db.js';
7
8
  import {getLinearInstallationByConnectionId} from '#db/installations.js';
8
9
  import {migrationsPath} from '#db/migrations.js';
@@ -67,6 +68,11 @@ export {
67
68
  } from '#core/tokens.js';
68
69
  export type {HandleLinearWebhookOutcome, HandleLinearWebhookParams} from '#core/webhook.js';
69
70
  export {handleLinearWebhook} from '#core/webhook.js';
71
+ export type {
72
+ CreateLinearWebhookProcessorOptions,
73
+ LinearWebhookProcessor,
74
+ } from '#core/webhook-processor.js';
75
+ export {createLinearWebhookProcessor} from '#core/webhook-processor.js';
70
76
  export type {
71
77
  LinearInstallation,
72
78
  LinearInstallationStatus,
@@ -74,6 +80,7 @@ export type {
74
80
  UpsertLinearInstallationParams,
75
81
  } from '#db/installations.js';
76
82
  export {
83
+ deleteLinearInstallationByConnectionId,
77
84
  getLinearInstallationByConnectionId,
78
85
  getLinearInstallationByOrganizationId,
79
86
  markLinearInstallationRevoked,
@@ -97,6 +104,15 @@ export interface CreateLinearIntegrationProviderOptions {
97
104
  }
98
105
  | undefined;
99
106
  getLinearInstallationByConnectionId?: typeof getLinearInstallationByConnectionId | undefined;
107
+ cleanup?:
108
+ | {
109
+ deleteConnectionRecords?: (
110
+ connection: {id: string},
111
+ options: {tx: unknown},
112
+ ) => Promise<void>;
113
+ deleteConnectionSecrets?: (connection: {id: string; workspaceId: string}) => Promise<void>;
114
+ }
115
+ | undefined;
100
116
  routes?:
101
117
  | (Omit<CreateLinearIntegrationRoutesOptions, 'linear' | 'connectionCapabilities'> &
102
118
  Partial<CreateLinearWebhookRoutesOptions>)
@@ -114,6 +130,10 @@ export function createLinearIntegrationProvider(
114
130
  agent_tools: new LinearAgentToolsProvider(options.agentTools),
115
131
  }
116
132
  : {};
133
+ const webhookProcessor =
134
+ options.routes && hasLinearWebhookRoutesOptions(options.routes)
135
+ ? createLinearWebhookProcessor(options.routes)
136
+ : undefined;
117
137
 
118
138
  const routes = options.routes
119
139
  ? [
@@ -124,8 +144,8 @@ export function createLinearIntegrationProvider(
124
144
  }),
125
145
  ]
126
146
  : [];
127
- if (options.routes && hasLinearWebhookRoutesOptions(options.routes)) {
128
- routes.push(createLinearWebhookRoutes(options.routes));
147
+ if (options.routes && hasLinearWebhookRoutesOptions(options.routes) && webhookProcessor) {
148
+ routes.push(createLinearWebhookRoutes({...options.routes, processor: webhookProcessor}));
129
149
  }
130
150
 
131
151
  return {
@@ -137,7 +157,11 @@ export function createLinearIntegrationProvider(
137
157
  if (!installation?.organizationUrlKey) return undefined;
138
158
  return `https://linear.app/${encodeURIComponent(installation.organizationUrlKey)}/settings`;
139
159
  },
160
+ ...options.cleanup,
140
161
  routes,
162
+ webhookProcessors: webhookProcessor
163
+ ? [{routeIds: ['linear'] as const, processor: webhookProcessor}]
164
+ : undefined,
141
165
  };
142
166
  }
143
167
 
@@ -16,12 +16,7 @@ import {verifyLinearInstallState} from '#core/state.js';
16
16
  import type {LinearTokenStore} from '#core/tokens.js';
17
17
  import {createLinearIntegrationProvider} from '#index.js';
18
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);
19
+ const requireWorkspaceMembershipMock = vi.fn(() => Promise.resolve());
25
20
  let authenticatedMemberships: UserContextMembership[] = [];
26
21
 
27
22
  const fakeUserAuth: AuthMethod = {
@@ -120,6 +115,7 @@ async function createTestApp(options: CreateTestAppOptions = {}): Promise<Fastif
120
115
  ),
121
116
  disconnectLinearInstallation:
122
117
  options.disconnectLinearInstallation ?? vi.fn(() => Promise.resolve()),
118
+ requireActiveWorkspaceMembership: requireWorkspaceMembershipMock,
123
119
  },
124
120
  });
125
121
  const app = await createApp({
@@ -7,7 +7,6 @@ import {
7
7
  linearCallbackQuerySchema,
8
8
  linearCallbackResponseSchema,
9
9
  } from '@shipfox/api-integration-linear-dto';
10
- import {requireWorkspaceMembership} from '@shipfox/api-workspaces';
11
10
  import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
12
11
  import type {LinearApiClient} from '#api/client.js';
13
12
  import {config} from '#config.js';
@@ -33,6 +32,11 @@ export interface CreateLinearIntegrationRoutesOptions {
33
32
  ) => Promise<IntegrationConnection<'linear'>>;
34
33
  disconnectLinearInstallation: (input: {connectionId: string}) => Promise<void>;
35
34
  connectionCapabilities: IntegrationCapability[];
35
+ requireActiveWorkspaceMembership?: (input: {
36
+ workspaceId: string;
37
+ userId: string;
38
+ memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
39
+ }) => Promise<unknown>;
36
40
  }
37
41
 
38
42
  export function createLinearIntegrationRoutes({
@@ -42,6 +46,7 @@ export function createLinearIntegrationRoutes({
42
46
  connectLinearInstallation,
43
47
  disconnectLinearInstallation,
44
48
  connectionCapabilities,
49
+ requireActiveWorkspaceMembership,
45
50
  }: CreateLinearIntegrationRoutesOptions): RouteGroup {
46
51
  const createInstallRoute = defineRoute({
47
52
  method: 'POST',
@@ -95,7 +100,8 @@ export function createLinearIntegrationRoutes({
95
100
  errorDescription: query.error_description,
96
101
  sessionUserId: actor.userId,
97
102
  sessionMemberships: actor.memberships,
98
- requireWorkspaceMembership,
103
+ requireWorkspaceMembership:
104
+ requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,
99
105
  });
100
106
  }
101
107
  const connection = await handleLinearCallback({
@@ -105,7 +111,8 @@ export function createLinearIntegrationRoutes({
105
111
  state: query.state,
106
112
  sessionUserId: actor.userId,
107
113
  sessionMemberships: actor.memberships,
108
- requireWorkspaceMembership,
114
+ requireWorkspaceMembership:
115
+ requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,
109
116
  getExistingLinearConnection,
110
117
  connectLinearInstallation,
111
118
  disconnectLinearInstallation,
@@ -121,6 +128,14 @@ export function createLinearIntegrationRoutes({
121
128
  };
122
129
  }
123
130
 
131
+ function unavailableWorkspaceMembershipCheck(_input: {
132
+ workspaceId: string;
133
+ userId: string;
134
+ memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
135
+ }): Promise<never> {
136
+ return Promise.reject(new Error('Workspaces inter-module client is not configured'));
137
+ }
138
+
124
139
  function isLinearOAuthErrorCallback(
125
140
  query: LinearCallbackQueryDto,
126
141
  ): query is LinearCallbackQueryDto & {