@shipfox/api-integration-core 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 (87) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +100 -0
  3. package/dist/core/entities/provider.d.ts +7 -2
  4. package/dist/core/entities/provider.d.ts.map +1 -1
  5. package/dist/core/entities/provider.js.map +1 -1
  6. package/dist/core/errors.d.ts +7 -0
  7. package/dist/core/errors.d.ts.map +1 -1
  8. package/dist/core/errors.js +8 -2
  9. package/dist/core/errors.js.map +1 -1
  10. package/dist/db/webhook-deliveries.d.ts +4 -0
  11. package/dist/db/webhook-deliveries.d.ts.map +1 -1
  12. package/dist/db/webhook-deliveries.js +10 -2
  13. package/dist/db/webhook-deliveries.js.map +1 -1
  14. package/dist/index.d.ts +19 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +51 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/presentation/inter-module.d.ts +9 -0
  19. package/dist/presentation/inter-module.d.ts.map +1 -0
  20. package/dist/presentation/inter-module.js +121 -0
  21. package/dist/presentation/inter-module.js.map +1 -0
  22. package/dist/presentation/routes/agent-tools-gateway/index.d.ts +1 -0
  23. package/dist/presentation/routes/agent-tools-gateway/index.d.ts.map +1 -1
  24. package/dist/presentation/routes/agent-tools-gateway/index.js +1 -0
  25. package/dist/presentation/routes/agent-tools-gateway/index.js.map +1 -1
  26. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts +5 -2
  27. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts.map +1 -1
  28. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js +53 -17
  29. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js.map +1 -1
  30. package/dist/presentation/routes/index.d.ts +2 -3
  31. package/dist/presentation/routes/index.d.ts.map +1 -1
  32. package/dist/presentation/routes/index.js +3 -3
  33. package/dist/presentation/routes/index.js.map +1 -1
  34. package/dist/presentation/routes/manage-connections.d.ts +1 -1
  35. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  36. package/dist/presentation/routes/manage-connections.js +28 -3
  37. package/dist/presentation/routes/manage-connections.js.map +1 -1
  38. package/dist/providers/gitea.d.ts.map +1 -1
  39. package/dist/providers/gitea.js +10 -8
  40. package/dist/providers/gitea.js.map +1 -1
  41. package/dist/providers/github.d.ts.map +1 -1
  42. package/dist/providers/github.js +18 -13
  43. package/dist/providers/github.js.map +1 -1
  44. package/dist/providers/jira.d.ts.map +1 -1
  45. package/dist/providers/jira.js +113 -3
  46. package/dist/providers/jira.js.map +1 -1
  47. package/dist/providers/linear.d.ts.map +1 -1
  48. package/dist/providers/linear.js +35 -16
  49. package/dist/providers/linear.js.map +1 -1
  50. package/dist/providers/sentry.d.ts.map +1 -1
  51. package/dist/providers/sentry.js +13 -11
  52. package/dist/providers/sentry.js.map +1 -1
  53. package/dist/providers/slack.d.ts.map +1 -1
  54. package/dist/providers/slack.js +36 -16
  55. package/dist/providers/slack.js.map +1 -1
  56. package/dist/providers/types.d.ts +12 -0
  57. package/dist/providers/types.d.ts.map +1 -1
  58. package/dist/providers/types.js.map +1 -1
  59. package/dist/providers/webhook.d.ts.map +1 -1
  60. package/dist/providers/webhook.js +11 -9
  61. package/dist/providers/webhook.js.map +1 -1
  62. package/dist/tsconfig.test.tsbuildinfo +1 -1
  63. package/package.json +33 -38
  64. package/src/core/entities/provider.ts +7 -1
  65. package/src/core/errors.ts +12 -2
  66. package/src/db/webhook-deliveries.test.ts +11 -0
  67. package/src/db/webhook-deliveries.ts +12 -3
  68. package/src/index.test.ts +112 -0
  69. package/src/index.ts +104 -10
  70. package/src/presentation/inter-module.ts +155 -0
  71. package/src/presentation/routes/agent-tools-gateway/index.ts +1 -0
  72. package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.test.ts +36 -0
  73. package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.ts +64 -22
  74. package/src/presentation/routes/index.ts +5 -6
  75. package/src/presentation/routes/manage-connections.test.ts +113 -0
  76. package/src/presentation/routes/manage-connections.ts +26 -2
  77. package/src/providers/gitea.ts +11 -8
  78. package/src/providers/github.ts +17 -11
  79. package/src/providers/jira.ts +155 -7
  80. package/src/providers/linear.test.ts +136 -0
  81. package/src/providers/linear.ts +33 -13
  82. package/src/providers/sentry.ts +15 -12
  83. package/src/providers/slack.test.ts +139 -0
  84. package/src/providers/slack.ts +39 -14
  85. package/src/providers/types.ts +15 -0
  86. package/src/providers/webhook.ts +11 -9
  87. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,155 @@
