@shipfox/api-integration-core 3.0.0 → 4.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-core",
3
3
  "license": "MIT",
4
- "version": "3.0.0",
4
+ "version": "4.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -40,17 +40,19 @@
40
40
  "@shipfox/api-agent-dto": "3.0.0",
41
41
  "@shipfox/api-auth-context": "3.0.0",
42
42
  "@shipfox/api-integration-core-dto": "3.0.0",
43
- "@shipfox/api-integration-gitea": "3.0.0",
44
- "@shipfox/api-integration-github": "3.0.0",
45
- "@shipfox/api-integration-linear": "3.0.0",
46
- "@shipfox/api-integration-sentry": "3.0.0",
43
+ "@shipfox/api-integration-gitea": "4.0.0",
44
+ "@shipfox/api-integration-github": "4.0.0",
45
+ "@shipfox/api-integration-jira": "4.0.0",
46
+ "@shipfox/api-integration-linear": "4.0.0",
47
+ "@shipfox/api-integration-sentry": "4.0.0",
48
+ "@shipfox/api-integration-slack": "4.0.0",
47
49
  "@shipfox/api-integration-webhook": "3.0.0",
48
50
  "@shipfox/config": "1.2.1",
49
- "@shipfox/node-drizzle": "0.2.1",
51
+ "@shipfox/node-drizzle": "0.3.0",
50
52
  "@shipfox/node-fastify": "0.2.2",
51
- "@shipfox/node-module": "0.3.0",
53
+ "@shipfox/node-module": "0.3.1",
52
54
  "@shipfox/node-opentelemetry": "0.5.1",
53
- "@shipfox/node-outbox": "0.2.1",
55
+ "@shipfox/node-outbox": "0.2.2",
54
56
  "@shipfox/node-postgres": "0.4.1",
55
57
  "@shipfox/node-temporal": "0.3.0",
56
58
  "@shipfox/regex": "0.2.1",
package/src/config.ts CHANGED
@@ -13,6 +13,10 @@ export const config = createConfig({
13
13
  desc: 'Enables the GitHub integration provider so users can connect GitHub.',
14
14
  default: false,
15
15
  }),
