@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +100 -0
- package/dist/core/entities/provider.d.ts +7 -2
- package/dist/core/entities/provider.d.ts.map +1 -1
- package/dist/core/entities/provider.js.map +1 -1
- package/dist/core/errors.d.ts +7 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +8 -2
- package/dist/core/errors.js.map +1 -1
- package/dist/db/webhook-deliveries.d.ts +4 -0
- package/dist/db/webhook-deliveries.d.ts.map +1 -1
- package/dist/db/webhook-deliveries.js +10 -2
- package/dist/db/webhook-deliveries.js.map +1 -1
- package/dist/index.d.ts +19 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -6
- package/dist/index.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +9 -0
- package/dist/presentation/inter-module.d.ts.map +1 -0
- package/dist/presentation/inter-module.js +121 -0
- package/dist/presentation/inter-module.js.map +1 -0
- package/dist/presentation/routes/agent-tools-gateway/index.d.ts +1 -0
- package/dist/presentation/routes/agent-tools-gateway/index.d.ts.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/index.js +1 -0
- package/dist/presentation/routes/agent-tools-gateway/index.js.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts +5 -2
- package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js +53 -17
- package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js.map +1 -1
- package/dist/presentation/routes/index.d.ts +2 -3
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +3 -3
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/manage-connections.d.ts +1 -1
- package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
- package/dist/presentation/routes/manage-connections.js +28 -3
- package/dist/presentation/routes/manage-connections.js.map +1 -1
- package/dist/providers/gitea.d.ts.map +1 -1
- package/dist/providers/gitea.js +10 -8
- package/dist/providers/gitea.js.map +1 -1
- package/dist/providers/github.d.ts.map +1 -1
- package/dist/providers/github.js +18 -13
- package/dist/providers/github.js.map +1 -1
- package/dist/providers/jira.d.ts.map +1 -1
- package/dist/providers/jira.js +113 -3
- package/dist/providers/jira.js.map +1 -1
- package/dist/providers/linear.d.ts.map +1 -1
- package/dist/providers/linear.js +35 -16
- package/dist/providers/linear.js.map +1 -1
- package/dist/providers/sentry.d.ts.map +1 -1
- package/dist/providers/sentry.js +13 -11
- package/dist/providers/sentry.js.map +1 -1
- package/dist/providers/slack.d.ts.map +1 -1
- package/dist/providers/slack.js +36 -16
- package/dist/providers/slack.js.map +1 -1
- package/dist/providers/types.d.ts +12 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/providers/webhook.d.ts.map +1 -1
- package/dist/providers/webhook.js +11 -9
- package/dist/providers/webhook.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +33 -38
- package/src/core/entities/provider.ts +7 -1
- package/src/core/errors.ts +12 -2
- package/src/db/webhook-deliveries.test.ts +11 -0
- package/src/db/webhook-deliveries.ts +12 -3
- package/src/index.test.ts +112 -0
- package/src/index.ts +104 -10
- package/src/presentation/inter-module.ts +155 -0
- package/src/presentation/routes/agent-tools-gateway/index.ts +1 -0
- package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.test.ts +36 -0
- package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.ts +64 -22
- package/src/presentation/routes/index.ts +5 -6
- package/src/presentation/routes/manage-connections.test.ts +113 -0
- package/src/presentation/routes/manage-connections.ts +26 -2
- package/src/providers/gitea.ts +11 -8
- package/src/providers/github.ts +17 -11
- package/src/providers/jira.ts +155 -7
- package/src/providers/linear.test.ts +136 -0
- package/src/providers/linear.ts +33 -13
- package/src/providers/sentry.ts +15 -12
- package/src/providers/slack.test.ts +139 -0
- package/src/providers/slack.ts +39 -14
- package/src/providers/types.ts +15 -0
- package/src/providers/webhook.ts +11 -9
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/providers/jira.ts
CHANGED
|
@@ -1,22 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type IntegrationConnection as CoreIntegrationConnection,
|
|
3
|
+
slugifyConnectionSlug,
|
|
4
|
+
} from '@shipfox/api-integration-core-dto';
|
|
5
|
+
import type {
|
|
6
|
+
ConnectJiraInstallationInput,
|
|
7
|
+
JiraPendingSelectionSecretsStore,
|
|
8
|
+
JiraSecretsStore,
|
|
9
|
+
} from '@shipfox/api-integration-jira';
|
|
1
10
|
import {config} from '#config.js';
|
|
11
|
+
import {
|
|
12
|
+
deleteIntegrationConnection,
|
|
13
|
+
getIntegrationConnectionById,
|
|
14
|
+
resolveUniqueConnectionSlug,
|
|
15
|
+
updateIntegrationConnectionLifecycleStatus,
|
|
16
|
+
upsertIntegrationConnection,
|
|
17
|
+
} from '#db/connections.js';
|
|
18
|
+
import {db} from '#db/db.js';
|
|
19
|
+
import {retryConnectionSlugCollision} from '#providers/connection-slug.js';
|
|
2
20
|
import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
|
|
3
21
|
|
|
4
22
|
const JIRA_MIGRATIONS_TABLE = '__drizzle_migrations_integrations_jira';
|
|
23
|
+
const JIRA_SECRETS_NAMESPACE_PREFIX = 'system/integrations/jira/';
|
|
24
|
+
type IntegrationDb = ReturnType<typeof db>;
|
|
25
|
+
type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
|
|
5
26
|
|
|
6
|
-
async function loadJiraModuleParts(
|
|
27
|
+
async function loadJiraModuleParts(
|
|
28
|
+
options: Parameters<IntegrationProviderModule['load']>[0] = {},
|
|
29
|
+
): Promise<IntegrationModuleParts> {
|
|
7
30
|
const {
|
|
8
31
|
createJiraIntegrationProvider,
|
|
32
|
+
createJiraPendingSelectionStore,
|
|
33
|
+
createJiraTokenStore,
|
|
9
34
|
db: jiraDb,
|
|
35
|
+
disconnectJiraInstallation: disconnectJiraInstallationRecords,
|
|
36
|
+
getJiraInstallationByCloudId,
|
|
10
37
|
migrationsPath,
|
|
38
|
+
upsertJiraInstallation,
|
|
11
39
|
} = await import('@shipfox/api-integration-jira');
|
|
12
40
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
async function getExistingJiraConnection(input: {
|
|
42
|
+
cloudId: string;
|
|
43
|
+
}): Promise<CoreIntegrationConnection<'jira'> | undefined> {
|
|
44
|
+
const installation = await getJiraInstallationByCloudId(input.cloudId);
|
|
45
|
+
if (!installation) return undefined;
|
|
46
|
+
return (await getIntegrationConnectionById(installation.connectionId)) as
|
|
47
|
+
| CoreIntegrationConnection<'jira'>
|
|
48
|
+
| undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function connectJiraInstallation(
|
|
52
|
+
input: ConnectJiraInstallationInput,
|
|
53
|
+
): Promise<CoreIntegrationConnection<'jira'>> {
|
|
54
|
+
return retryConnectionSlugCollision(() =>
|
|
55
|
+
db().transaction(async (tx) => {
|
|
56
|
+
const slug = await resolveUniqueConnectionSlug(
|
|
57
|
+
{
|
|
58
|
+
workspaceId: input.workspaceId,
|
|
59
|
+
provider: 'jira',
|
|
60
|
+
externalAccountId: input.cloudId,
|
|
61
|
+
baseSlug: slugifyConnectionSlug(`jira_${input.siteName || input.cloudId}`, {
|
|
62
|
+
fallback: 'jira',
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
{tx},
|
|
66
|
+
);
|
|
67
|
+
const connection = await upsertIntegrationConnection(
|
|
68
|
+
{
|
|
69
|
+
workspaceId: input.workspaceId,
|
|
70
|
+
provider: 'jira',
|
|
71
|
+
externalAccountId: input.cloudId,
|
|
72
|
+
slug,
|
|
73
|
+
displayName: input.displayName,
|
|
74
|
+
lifecycleStatus: 'active',
|
|
75
|
+
},
|
|
76
|
+
{tx},
|
|
77
|
+
);
|
|
78
|
+
await upsertJiraInstallation(
|
|
79
|
+
{
|
|
80
|
+
connectionId: connection.id,
|
|
81
|
+
cloudId: input.cloudId,
|
|
82
|
+
siteUrl: input.siteUrl,
|
|
83
|
+
siteName: input.siteName,
|
|
84
|
+
authorizingAccountId: input.authorizingAccountId,
|
|
85
|
+
scopes: input.scopes,
|
|
86
|
+
status: 'installed',
|
|
87
|
+
tokenExpiresAt: input.tokenExpiresAt,
|
|
88
|
+
},
|
|
89
|
+
{tx},
|
|
90
|
+
);
|
|
91
|
+
return connection as CoreIntegrationConnection<'jira'>;
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function disconnectJiraInstallation(input: {connectionId: string}): Promise<void> {
|
|
97
|
+
await disconnectJiraInstallationRecords<IntegrationTx>({
|
|
98
|
+
connectionId: input.connectionId,
|
|
99
|
+
getConnection: getIntegrationConnectionById,
|
|
100
|
+
deleteSecrets: (params) =>
|
|
101
|
+
options.secrets?.jira?.deleteSecrets({
|
|
102
|
+
...params,
|
|
103
|
+
namespace: jiraNamespaceSuffix(params.namespace),
|
|
104
|
+
}) ?? Promise.resolve(0),
|
|
105
|
+
transaction: (fn) => db().transaction((tx) => fn(tx)),
|
|
106
|
+
deleteConnection: (params, transactionOptions) =>
|
|
107
|
+
deleteIntegrationConnection({id: params.connectionId}, transactionOptions),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fallbackSecrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = {
|
|
112
|
+
getSecret: () => Promise.resolve(null),
|
|
113
|
+
setSecrets: () => Promise.reject(new Error('Jira token storage is not configured')),
|
|
114
|
+
deleteSecrets: () => Promise.resolve(0),
|
|
115
|
+
};
|
|
116
|
+
const secrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = options.secrets?.jira
|
|
117
|
+
? {
|
|
118
|
+
getSecret: (params) =>
|
|
119
|
+
options.secrets?.jira?.getSecret({
|
|
120
|
+
...params,
|
|
121
|
+
namespace: jiraNamespaceSuffix(params.namespace),
|
|
122
|
+
}) ?? Promise.resolve(null),
|
|
123
|
+
setSecrets: (params) =>
|
|
124
|
+
options.secrets?.jira?.setSecrets({
|
|
125
|
+
...params,
|
|
126
|
+
namespace: jiraNamespaceSuffix(params.namespace),
|
|
127
|
+
}) ?? Promise.resolve(),
|
|
128
|
+
deleteSecrets: (params) =>
|
|
129
|
+
options.secrets?.jira?.deleteSecrets({
|
|
130
|
+
...params,
|
|
131
|
+
namespace: jiraNamespaceSuffix(params.namespace),
|
|
132
|
+
}) ?? Promise.resolve(0),
|
|
133
|
+
}
|
|
134
|
+
: fallbackSecrets;
|
|
135
|
+
const tokenStore = createJiraTokenStore({
|
|
136
|
+
resolveConnection: getIntegrationConnectionById,
|
|
137
|
+
secrets,
|
|
138
|
+
markConnectionError: async ({connectionId}) => {
|
|
139
|
+
await updateIntegrationConnectionLifecycleStatus({
|
|
140
|
+
id: connectionId,
|
|
141
|
+
lifecycleStatus: 'error',
|
|
142
|
+
});
|
|
19
143
|
},
|
|
144
|
+
});
|
|
145
|
+
const pendingStore = createJiraPendingSelectionStore({secrets});
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
provider: createJiraIntegrationProvider({
|
|
149
|
+
routes: {
|
|
150
|
+
tokenStore,
|
|
151
|
+
pendingStore,
|
|
152
|
+
getExistingJiraConnection,
|
|
153
|
+
connectJiraInstallation,
|
|
154
|
+
disconnectJiraInstallation,
|
|
155
|
+
...(options.requireActiveWorkspaceMembership
|
|
156
|
+
? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
|
|
157
|
+
: {}),
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
database: {db: jiraDb, migrationsPath, migrationsTableName: JIRA_MIGRATIONS_TABLE},
|
|
20
161
|
};
|
|
21
162
|
}
|
|
22
163
|
|
|
@@ -25,3 +166,10 @@ export const jiraProviderModule: IntegrationProviderModule = {
|
|
|
25
166
|
enabled: config.INTEGRATIONS_ENABLE_JIRA_PROVIDER,
|
|
26
167
|
load: loadJiraModuleParts,
|
|
27
168
|
};
|
|
169
|
+
|
|
170
|
+
function jiraNamespaceSuffix(namespace: string): string {
|
|
171
|
+
if (!namespace.startsWith(JIRA_SECRETS_NAMESPACE_PREFIX)) {
|
|
172
|
+
throw new Error('Jira provider attempted to access an unscoped secret namespace');
|
|
173
|
+
}
|
|
174
|
+
return namespace.slice(JIRA_SECRETS_NAMESPACE_PREFIX.length);
|
|
175
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLinearInstallationByConnectionId,
|
|
3
|
+
upsertLinearInstallation,
|
|
4
|
+
} from '@shipfox/api-integration-linear';
|
|
5
|
+
import {runMigrations} from '@shipfox/node-drizzle';
|
|
6
|
+
import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
|
|
7
|
+
import {db} from '#db/db.js';
|
|
8
|
+
import {linearProviderModule} from '#providers/linear.js';
|
|
9
|
+
import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
|
|
10
|
+
|
|
11
|
+
describe('linearProviderModule', () => {
|
|
12
|
+
const context = useIntegrationRouteTest();
|
|
13
|
+
|
|
14
|
+
it('deletes the installation and tokens through the generic route before allowing a reinstall', async () => {
|
|
15
|
+
const deleteSecrets = vi.fn(() => Promise.resolve(2));
|
|
16
|
+
const scopedSecrets = {
|
|
17
|
+
getSecret: vi.fn(() => Promise.resolve(null)),
|
|
18
|
+
setSecrets: vi.fn(() => Promise.resolve()),
|
|
19
|
+
deleteSecrets,
|
|
20
|
+
};
|
|
21
|
+
const linearPart = await linearProviderModule.load({
|
|
22
|
+
secrets: {linear: scopedSecrets, deleteSecrets},
|
|
23
|
+
});
|
|
24
|
+
if (!linearPart.database) throw new Error('Linear provider database is not configured');
|
|
25
|
+
const organizationId = crypto.randomUUID();
|
|
26
|
+
|
|
27
|
+
await runMigrations(
|
|
28
|
+
linearPart.database.db(),
|
|
29
|
+
linearPart.database.migrationsPath,
|
|
30
|
+
linearPart.database.migrationsTableName,
|
|
31
|
+
);
|
|
32
|
+
const app = await createTestApp([linearPart.provider]);
|
|
33
|
+
const connection = await upsertIntegrationConnection({
|
|
34
|
+
workspaceId: context.workspaceId,
|
|
35
|
+
provider: 'linear',
|
|
36
|
+
externalAccountId: organizationId,
|
|
37
|
+
slug: 'linear_acme',
|
|
38
|
+
displayName: 'Linear Acme',
|
|
39
|
+
});
|
|
40
|
+
await upsertLinearInstallation({
|
|
41
|
+
connectionId: connection.id,
|
|
42
|
+
organizationId,
|
|
43
|
+
organizationUrlKey: 'acme',
|
|
44
|
+
appUserId: 'user-1',
|
|
45
|
+
scopes: ['read'],
|
|
46
|
+
status: 'installed',
|
|
47
|
+
tokenExpiresAt: null,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const res = await app.inject({
|
|
51
|
+
method: 'DELETE',
|
|
52
|
+
url: `/integration-connections/${connection.id}`,
|
|
53
|
+
headers: {authorization: 'Bearer user'},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(res.statusCode).toBe(204);
|
|
57
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
58
|
+
await expect(getLinearInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
|
|
59
|
+
expect(deleteSecrets).toHaveBeenCalledWith({
|
|
60
|
+
workspaceId: context.workspaceId,
|
|
61
|
+
namespace: connection.id,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const replacement = await upsertIntegrationConnection({
|
|
65
|
+
workspaceId: context.workspaceId,
|
|
66
|
+
provider: 'linear',
|
|
67
|
+
externalAccountId: organizationId,
|
|
68
|
+
slug: 'linear_acme_again',
|
|
69
|
+
displayName: 'Linear Acme',
|
|
70
|
+
});
|
|
71
|
+
await upsertLinearInstallation({
|
|
72
|
+
connectionId: replacement.id,
|
|
73
|
+
organizationId,
|
|
74
|
+
organizationUrlKey: 'acme',
|
|
75
|
+
appUserId: 'user-1',
|
|
76
|
+
scopes: ['read'],
|
|
77
|
+
status: 'installed',
|
|
78
|
+
tokenExpiresAt: null,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await expect(getLinearInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
|
|
82
|
+
organizationId,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rolls back provider record cleanup when its transaction fails', async () => {
|
|
87
|
+
const deleteSecrets = vi.fn(() => Promise.resolve(2));
|
|
88
|
+
const scopedSecrets = {
|
|
89
|
+
getSecret: vi.fn(() => Promise.resolve(null)),
|
|
90
|
+
setSecrets: vi.fn(() => Promise.resolve()),
|
|
91
|
+
deleteSecrets,
|
|
92
|
+
};
|
|
93
|
+
const linearPart = await linearProviderModule.load({
|
|
94
|
+
secrets: {linear: scopedSecrets, deleteSecrets},
|
|
95
|
+
});
|
|
96
|
+
if (!linearPart.database) throw new Error('Linear provider database is not configured');
|
|
97
|
+
const organizationId = crypto.randomUUID();
|
|
98
|
+
|
|
99
|
+
await runMigrations(
|
|
100
|
+
linearPart.database.db(),
|
|
101
|
+
linearPart.database.migrationsPath,
|
|
102
|
+
linearPart.database.migrationsTableName,
|
|
103
|
+
);
|
|
104
|
+
const connection = await upsertIntegrationConnection({
|
|
105
|
+
workspaceId: context.workspaceId,
|
|
106
|
+
provider: 'linear',
|
|
107
|
+
externalAccountId: organizationId,
|
|
108
|
+
slug: 'linear_acme',
|
|
109
|
+
displayName: 'Linear Acme',
|
|
110
|
+
});
|
|
111
|
+
await upsertLinearInstallation({
|
|
112
|
+
connectionId: connection.id,
|
|
113
|
+
organizationId,
|
|
114
|
+
organizationUrlKey: 'acme',
|
|
115
|
+
appUserId: 'user-1',
|
|
116
|
+
scopes: ['read'],
|
|
117
|
+
status: 'installed',
|
|
118
|
+
tokenExpiresAt: null,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await expect(
|
|
122
|
+
db().transaction(async (tx) => {
|
|
123
|
+
await linearPart.provider.deleteConnectionRecords?.(connection, {tx});
|
|
124
|
+
throw new Error('transaction failed');
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow('transaction failed');
|
|
127
|
+
|
|
128
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
|
|
129
|
+
id: connection.id,
|
|
130
|
+
});
|
|
131
|
+
await expect(getLinearInstallationByConnectionId(connection.id)).resolves.toMatchObject({
|
|
132
|
+
connectionId: connection.id,
|
|
133
|
+
});
|
|
134
|
+
expect(deleteSecrets).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/providers/linear.ts
CHANGED
|
@@ -32,9 +32,11 @@ async function loadLinearModuleParts(
|
|
|
32
32
|
createLinearE2eRoutes,
|
|
33
33
|
createLinearIntegrationProvider,
|
|
34
34
|
config: linearConfig,
|
|
35
|
+
deleteLinearInstallationByConnectionId,
|
|
35
36
|
disconnectLinearInstallation: disconnectLinearInstallationRecords,
|
|
36
37
|
getLinearInstallationByOrganizationId,
|
|
37
38
|
db: linearDb,
|
|
39
|
+
linearSecretsNamespace,
|
|
38
40
|
migrationsPath: linearMigrationsPath,
|
|
39
41
|
upsertLinearInstallation,
|
|
40
42
|
} = await import('@shipfox/api-integration-linear');
|
|
@@ -134,20 +136,38 @@ async function loadLinearModuleParts(
|
|
|
134
136
|
secrets,
|
|
135
137
|
});
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
const integrationProvider = createLinearIntegrationProvider({
|
|
140
|
+
agentTools: {tokenStore, endpoint: linearConfig.LINEAR_MCP_ENDPOINT},
|
|
141
|
+
cleanup: {
|
|
142
|
+
deleteConnectionRecords: async (connection, {tx}) => {
|
|
143
|
+
await deleteLinearInstallationByConnectionId(connection.id, {tx});
|
|
144
|
+
},
|
|
145
|
+
deleteConnectionSecrets: async (connection) => {
|
|
146
|
+
// Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
|
|
147
|
+
await (options.secrets?.linear?.deleteSecrets({
|
|
148
|
+
workspaceId: connection.workspaceId,
|
|
149
|
+
namespace: linearNamespaceSuffix(linearSecretsNamespace(connection.id)),
|
|
150
|
+
}) ?? Promise.resolve());
|
|
149
151
|
},
|
|
150
|
-
}
|
|
152
|
+
},
|
|
153
|
+
routes: {
|
|
154
|
+
tokenStore,
|
|
155
|
+
getExistingLinearConnection,
|
|
156
|
+
connectLinearInstallation,
|
|
157
|
+
disconnectLinearInstallation,
|
|
158
|
+
publishIntegrationEventReceived,
|
|
159
|
+
recordDeliveryOnly,
|
|
160
|
+
getIntegrationConnectionById,
|
|
161
|
+
coreDb: db,
|
|
162
|
+
...(options.requireActiveWorkspaceMembership
|
|
163
|
+
? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
|
|
164
|
+
: {}),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
provider: integrationProvider,
|
|
170
|
+
webhookProcessors: integrationProvider.webhookProcessors,
|
|
151
171
|
e2eRoutes: [
|
|
152
172
|
createLinearE2eRoutes({
|
|
153
173
|
tokenStore,
|
package/src/providers/sentry.ts
CHANGED
|
@@ -84,19 +84,22 @@ async function loadSentryModuleParts(): Promise<IntegrationModuleParts> {
|
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
const integrationProvider = createSentryIntegrationProvider({
|
|
88
|
+
getSentryInstallation: ({installationUuid}) =>
|
|
89
|
+
getSentryInstallationByInstallationUuid(installationUuid),
|
|
90
|
+
getConnectionById,
|
|
91
|
+
connectSentryInstallation,
|
|
92
|
+
persistVerifiedUnclaimedInstallation,
|
|
93
|
+
coreDb: db,
|
|
94
|
+
publishIntegrationEventReceived,
|
|
95
|
+
recordDeliveryOnly,
|
|
96
|
+
getIntegrationConnectionById,
|
|
97
|
+
updateConnectionLifecycleStatus: updateIntegrationConnectionLifecycleStatus,
|
|
98
|
+
});
|
|
99
|
+
|
|
87
100
|
return {
|
|
88
|
-
provider:
|
|
89
|
-
|
|
90
|
-
getSentryInstallationByInstallationUuid(installationUuid),
|
|
91
|
-
getConnectionById,
|
|
92
|
-
connectSentryInstallation,
|
|
93
|
-
persistVerifiedUnclaimedInstallation,
|
|
94
|
-
coreDb: db,
|
|
95
|
-
publishIntegrationEventReceived,
|
|
96
|
-
recordDeliveryOnly,
|
|
97
|
-
getIntegrationConnectionById,
|
|
98
|
-
updateConnectionLifecycleStatus: updateIntegrationConnectionLifecycleStatus,
|
|
99
|
-
}),
|
|
101
|
+
provider: integrationProvider,
|
|
102
|
+
webhookProcessors: integrationProvider.webhookProcessors,
|
|
100
103
|
database: {
|
|
101
104
|
db: sentryDb,
|
|
102
105
|
migrationsPath: sentryMigrationsPath,
|
|
@@ -4,8 +4,12 @@ import {
|
|
|
4
4
|
} from '@shipfox/api-integration-slack';
|
|
5
5
|
import {runMigrations} from '@shipfox/node-drizzle';
|
|
6
6
|
import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
|
|
7
|
+
import {db} from '#db/db.js';
|
|
8
|
+
import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
|
|
7
9
|
|
|
8
10
|
describe('slackProviderModule', () => {
|
|
11
|
+
const context = useIntegrationRouteTest();
|
|
12
|
+
|
|
9
13
|
afterEach(() => {
|
|
10
14
|
vi.unstubAllEnvs();
|
|
11
15
|
vi.resetModules();
|
|
@@ -54,4 +58,139 @@ describe('slackProviderModule', () => {
|
|
|
54
58
|
teamId,
|
|
55
59
|
});
|
|
56
60
|
});
|
|
61
|
+
|
|
62
|
+
it('deletes the installation and token through the generic route before allowing a reinstall', async () => {
|
|
63
|
+
vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
|
|
64
|
+
vi.resetModules();
|
|
65
|
+
const deleteSecrets = vi.fn(() => Promise.resolve(1));
|
|
66
|
+
const scopedSecrets = {
|
|
67
|
+
getSecret: vi.fn(() => Promise.resolve(null)),
|
|
68
|
+
setSecrets: vi.fn(() => Promise.resolve()),
|
|
69
|
+
deleteSecrets,
|
|
70
|
+
};
|
|
71
|
+
const {createPostgresClient} = await import('@shipfox/node-postgres');
|
|
72
|
+
createPostgresClient();
|
|
73
|
+
const {loadEnabledProviderModules} = await import('#providers/modules.js');
|
|
74
|
+
const parts = await loadEnabledProviderModules({
|
|
75
|
+
secrets: {slack: scopedSecrets, deleteSecrets},
|
|
76
|
+
});
|
|
77
|
+
const slackPart = parts.find((part) => part.provider.provider === 'slack');
|
|
78
|
+
if (!slackPart?.database) throw new Error('Slack provider database is not configured');
|
|
79
|
+
const teamId = `T${crypto.randomUUID()}`;
|
|
80
|
+
|
|
81
|
+
await runMigrations(
|
|
82
|
+
slackPart.database.db(),
|
|
83
|
+
slackPart.database.migrationsPath,
|
|
84
|
+
slackPart.database.migrationsTableName,
|
|
85
|
+
);
|
|
86
|
+
const app = await createTestApp([slackPart.provider]);
|
|
87
|
+
const connection = await upsertIntegrationConnection({
|
|
88
|
+
workspaceId: context.workspaceId,
|
|
89
|
+
provider: 'slack',
|
|
90
|
+
externalAccountId: teamId,
|
|
91
|
+
slug: `slack_${teamId}`,
|
|
92
|
+
displayName: 'Slack Acme',
|
|
93
|
+
});
|
|
94
|
+
await upsertSlackInstallation({
|
|
95
|
+
connectionId: connection.id,
|
|
96
|
+
teamId,
|
|
97
|
+
teamName: 'Acme',
|
|
98
|
+
appId: 'A123',
|
|
99
|
+
botUserId: 'U123',
|
|
100
|
+
scopes: ['app_mentions:read'],
|
|
101
|
+
status: 'installed',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const res = await app.inject({
|
|
105
|
+
method: 'DELETE',
|
|
106
|
+
url: `/integration-connections/${connection.id}`,
|
|
107
|
+
headers: {authorization: 'Bearer user'},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(res.statusCode).toBe(204);
|
|
111
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
112
|
+
await expect(getSlackInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
|
|
113
|
+
expect(deleteSecrets).toHaveBeenCalledWith({
|
|
114
|
+
workspaceId: context.workspaceId,
|
|
115
|
+
namespace: connection.id,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const replacement = await upsertIntegrationConnection({
|
|
119
|
+
workspaceId: context.workspaceId,
|
|
120
|
+
provider: 'slack',
|
|
121
|
+
externalAccountId: teamId,
|
|
122
|
+
slug: `slack_${teamId}_again`,
|
|
123
|
+
displayName: 'Slack Acme',
|
|
124
|
+
});
|
|
125
|
+
await upsertSlackInstallation({
|
|
126
|
+
connectionId: replacement.id,
|
|
127
|
+
teamId,
|
|
128
|
+
teamName: 'Acme',
|
|
129
|
+
appId: 'A123',
|
|
130
|
+
botUserId: 'U123',
|
|
131
|
+
scopes: ['app_mentions:read'],
|
|
132
|
+
status: 'installed',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await expect(getSlackInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
|
|
136
|
+
teamId,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('rolls back provider record cleanup when its transaction fails', async () => {
|
|
141
|
+
vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
|
|
142
|
+
vi.resetModules();
|
|
143
|
+
const deleteSecrets = vi.fn(() => Promise.resolve(1));
|
|
144
|
+
const scopedSecrets = {
|
|
145
|
+
getSecret: vi.fn(() => Promise.resolve(null)),
|
|
146
|
+
setSecrets: vi.fn(() => Promise.resolve()),
|
|
147
|
+
deleteSecrets,
|
|
148
|
+
};
|
|
149
|
+
const {createPostgresClient} = await import('@shipfox/node-postgres');
|
|
150
|
+
createPostgresClient();
|
|
151
|
+
const {loadEnabledProviderModules} = await import('#providers/modules.js');
|
|
152
|
+
const parts = await loadEnabledProviderModules({
|
|
153
|
+
secrets: {slack: scopedSecrets, deleteSecrets},
|
|
154
|
+
});
|
|
155
|
+
const slackPart = parts.find((part) => part.provider.provider === 'slack');
|
|
156
|
+
if (!slackPart?.database) throw new Error('Slack provider database is not configured');
|
|
157
|
+
const teamId = `T${crypto.randomUUID()}`;
|
|
158
|
+
|
|
159
|
+
await runMigrations(
|
|
160
|
+
slackPart.database.db(),
|
|
161
|
+
slackPart.database.migrationsPath,
|
|
162
|
+
slackPart.database.migrationsTableName,
|
|
163
|
+
);
|
|
164
|
+
const connection = await upsertIntegrationConnection({
|
|
165
|
+
workspaceId: context.workspaceId,
|
|
166
|
+
provider: 'slack',
|
|
167
|
+
externalAccountId: teamId,
|
|
168
|
+
slug: `slack_${teamId}`,
|
|
169
|
+
displayName: 'Slack Acme',
|
|
170
|
+
});
|
|
171
|
+
await upsertSlackInstallation({
|
|
172
|
+
connectionId: connection.id,
|
|
173
|
+
teamId,
|
|
174
|
+
teamName: 'Acme',
|
|
175
|
+
appId: 'A123',
|
|
176
|
+
botUserId: 'U123',
|
|
177
|
+
scopes: ['app_mentions:read'],
|
|
178
|
+
status: 'installed',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
db().transaction(async (tx) => {
|
|
183
|
+
await slackPart.provider.deleteConnectionRecords?.(connection, {tx});
|
|
184
|
+
throw new Error('transaction failed');
|
|
185
|
+
}),
|
|
186
|
+
).rejects.toThrow('transaction failed');
|
|
187
|
+
|
|
188
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
|
|
189
|
+
id: connection.id,
|
|
190
|
+
});
|
|
191
|
+
await expect(getSlackInstallationByConnectionId(connection.id)).resolves.toMatchObject({
|
|
192
|
+
connectionId: connection.id,
|
|
193
|
+
});
|
|
194
|
+
expect(deleteSecrets).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
57
196
|
});
|
package/src/providers/slack.ts
CHANGED
|
@@ -14,7 +14,11 @@ import {
|
|
|
14
14
|
upsertIntegrationConnection,
|
|
15
15
|
} from '#db/connections.js';
|
|
16
16
|
import {db} from '#db/db.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
claimWebhookDelivery,
|
|
19
|
+
publishIntegrationEventReceived,
|
|
20
|
+
recordDeliveryOnly,
|
|
21
|
+
} from '#db/webhook-deliveries.js';
|
|
18
22
|
import {retryConnectionSlugCollision} from '#providers/connection-slug.js';
|
|
19
23
|
import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
|
|
20
24
|
|
|
@@ -32,9 +36,11 @@ async function loadSlackModuleParts(
|
|
|
32
36
|
createSlackIntegrationProvider,
|
|
33
37
|
createSlackTokenStore,
|
|
34
38
|
db: slackDb,
|
|
39
|
+
deleteSlackInstallationByConnectionId,
|
|
35
40
|
disconnectSlackInstallation: disconnectSlackInstallationRecords,
|
|
36
41
|
getSlackInstallationByTeamId,
|
|
37
42
|
migrationsPath: slackMigrationsPath,
|
|
43
|
+
slackSecretsNamespace,
|
|
38
44
|
upsertSlackInstallation,
|
|
39
45
|
} = await import('@shipfox/api-integration-slack');
|
|
40
46
|
|
|
@@ -132,20 +138,39 @@ async function loadSlackModuleParts(
|
|
|
132
138
|
secrets,
|
|
133
139
|
});
|
|
134
140
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
getExistingSlackConnection,
|
|
141
|
-
connectSlackInstallation,
|
|
142
|
-
disconnectSlackInstallation,
|
|
143
|
-
coreDb: db,
|
|
144
|
-
publishIntegrationEventReceived,
|
|
145
|
-
recordDeliveryOnly,
|
|
146
|
-
getIntegrationConnectionById,
|
|
141
|
+
const integrationProvider = createSlackIntegrationProvider({
|
|
142
|
+
agentTools: {tokenStore},
|
|
143
|
+
cleanup: {
|
|
144
|
+
deleteConnectionRecords: async (connection, {tx}) => {
|
|
145
|
+
await deleteSlackInstallationByConnectionId(connection.id, {tx});
|
|
147
146
|
},
|
|
148
|
-
|
|
147
|
+
deleteConnectionSecrets: async (connection) => {
|
|
148
|
+
// Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
|
|
149
|
+
await (options.secrets?.slack?.deleteSecrets({
|
|
150
|
+
workspaceId: connection.workspaceId,
|
|
151
|
+
namespace: slackNamespaceSuffix(slackSecretsNamespace(connection.id)),
|
|
152
|
+
}) ?? Promise.resolve());
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
routes: {
|
|
156
|
+
tokenStore,
|
|
157
|
+
getExistingSlackConnection,
|
|
158
|
+
connectSlackInstallation,
|
|
159
|
+
disconnectSlackInstallation,
|
|
160
|
+
coreDb: db,
|
|
161
|
+
claimWebhookDelivery,
|
|
162
|
+
publishIntegrationEventReceived,
|
|
163
|
+
recordDeliveryOnly,
|
|
164
|
+
getIntegrationConnectionById,
|
|
165
|
+
...(options.requireActiveWorkspaceMembership
|
|
166
|
+
? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
|
|
167
|
+
: {}),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
provider: integrationProvider,
|
|
173
|
+
webhookProcessors: integrationProvider.webhookProcessors,
|
|
149
174
|
e2eRoutes: [
|
|
150
175
|
createSlackE2eRoutes({
|
|
151
176
|
tokenStore,
|
package/src/providers/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type {UserContextMembership} from '@shipfox/api-auth-context';
|
|
2
|
+
import type {WebhookRequestProcessor, WebhookRouteId} from '@shipfox/api-integration-core-dto';
|
|
1
3
|
import type {RouteExport} from '@shipfox/node-fastify';
|
|
2
4
|
import type {ModuleDatabase, ModuleWorker} from '@shipfox/node-module';
|
|
3
5
|
import type {IntegrationProvider} from '#core/entities/provider.js';
|
|
@@ -17,6 +19,12 @@ export interface IntegrationModuleParts {
|
|
|
17
19
|
e2eRoutes?: RouteExport[] | undefined;
|
|
18
20
|
workers?: ModuleWorker[] | undefined;
|
|
19
21
|
startupTasks?: Array<() => Promise<void>> | undefined;
|
|
22
|
+
webhookProcessors?: WebhookProcessorRegistration[] | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WebhookProcessorRegistration {
|
|
26
|
+
routeIds: readonly WebhookRouteId[];
|
|
27
|
+
processor: WebhookRequestProcessor;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|
|
@@ -32,6 +40,13 @@ export interface IntegrationProviderModule {
|
|
|
32
40
|
|
|
33
41
|
export interface IntegrationProviderModuleLoadOptions {
|
|
34
42
|
secrets?: IntegrationProviderSecrets | undefined;
|
|
43
|
+
requireActiveWorkspaceMembership?:
|
|
44
|
+
| ((input: {
|
|
45
|
+
workspaceId: string;
|
|
46
|
+
userId: string;
|
|
47
|
+
memberships: ReadonlyArray<UserContextMembership>;
|
|
48
|
+
}) => Promise<unknown>)
|
|
49
|
+
| undefined;
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
export interface IntegrationProviderSecrets {
|