1
+ import {integrationsInterModuleContract} from '@shipfox/api-integration-core-dto';
2
+ import {
3
+ createInterModuleKnownError,
4
+ defineInterModulePresentation,
5
+ type InterModuleMethodContract,
6
+ type InterModulePresentation,
7
+ } from '@shipfox/inter-module';
8
+ import {
9
+ buildAgentToolCatalogs,
10
+ buildAgentToolSelectionCatalogs,
11
+ createWorkspaceConnectionSnapshotLoader,
12
+ } from '#core/agent-tool-selection.js';
13
+ import {
14
+ IntegrationCapabilityUnavailableError,
15
+ IntegrationCheckoutUnsupportedError,
16
+ IntegrationConnectionInactiveError,
17
+ IntegrationConnectionNotFoundError,
18
+ IntegrationConnectionWorkspaceMismatchError,
19
+ IntegrationProviderError,
20
+ IntegrationProviderUnavailableError,
21
+ } from '#core/errors.js';
22
+ import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
23
+ import type {IntegrationSourceControlService} from '#core/source-control-service.js';
24
+ import {getIntegrationConnectionById} from '#db/connections.js';
25
+
26
+ export function createIntegrationsInterModulePresentation(params: {
27
+ registry: IntegrationProviderRegistry;
28
+ sourceControl: IntegrationSourceControlService;
29
+ }): InterModulePresentation<typeof integrationsInterModuleContract> {
30
+ const contract = integrationsInterModuleContract;
31
+ return defineInterModulePresentation(contract, {
32
+ resolveSourceRepository: async (input) =>
33
+ await known(contract.methods.resolveSourceRepository, input, async () => {
34
+ const resolved = await params.sourceControl.resolveRepository(input);
35
+ return {
36
+ connection: {
37
+ id: resolved.connection.id,
38
+ provider: resolved.connection.provider,
39
+ slug: resolved.connection.slug,
40
+ },
41
+ repository: resolved.repository,
42
+ };
43
+ }),
44
+ listSourceFiles: async (input) =>
45
+ await known(
46
+ contract.methods.listSourceFiles,
47
+ input,
48
+ async () => await params.sourceControl.listFiles(input),
49
+ ),
50
+ fetchSourceFile: async (input) =>
51
+ await known(
52
+ contract.methods.fetchSourceFile,
53
+ input,
54
+ async () => await params.sourceControl.fetchFile(input),
55
+ ),
56
+ createCheckoutSpec: async (input) =>
57
+ await known(contract.methods.createCheckoutSpec, input, async () => {
58
+ const spec = await params.sourceControl.createCheckoutSpec(input);
59
+ return {
60
+ repositoryUrl: spec.repositoryUrl,
61
+ ref: spec.ref,
62
+ ...(spec.credentials
63
+ ? {
64
+ credentials: {
65
+ ...spec.credentials,
66
+ expiresAt: spec.credentials.expiresAt.toISOString(),
67
+ },
68
+ }
69
+ : {}),
70
+ ...(spec.gitAuthor === undefined ? {} : {gitAuthor: spec.gitAuthor}),
71
+ };
72
+ }),
73
+ getAgentToolsContext: async (input) =>
74
+ await known(contract.methods.getAgentToolsContext, input, async () => {
75
+ const [selectionCatalogs, catalogs, snapshot, defaultConnection] = await Promise.all([
76
+ buildAgentToolSelectionCatalogs(params.registry),
77
+ buildAgentToolCatalogs(params.registry),
78
+ createWorkspaceConnectionSnapshotLoader(params.registry)(input.workspaceId),
79
+ getIntegrationConnectionById(input.defaultConnectionId),
80
+ ]);
81
+ return {
82
+ selectionCatalogs: [...selectionCatalogs].map(([provider, value]) => ({
83
+ provider,
84
+ selectors: value.selectors.map((selector) => ({...selector})),
85
+ })),
86
+ catalogs: [...catalogs].map(([provider, tools]) => ({
87
+ provider,
88
+ tools: tools.map(({methods, ...tool}) => ({
89
+ ...tool,
90
+ ...(methods === undefined ? {} : {methods: methods.map((method) => ({...method}))}),
91
+ })),
92
+ })),
93
+ workspaceConnections: [...snapshot].map(([slug, value]) => ({
94
+ slug,
95
+ ...value,
96
+ capabilities: [...value.capabilities],
97
+ })),
98
+ defaultConnection: defaultConnection
99
+ ? {
100
+ id: defaultConnection.id,
101
+ slug: defaultConnection.slug,
102
+ provider: defaultConnection.provider,
103
+ }
104
+ : null,
105
+ };
106
+ }),
107
+ });
108
+ }
109
+
110
+ async function known<Output>(
111
+ method: InterModuleMethodContract,
112
+ input: {connectionId?: string; defaultConnectionId?: string},
113
+ operation: () => Promise<Output>,
114
+ ): Promise<Output> {
115
+ try {
116
+ return await operation();
117
+ } catch (error) {
118
+ throw mapError(method, input, error);
119
+ }
120
+ }
121
+ function mapError(
122
+ method: InterModuleMethodContract,
123
+ input: {connectionId?: string; defaultConnectionId?: string},
124
+ error: unknown,
125
+ ): unknown {
126
+ if (error instanceof IntegrationConnectionNotFoundError)
127
+ return createInterModuleKnownError(method, 'connection-not-found', {
128
+ connectionId: input.connectionId ?? input.defaultConnectionId,
129
+ });
130
+ if (error instanceof IntegrationConnectionInactiveError)
131
+ return createInterModuleKnownError(method, 'connection-inactive', {
132
+ connectionId: input.connectionId,
133
+ });
134
+ if (error instanceof IntegrationConnectionWorkspaceMismatchError)
135
+ return createInterModuleKnownError(method, 'connection-workspace-mismatch', {
136
+ connectionId: input.connectionId,
137
+ });
138
+ if (error instanceof IntegrationProviderUnavailableError)
139
+ return createInterModuleKnownError(method, 'provider-unavailable', {provider: error.provider});
140
+ if (error instanceof IntegrationCapabilityUnavailableError)
141
+ return createInterModuleKnownError(method, 'capability-unavailable', {
142
+ provider: error.provider,
143
+ capability: error.capability,
144
+ });
145
+ if (error instanceof IntegrationCheckoutUnsupportedError)
146
+ return createInterModuleKnownError(method, 'checkout-unsupported', {provider: error.provider});
147
+ if (error instanceof IntegrationProviderError)
148
+ return createInterModuleKnownError(method, 'provider-failure', {
149
+ reason: error.reason,
150
+ ...(error.retryAfterSeconds === undefined
151
+ ? {}
152
+ : {retryAfterSeconds: error.retryAfterSeconds}),
153
+ });
154
+ return error;
155
+ }
@@ -13,6 +13,7 @@ import {
13
13
  } from './resolve-authorized-tools.js';