16
+ INTEGRATIONS_ENABLE_JIRA_PROVIDER: bool({
17
+ desc: 'Enables the Jira integration provider so users can connect Jira sites.',
18
+ default: false,
19
+ }),
16
20
  INTEGRATIONS_ENABLE_LINEAR_PROVIDER: bool({
17
21
  desc: 'Enables the Linear integration provider so users can connect Linear workspaces.',
18
22
  default: false,
@@ -21,6 +25,10 @@ export const config = createConfig({
21
25
  desc: 'Enables the Sentry integration provider so users can connect Sentry.',
22
26
  default: false,
23
27
  }),
28
+ INTEGRATIONS_ENABLE_SLACK_PROVIDER: bool({
29
+ desc: 'Enables the Slack integration provider so users can connect Slack workspaces.',
30
+ default: false,
31
+ }),
24
32
  INTEGRATIONS_ENABLE_WEBHOOK_PROVIDER: bool({
25
33
  desc: 'Enables the generic webhook integration provider so users can create inbound webhook URLs. It is enabled by default because it does not require provider setup.',
26
34
  default: true,
@@ -0,0 +1,57 @@
1
+ import {
2
+ getJiraInstallationByConnectionId,
3
+ upsertJiraInstallation,
4
+ } from '@shipfox/api-integration-jira';
5
+ import {runMigrations} from '@shipfox/node-drizzle';
6
+ import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
7
+
8
+ describe('jiraProviderModule', () => {
9
+ afterEach(() => {
10
+ vi.unstubAllEnvs();
11
+ vi.resetModules();
12
+ });
13
+
14
+ it('loads its database descriptor and persists a core connection with its Jira installation', async () => {
15
+ vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
16
+ vi.resetModules();
17
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
18
+ createPostgresClient();
19
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
20
+ const parts = await loadEnabledProviderModules();
21
+ const jiraPart = parts.find((part) => part.provider.provider === 'jira');
22
+ if (!jiraPart?.database) throw new Error('Jira provider database is not configured');
23
+ const workspaceId = crypto.randomUUID();
24
+ const cloudId = crypto.randomUUID();
25
+
26
+ await runMigrations(
27
+ jiraPart.database.db(),
28
+ jiraPart.database.migrationsPath,
29
+ jiraPart.database.migrationsTableName,
30
+ );
31
+ const connection = await upsertIntegrationConnection({
32
+ workspaceId,
33
+ provider: 'jira',
34
+ externalAccountId: cloudId,
35
+ slug: `jira_${cloudId}`,
36
+ displayName: 'Jira Acme',
37
+ });
38
+ await upsertJiraInstallation({
39
+ connectionId: connection.id,
40
+ cloudId,
41
+ siteUrl: 'https://acme.atlassian.net',
42
+ siteName: 'Acme',
43
+ authorizingAccountId: crypto.randomUUID(),
44
+ scopes: ['read:jira-work'],
45
+ status: 'installed',
46
+ });
47
+
48
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
49
+ id: connection.id,
50
+ provider: 'jira',
51
+ });
52
+ await expect(getJiraInstallationByConnectionId(connection.id)).resolves.toMatchObject({
53
+ connectionId: connection.id,
54
+ cloudId,
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,27 @@
1
+ import {config} from '#config.js';
2
+ import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
3
+
4
+ const JIRA_MIGRATIONS_TABLE = '__drizzle_migrations_integrations_jira';
5
+
6
+ async function loadJiraModuleParts(): Promise<IntegrationModuleParts> {
7
+ const {
8
+ createJiraIntegrationProvider,
9
+ db: jiraDb,
10
+ migrationsPath,
11
+ } = await import('@shipfox/api-integration-jira');
12
+
13
+ return {
14
+ provider: createJiraIntegrationProvider(),
15
+ database: {
16
+ db: jiraDb,
17
+ migrationsPath,
18
+ migrationsTableName: JIRA_MIGRATIONS_TABLE,
19
+ },
20
+ };
21
+ }
22
+
23
+ export const jiraProviderModule: IntegrationProviderModule = {
24
+ id: 'jira',
25
+ enabled: config.INTEGRATIONS_ENABLE_JIRA_PROVIDER,
26
+ load: loadJiraModuleParts,
27
+ };
@@ -37,4 +37,30 @@ describe('loadEnabledProviderModules', () => {
37
37
  adapters: {},
38
38
  });
39
39
  });
40
+
41
+ it('loads Slack when the provider is enabled', async () => {
42
+ vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
43
+ vi.resetModules();
44
+
45
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
46
+ const parts = await loadEnabledProviderModules();
47
+
48
+ expect(parts.map((part) => part.provider.provider)).toEqual(['slack', 'cron', 'webhook']);
49
+ expect(parts[0]?.provider).toMatchObject({
50
+ provider: 'slack',
51
+ displayName: 'Slack',
52
+ });
53
+ expect(parts[0]?.provider.adapters?.agent_tools).toBeDefined();
54
+ });
55
+
56
+ it('loads Jira when the provider is enabled', async () => {
57
+ vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
58
+ vi.resetModules();
59
+
60
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
61
+ const parts = await loadEnabledProviderModules();
62
+
63
+ expect(parts.map((part) => part.provider.provider)).toEqual(['jira', 'cron', 'webhook']);
64
+ expect(parts[0]?.provider).toMatchObject({provider: 'jira', displayName: 'Jira'});
65
+ });
40
66
  });
@@ -1,8 +1,10 @@
1
1
  import {cronProviderModule} from '#providers/cron.js';
2
2
  import {giteaProviderModule} from '#providers/gitea.js';
3
3
  import {githubProviderModule} from '#providers/github.js';
4
+ import {jiraProviderModule} from '#providers/jira.js';
4
5
  import {linearProviderModule} from '#providers/linear.js';
5
6
  import {sentryProviderModule} from '#providers/sentry.js';
7
+ import {slackProviderModule} from '#providers/slack.js';
6
8
  import type {
7
9
  IntegrationModuleParts,
8
10
  IntegrationProviderModuleLoadOptions,
@@ -14,6 +16,8 @@ import {webhookProviderModule} from '#providers/webhook.js';
14
16
  const providerModules = [
15
17
  githubProviderModule,
16
18
  linearProviderModule,
19
+ slackProviderModule,
20
+ jiraProviderModule,
17
21
  sentryProviderModule,
18
22
  giteaProviderModule,
19
23
  cronProviderModule,
@@ -0,0 +1,57 @@
1
+ import {
2
+ getSlackInstallationByConnectionId,
3
+ upsertSlackInstallation,
4
+ } from '@shipfox/api-integration-slack';
5
+ import {runMigrations} from '@shipfox/node-drizzle';
6
+ import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
7
+
8
+ describe('slackProviderModule', () => {
9
+ afterEach(() => {
10
+ vi.unstubAllEnvs();
11
+ vi.resetModules();
12
+ });
13
+
14
+ it('loads its database descriptor and persists a core connection with its Slack installation', async () => {
15
+ vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
16
+ vi.resetModules();
17
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
18
+ createPostgresClient();
19
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
20
+ const parts = await loadEnabledProviderModules();
21
+ const slackPart = parts.find((part) => part.provider.provider === 'slack');
22
+ if (!slackPart?.database) throw new Error('Slack provider database is not configured');
23
+ const workspaceId = crypto.randomUUID();
24
+ const teamId = `T${crypto.randomUUID()}`;
25
+
26
+ await runMigrations(
27
+ slackPart.database.db(),
28
+ slackPart.database.migrationsPath,
29
+ slackPart.database.migrationsTableName,
30
+ );
31
+ const connection = await upsertIntegrationConnection({
32
+ workspaceId,
33
+ provider: 'slack',
34
+ externalAccountId: teamId,
35
+ slug: `slack_${teamId}`,
36
+ displayName: 'Slack Acme',
37
+ });
38
+ await upsertSlackInstallation({
39
+ connectionId: connection.id,
40
+ teamId,
41
+ teamName: 'Acme',
42
+ appId: 'A123',
43
+ botUserId: 'U123',
44
+ scopes: ['app_mentions:read'],
45
+ status: 'installed',
46
+ });
47
+
48
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
49
+ id: connection.id,
50
+ provider: 'slack',
51
+ });
52
+ await expect(getSlackInstallationByConnectionId(connection.id)).resolves.toMatchObject({
53
+ connectionId: connection.id,
54
+ teamId,
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,177 @@
1
+ import {
2
+ type IntegrationConnection as CoreIntegrationConnection,
3
+ slugifyConnectionSlug,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import type {
6
+ ConnectSlackInstallationInput,
7
+ SlackSecretsStore,
8
+ } from '@shipfox/api-integration-slack';
9
+ import {config} from '#config.js';
10
+ import {
11
+ deleteIntegrationConnection,
12
+ getIntegrationConnectionById,
13
+ resolveUniqueConnectionSlug,
14
+ upsertIntegrationConnection,
15
+ } from '#db/connections.js';
16
+ import {db} from '#db/db.js';
17
+ import {publishIntegrationEventReceived, recordDeliveryOnly} from '#db/webhook-deliveries.js';
18
+ import {retryConnectionSlugCollision} from '#providers/connection-slug.js';
19
+ import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
20
+
21
+ const SLACK_MIGRATIONS_TABLE = '__drizzle_migrations_integrations_slack';
22
+ const SLACK_SECRETS_NAMESPACE_PREFIX = 'system/integrations/slack/';
23
+
24
+ type IntegrationDb = ReturnType<typeof db>;
25
+ type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
26
+
27
+ async function loadSlackModuleParts(
28
+ options: Parameters<IntegrationProviderModule['load']>[0] = {},
29
+ ): Promise<IntegrationModuleParts> {
30
+ const {
31
+ createSlackE2eRoutes,
32
+ createSlackIntegrationProvider,
33
+ createSlackTokenStore,
34
+ db: slackDb,
35
+ disconnectSlackInstallation: disconnectSlackInstallationRecords,
36
+ getSlackInstallationByTeamId,
37
+ migrationsPath: slackMigrationsPath,
38
+ upsertSlackInstallation,
39
+ } = await import('@shipfox/api-integration-slack');
40
+
41
+ async function getExistingSlackConnection(input: {
42
+ teamId: string;
43
+ }): Promise<CoreIntegrationConnection<'slack'> | undefined> {
44
+ const installation = await getSlackInstallationByTeamId(input.teamId);
45
+ if (!installation) return undefined;
46
+ const connection = await getIntegrationConnectionById(installation.connectionId);
47
+ if (!connection) return undefined;
48
+ return connection as CoreIntegrationConnection<'slack'>;
49
+ }
50
+
51
+ async function connectSlackInstallation(
52
+ input: ConnectSlackInstallationInput,
53
+ ): Promise<CoreIntegrationConnection<'slack'>> {
54
+ return await retryConnectionSlugCollision(() =>
55
+ db().transaction(async (tx) => {
56
+ const baseSlug = slugifyConnectionSlug(`slack_${input.teamName || input.teamId}`, {
57
+ fallback: 'slack',
58
+ });
59
+ const slug = await resolveUniqueConnectionSlug(
60
+ {
61
+ workspaceId: input.workspaceId,
62
+ provider: 'slack',
63
+ externalAccountId: input.teamId,
64
+ baseSlug,
65
+ },
66
+ {tx},
67
+ );
68
+ const connection = await upsertIntegrationConnection(
69
+ {
70
+ workspaceId: input.workspaceId,
71
+ provider: 'slack',
72
+ externalAccountId: input.teamId,
73
+ slug,
74
+ displayName: input.displayName,
75
+ lifecycleStatus: 'active',
76
+ },
77
+ {tx},
78
+ );
79
+ await upsertSlackInstallation(
80
+ {
81
+ connectionId: connection.id,
82
+ teamId: input.teamId,
83
+ teamName: input.teamName,
84
+ appId: input.appId,
85
+ botUserId: input.botUserId,
86
+ scopes: input.scopes,
87
+ tokenExpiresAt: input.tokenExpiresAt,
88
+ status: 'installed',
89
+ },
90
+ {tx},
91
+ );
92
+ return connection as CoreIntegrationConnection<'slack'>;
93
+ }),
94
+ );
95
+ }
96
+
97
+ async function disconnectSlackInstallation(input: {connectionId: string}): Promise<void> {
98
+ await disconnectSlackInstallationRecords<IntegrationTx>({
99
+ connectionId: input.connectionId,
100
+ getConnection: getIntegrationConnectionById,
101
+ deleteSecrets: (params) =>
102
+ options.secrets?.slack?.deleteSecrets({
103
+ ...params,
104
+ namespace: slackNamespaceSuffix(params.namespace),
105
+ }) ?? Promise.resolve(0),
106
+ transaction: (fn) => db().transaction((tx) => fn(tx)),
107
+ deleteConnection: (params, options) =>
108
+ deleteIntegrationConnection({id: params.connectionId}, options),
109
+ });
110
+ }
111
+
112
+ const fallbackSecrets: SlackSecretsStore = {
113
+ getSecret: () => Promise.resolve(null),
114
+ setSecrets: () => Promise.reject(new Error('Slack token storage is not configured')),
115
+ };
116
+ const secrets: SlackSecretsStore = options.secrets?.slack
117
+ ? {
118
+ getSecret: (params: Parameters<SlackSecretsStore['getSecret']>[0]) =>
119
+ options.secrets?.slack?.getSecret({
120
+ ...params,
121
+ namespace: slackNamespaceSuffix(params.namespace),
122
+ }) ?? Promise.resolve(null),
123
+ setSecrets: (params: Parameters<SlackSecretsStore['setSecrets']>[0]) =>
124
+ options.secrets?.slack?.setSecrets({
125
+ ...params,
126
+ namespace: slackNamespaceSuffix(params.namespace),
127
+ }) ?? Promise.resolve(),
128
+ }
129
+ : fallbackSecrets;
130
+ const tokenStore = createSlackTokenStore({
131
+ resolveConnection: async (connectionId) => getIntegrationConnectionById(connectionId),
132
+ secrets,
133
+ });
134
+
135
+ return {
136
+ provider: createSlackIntegrationProvider({
137
+ agentTools: {tokenStore},
138
+ routes: {
139
+ tokenStore,
140
+ getExistingSlackConnection,
141
+ connectSlackInstallation,
142
+ disconnectSlackInstallation,
143
+ coreDb: db,
144
+ publishIntegrationEventReceived,
145
+ recordDeliveryOnly,
146
+ getIntegrationConnectionById,
147
+ },
148
+ }),
149
+ e2eRoutes: [
150
+ createSlackE2eRoutes({
151
+ tokenStore,
152
+ getExistingSlackConnection,
153
+ connectSlackInstallation,
154
+ disconnectSlackInstallation,
155
+ connectionCapabilities: ['agent_tools'],
156
+ }),
157
+ ],
158
+ database: {
159
+ db: slackDb,
160
+ migrationsPath: slackMigrationsPath,
161
+ migrationsTableName: SLACK_MIGRATIONS_TABLE,
162
+ },
163
+ };
164
+ }
165
+
166
+ export const slackProviderModule: IntegrationProviderModule = {
167
+ id: 'slack',
168
+ enabled: config.INTEGRATIONS_ENABLE_SLACK_PROVIDER,
169
+ load: loadSlackModuleParts,
170
+ };
171
+
172
+ function slackNamespaceSuffix(namespace: string): string {
173
+ if (!namespace.startsWith(SLACK_SECRETS_NAMESPACE_PREFIX)) {
174
+ throw new Error('Slack provider attempted to access an unscoped secret namespace');
175
+ }
176
+ return namespace.slice(SLACK_SECRETS_NAMESPACE_PREFIX.length);
177
+ }
@@ -36,7 +36,9 @@ export interface IntegrationProviderModuleLoadOptions {
36
36
 
37
37
  export interface IntegrationProviderSecrets {
38
38
  github?: IntegrationProviderScopedSecrets | undefined;
39
+ jira?: IntegrationProviderScopedSecrets | undefined;
39
40
  linear?: IntegrationProviderScopedSecrets | undefined;
41
+ slack?: IntegrationProviderScopedSecrets | undefined;
40
42
  deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
41
43
  }
42
44
 
package/test/env.ts CHANGED
@@ -12,11 +12,23 @@ process.env.GITHUB_APP_CLIENT_SECRET = 'test-client-secret';
12
12
  process.env.GITHUB_APP_WEBHOOK_SECRET = 'test-webhook-secret';
13
13
  process.env.GITHUB_APP_SLUG = 'shipfox-test';
14
14
  process.env.GITHUB_INSTALL_STATE_SECRET = 'test-install-state-secret';
15
+ process.env.JIRA_OAUTH_CLIENT_ID = 'test-client-id';
16
+ process.env.JIRA_OAUTH_CLIENT_SECRET = 'test-client-secret';
17
+ process.env.JIRA_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/jira/callback';
18
+ process.env.JIRA_WEBHOOK_SIGNING_SECRET = 'test-webhook-secret';
19
+ process.env.JIRA_WEBHOOK_BASE_URL = 'https://shipfox.example.com';
20
+ process.env.JIRA_API_BASE_URL = 'https://jira.example.com/api';
21
+ process.env.JIRA_AUTH_BASE_URL = 'https://jira.example.com/auth';
15
22
  process.env.LINEAR_OAUTH_CLIENT_ID = 'test-client-id';
16
23
  process.env.LINEAR_OAUTH_CLIENT_SECRET = 'test-client-secret';
17
24
  process.env.LINEAR_WEBHOOK_SIGNING_SECRET = 'test-webhook-secret';
18
25
  process.env.LINEAR_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/linear/callback';
19
26
  process.env.LINEAR_MCP_ENDPOINT = 'https://mcp.linear.app/mcp';
27
+ process.env.SLACK_OAUTH_CLIENT_ID = 'test-client-id';
28
+ process.env.SLACK_OAUTH_CLIENT_SECRET = 'test-client-secret';
29
+ process.env.SLACK_SIGNING_SECRET = 'test-signing-secret';
30
+ process.env.SLACK_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/slack/callback';
31
+ process.env.SLACK_API_BASE_URL = 'https://slack.example.com/api';
20
32
  process.env.GITEA_BASE_URL = 'https://gitea.example.com';
21
33
  process.env.GITEA_SERVICE_USERNAME = 'shipfox-bot';
22
34
  process.env.GITEA_SERVICE_TOKEN = 'test-service-token';