14
14
 
15
15
  export type {LeasedAgentStepLoader} from './resolve-authorized-tools.js';
16
+ export {createWorkflowsLeasedAgentStepLoader} from './resolve-authorized-tools.js';
16
17
 
17
18
  export interface CreateAgentToolsGatewayRoutesParams {
18
19
  loadLeasedAgentStep: LeasedAgentStepLoader;
@@ -1,4 +1,6 @@
1
1
  import {setLeasedJobContext} from '@shipfox/api-auth-context';
2
+ import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
3
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
2
4
  import {createIntegrationProviderRegistry} from '#core/providers/registry.js';
3
5
  import {
4
6
  agentStepConfig,
@@ -9,6 +11,7 @@ import {
9
11
  registryWithAgentTools,
10
12
  } from '#test/agent-tools-gateway-helpers.js';
11
13
  import {
14
+ createWorkflowsLeasedAgentStepLoader,
12
15
  mcpToolName,
13
16
  narrowMethodEnum,
14
17
  resolveAuthorizedIntegrationTools,
@@ -16,6 +19,39 @@ import {
16
19
  } from './resolve-authorized-tools.js';
17
20
 
18
21
  describe('resolveAuthorizedIntegrationTools', () => {
22
+ it.each([
23
+ ['lease-not-active', 404],
24
+ ['step-not-found', 404],
25
+ ['job-not-found', 404],
26
+ ['step-attempt-mismatch', 409],
27
+ ['step-not-running', 409],
28
+ ['leased-step-not-agent', 409],
29
+ ['agent-step-config-invalid', 409],
30
+ ] as const)('maps %s from Workflows to HTTP %i', async (code, status) => {
31
+ const request = {};
32
+ const lease = leaseContext();
33
+ setLeasedJobContext(request, lease);
34
+ const workflows = {
35
+ getLeasedAgentToolContext: () =>
36
+ Promise.reject(
37
+ createInterModuleKnownError(
38
+ workflowsInterModuleContract.methods.getLeasedAgentToolContext,
39
+ code,
40
+ {},
41
+ ),
42
+ ),
43
+ };
44
+ const loadLeasedAgentStep = createWorkflowsLeasedAgentStepLoader(workflows as never);
45
+
46
+ const error = await loadLeasedAgentStep({
47
+ request,
48
+ stepId: lease.currentStepId as string,
49
+ attempt: lease.currentStepAttempt as number,
50
+ }).catch((error: unknown) => error);
51
+
52
+ expect(error).toMatchObject({code, status});
53
+ });
54
+
19
55
  it('resolves namespaced tools with live descriptions and Anthropic-compatible method schemas', async () => {
20
56
  const request = {};
21
57
  const lease = leaseContext();
@@ -4,6 +4,11 @@ import {
4
4
  materializedAgentStepConfigSchema,
5
5
  } from '@shipfox/api-agent-dto';
6
6
  import {requireLeasedJobContext} from '@shipfox/api-auth-context';
7
+ import {
8
+ type WorkflowsModuleClient,
9
+ workflowsInterModuleContract,
10
+ } from '@shipfox/api-workflows-dto/inter-module';
11
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
7
12
  import {ClientError} from '@shipfox/node-fastify';
8
13
  import type {IntegrationConnection} from '#core/entities/connection.js';
9
14
  import type {IntegrationProviderKind} from '#core/entities/provider.js';
@@ -16,10 +21,49 @@ export type LeasedAgentStepLoader = (params: {
16
21
  stepId: string;
17
22
  attempt: number;
18
23
  }) => Promise<{
19
- step: {type: string; config: Record<string, unknown>};
20
24
  workspaceId: string;
25
+ integrations?: MaterializedAgentIntegrationConfigDto[];
26
+ step?: {type: string; config: Record<string, unknown>};
21
27
  }>;
22
28
 
29
+ export function createWorkflowsLeasedAgentStepLoader(
30
+ workflows: WorkflowsModuleClient,
31
+ ): LeasedAgentStepLoader {
32
+ return async ({request, stepId, attempt}) => {
33
+ const leasedJob = requireLeasedJobContext(request);
34
+ try {
35
+ const context = await workflows.getLeasedAgentToolContext({
36
+ jobId: leasedJob.jobId,
37
+ jobExecutionId: leasedJob.jobExecutionId,
38
+ runnerSessionId: leasedJob.runnerSessionId,
39
+ stepId,
40
+ attempt,
41
+ });
42
+ return {
43
+ workspaceId: context.workspaceId,
44
+ integrations: context.integrations,
45
+ };
46
+ } catch (error) {
47
+ throw toLeasedAgentToolClientError(error);
48
+ }
49
+ };
50
+ }
51
+
52
+ function toLeasedAgentToolClientError(error: unknown): unknown {
53
+ const method = workflowsInterModuleContract.methods.getLeasedAgentToolContext;
54
+ if (!isInterModuleKnownError(method, error)) return error;
55
+
56
+ const status = [
57
+ 'step-attempt-mismatch',
58
+ 'step-not-running',
59
+ 'leased-step-not-agent',
60
+ 'agent-step-config-invalid',
61
+ ].includes(error.code)
62
+ ? 409
63
+ : 404;
64
+ return new ClientError(error.code.replaceAll('-', ' '), error.code, {status});
65
+ }
66
+
23
67
  export interface AuthorizedIntegrationTool {
24
68
  mcpName: string;
25
69
  integration: MaterializedAgentIntegrationConfigDto;
@@ -49,18 +93,13 @@ export async function resolveAuthorizedIntegrationTools(
49
93
  });
50
94
  }
51
95
 
52
- const {step, workspaceId} = await params.loadLeasedAgentStep({
96
+ const loaded = await params.loadLeasedAgentStep({
53
97
  request: params.request,
54
98
  stepId: leasedJob.currentStepId,
55
99
  attempt: leasedJob.currentStepAttempt,
56
100
  });
57
- if (step.type !== 'agent') {
58
- throw new ClientError('Current leased step is not an agent step', 'leased-step-not-agent', {
59
- status: 409,
60
- });
61
- }
62
-
63
- const integrations = parseAgentIntegrations(step.config);
101
+ const workspaceId = loaded.workspaceId;
102
+ const integrations = loaded.integrations ?? legacyIntegrations(loaded.step);
64
103
  const authorizedTools: AuthorizedIntegrationToolMap = new Map();
65
104
 
66
105
  for (const integration of integrations) {
@@ -102,6 +141,22 @@ export async function resolveAuthorizedIntegrationTools(
102
141
  return authorizedTools;
103
142
  }
104
143
 
144
+ function legacyIntegrations(step: {type: string; config: Record<string, unknown>} | undefined) {
145
+ if (step?.type !== 'agent') {
146
+ throw new ClientError('Current leased step is not an agent step', 'leased-step-not-agent', {
147
+ status: 409,
148
+ });
149
+ }
150
+ try {
151
+ return materializedAgentStepConfigSchema.parse(step.config).integrations ?? [];
152
+ } catch (error) {
153
+ throw new ClientError('Agent step config is invalid', 'agent-step-config-invalid', {
154
+ status: 409,
155
+ cause: error,
156
+ });
157
+ }
158
+ }
159
+
105
160
  export function sanitizeSlug(slug: string): string {
106
161
  return slug.replaceAll('-', '_');
107
162
  }
@@ -132,19 +187,6 @@ function toolInputSchema(tool: MaterializedAgentIntegrationToolConfigDto): Agent
132
187
  return narrowMethodEnum(tool.inputSchema, authorizedMethods);
133
188
  }
134
189
 
135
- function parseAgentIntegrations(
136
- config: Record<string, unknown>,
137
- ): MaterializedAgentIntegrationConfigDto[] {
138
- try {
139
- return materializedAgentStepConfigSchema.parse(config).integrations ?? [];
140
- } catch (error) {
141
- throw new ClientError('Agent step config is invalid', 'agent-step-config-invalid', {
142
- status: 409,
143
- cause: error,
144
- });
145
- }
146
- }
147
-
148
190
  async function loadAuthorizedConnection(params: {
149
191
  integration: MaterializedAgentIntegrationConfigDto;
150
192
  workspaceId: string;
@@ -1,10 +1,11 @@
1
+ import type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';
1
2
  import type {RouteExport} from '@shipfox/node-fastify';
2
3
  import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
3
4
  import type {IntegrationSourceControlService} from '#core/source-control-service.js';
4
5
  import type {GetIntegrationConnectionByIdFn} from '#db/connections.js';
5
6
  import {
6
7
  createAgentToolsGatewayRoutes,
7
- type LeasedAgentStepLoader,
8
+ createWorkflowsLeasedAgentStepLoader,
8
9
  } from './agent-tools-gateway/index.js';
9
10
  import {createListIntegrationConnectionsRoute} from './list-connections.js';
10
11
  import {createListIntegrationProvidersRoute} from './list-providers.js';
@@ -14,12 +15,10 @@ import {
14
15
  createUpdateIntegrationConnectionRoute,
15
16
  } from './manage-connections.js';
16
17
 
17
- export type {LeasedAgentStepLoader} from './agent-tools-gateway/index.js';
18
-
19
18
  export interface CreateIntegrationRoutesOptions {
20
19
  agentTools?:
21
20
  | {
22
- loadLeasedAgentStep: LeasedAgentStepLoader;
21
+ workflows: WorkflowsModuleClient;
23
22
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
24
23
  }
25
24
  | undefined;
@@ -35,7 +34,7 @@ export function createIntegrationRoutes(
35
34
  ? [
36
35
  createAgentToolsGatewayRoutes({
37
36
  registry,
38
- loadLeasedAgentStep: options.agentTools.loadLeasedAgentStep,
37
+ loadLeasedAgentStep: createWorkflowsLeasedAgentStepLoader(options.agentTools.workflows),
39
38
  getIntegrationConnectionById: options.agentTools.getIntegrationConnectionById,
40
39
  }),
41
40
  ]
@@ -45,7 +44,7 @@ export function createIntegrationRoutes(
45
44
  createListIntegrationProvidersRoute(registry),
46
45
  createListIntegrationConnectionsRoute(registry),
47
46
  createUpdateIntegrationConnectionRoute(registry),
48
- createDeleteIntegrationConnectionRoute(),
47
+ createDeleteIntegrationConnectionRoute(registry),
49
48
  createListRepositoriesRoute(sourceControl),
50
49
  ...agentToolsRoutes,
51
50
  ...providerRoutes,
@@ -110,6 +110,119 @@ describe('DELETE /integration-connections/:connectionId', () => {
110
110
  expect(reloaded).toBeUndefined();
111
111
  });
112
112
 
113
+ it('runs provider cleanup hooks while retaining ownership of the core row', async () => {
114
+ const deleteConnectionRecords = vi.fn(() => Promise.resolve());
115
+ const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
116
+ const app = await createTestApp([
117
+ sourceProvider({
118
+ provider: 'slack',
119
+ displayName: 'Slack',
120
+ adapters: {},
121
+ deleteConnectionRecords,
122
+ deleteConnectionSecrets,
123
+ }),
124
+ ]);
125
+ const connection = await upsertIntegrationConnection({
126
+ workspaceId: context.workspaceId,
127
+ provider: 'slack',
128
+ externalAccountId: 'T123',
129
+ slug: 'slack_acme',
130
+ displayName: 'Slack Acme',
131
+ });
132
+
133
+ const res = await app.inject({
134
+ method: 'DELETE',
135
+ url: `/integration-connections/${connection.id}`,
136
+ headers: {authorization: 'Bearer user'},
137
+ });
138
+
139
+ expect(res.statusCode).toBe(204);
140
+ expect(deleteConnectionRecords).toHaveBeenCalledWith(connection, {
141
+ tx: expect.anything(),
142
+ });
143
+ expect(deleteConnectionSecrets).toHaveBeenCalledWith(connection);
144
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
145
+ });
146
+
147
+ it('keeps the connection when provider record cleanup fails', async () => {
148
+ const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
149
+ const app = await createTestApp([
150
+ sourceProvider({
151
+ provider: 'slack',
152
+ displayName: 'Slack',
153
+ adapters: {},
154
+ deleteConnectionRecords: () => Promise.reject(new Error('record cleanup failed')),
155
+ deleteConnectionSecrets,
156
+ }),
157
+ ]);
158
+ const connection = await upsertIntegrationConnection({
159
+ workspaceId: context.workspaceId,
160
+ provider: 'slack',
161
+ externalAccountId: 'T123',
162
+ slug: 'slack_acme',
163
+ displayName: 'Slack Acme',
164
+ });
165
+
166
+ const res = await app.inject({
167
+ method: 'DELETE',
168
+ url: `/integration-connections/${connection.id}`,
169
+ headers: {authorization: 'Bearer user'},
170
+ });
171
+
172
+ expect(res.statusCode).toBe(500);
173
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
174
+ id: connection.id,
175
+ });
176
+ expect(deleteConnectionSecrets).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it('deletes the connection when provider secret cleanup fails after commit', async () => {
180
+ const app = await createTestApp([
181
+ sourceProvider({
182
+ provider: 'slack',
183
+ displayName: 'Slack',
184
+ adapters: {},
185
+ deleteConnectionSecrets: () => Promise.reject(new Error('secret cleanup failed')),
186
+ }),
187
+ ]);
188
+ const connection = await upsertIntegrationConnection({
189
+ workspaceId: context.workspaceId,
190
+ provider: 'slack',
191
+ externalAccountId: 'T123',
192
+ slug: 'slack_acme',
193
+ displayName: 'Slack Acme',
194
+ });
195
+
196
+ const res = await app.inject({
197
+ method: 'DELETE',
198
+ url: `/integration-connections/${connection.id}`,
199
+ headers: {authorization: 'Bearer user'},
200
+ });
201
+
202
+ expect(res.statusCode).toBe(204);
203
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
204
+ });
205
+
206
+ it('deletes an unregistered provider connection without provider cleanup', async () => {
207
+ const app = await createTestApp([]);
208
+ const connection = await upsertIntegrationConnection({
209
+ workspaceId: context.workspaceId,
210
+ provider: 'slack',
211
+ externalAccountId: 'T123',
212
+ slug: 'slack_acme',
213
+ displayName: 'Slack Acme',
214
+ });
215
+
216
+ const res = await app.inject({
217
+ method: 'DELETE',
218
+ url: `/integration-connections/${connection.id}`,
219
+ headers: {authorization: 'Bearer user'},
220
+ });
221
+
222
+ expect(res.statusCode).toBe(204);
223
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
224
+ });
225
+
113
226
  it('returns not-found for a missing connection', async () => {
114
227
  const app = await createTestApp([sourceProvider()]);
115
228
 
@@ -11,6 +11,7 @@ import {
11
11
  getIntegrationConnectionById,
12
12
  updateIntegrationConnectionLifecycleStatus,
13
13
  } from '#db/connections.js';
14
+ import {db} from '#db/db.js';
14
15
  import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
15
16
 
16
17
  const connectionParamsSchema = z.object({
@@ -51,7 +52,7 @@ export function createUpdateIntegrationConnectionRoute(registry: IntegrationProv
51
52
  });
52
53
  }
53
54
 
54
- export function createDeleteIntegrationConnectionRoute() {
55
+ export function createDeleteIntegrationConnectionRoute(registry: IntegrationProviderRegistry) {
55
56
  return defineRoute({
56
57
  method: 'DELETE',
57
58
  path: '/integration-connections/:connectionId',
@@ -70,7 +71,30 @@ export function createDeleteIntegrationConnectionRoute() {
70
71
  }
71
72
 
72
73
  requireWorkspaceAccess({request, workspaceId: connection.workspaceId});
73
- await deleteIntegrationConnection({id: connection.id});
74
+ const provider = registry
75
+ .list()
76
+ .find((candidate) => candidate.provider === connection.provider);
77
+ const hasCleanupHooks =
78
+ provider?.deleteConnectionRecords !== undefined ||
79
+ provider?.deleteConnectionSecrets !== undefined;
80
+ if (!hasCleanupHooks) {
81
+ request.log.warn(
82
+ {connectionId: connection.id, provider: connection.provider},
83
+ 'Deleting integration connection without provider cleanup',
84
+ );
85
+ }
86
+ await db().transaction(async (tx) => {
87
+ await provider?.deleteConnectionRecords?.(connection, {tx});
88
+ await deleteIntegrationConnection({id: connection.id}, {tx});
89
+ });
90
+ try {
91
+ await provider?.deleteConnectionSecrets?.(connection);
92
+ } catch (error) {
93
+ request.log.error(
94
+ {connectionId: connection.id, provider: connection.provider, err: error},
95
+ 'Integration connection secret cleanup failed after connection deletion',
96
+ );
97
+ }
74
98
  reply.status(204);
75
99
  },
76
100
  });
@@ -79,15 +79,18 @@ async function loadGiteaModuleParts(): Promise<IntegrationModuleParts> {
79
79
  );
80
80
  }
81
81
 
82
+ const integrationProvider = createGiteaIntegrationProvider({
83
+ getExistingGiteaConnection,
84
+ connectGiteaConnection,
85
+ publishSourcePush,
86
+ recordDeliveryOnly,
87
+ getIntegrationConnectionById,
88
+ coreDb: db,
89
+ });
90
+
82
91
  return {
83
- provider: createGiteaIntegrationProvider({
84
- getExistingGiteaConnection,
85
- connectGiteaConnection,
86
- publishSourcePush,
87
- recordDeliveryOnly,
88
- getIntegrationConnectionById,
89
- coreDb: db,
90
- }),
92
+ provider: integrationProvider,
93
+ webhookProcessors: integrationProvider.webhookProcessors,
91
94
  database: {
92
95
  db: giteaDb,
93
96
  migrationsPath: giteaMigrationsPath,
@@ -116,18 +116,24 @@ async function loadGithubModuleParts(
116
116
  );
117
117
  }
118
118
 
119
+ const integrationProvider = createGithubIntegrationProvider({
120
+ getExistingGithubConnection,
121
+ connectGithubInstallation,
122
+ publishIntegrationEventReceived,
123
+ publishSourcePush,
124
+ recordDeliveryOnly,
125
+ getIntegrationConnectionById,
126
+ coreDb: db,
127
+ deleteSecrets: options.secrets?.deleteSecrets,
128
+ agentTools: {tokenProvider},
129
+ ...(options.requireActiveWorkspaceMembership
130
+ ? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
131
+ : {}),
132
+ });
133
+
119
134
  return {
120
- provider: createGithubIntegrationProvider({
121
- getExistingGithubConnection,
122
- connectGithubInstallation,
123
- publishIntegrationEventReceived,
124
- publishSourcePush,
125
- recordDeliveryOnly,
126
- getIntegrationConnectionById,
127
- coreDb: db,
128
- deleteSecrets: options.secrets?.deleteSecrets,
129
- agentTools: {tokenProvider},
130
- }),
135
+ provider: integrationProvider,
136
+ webhookProcessors: integrationProvider.webhookProcessors,
131
137
  e2eRoutes: [
132
138
  createGithubE2eRoutes({
133
139
  getExistingGithubConnection,