@elevasis/core 0.11.2 → 0.13.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/dist/index.d.ts +2 -1
- package/dist/index.js +8 -1
- package/dist/organization-model/index.d.ts +2 -1
- package/dist/organization-model/index.js +8 -1
- package/dist/test-utils/index.d.ts +27 -15
- package/dist/test-utils/index.js +25 -0
- package/package.json +1 -1
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +27 -270
- package/src/auth/multi-tenancy/credentials/__tests__/encryption.test.ts +217 -216
- package/src/auth/multi-tenancy/credentials/server/encryption.ts +69 -39
- package/src/auth/multi-tenancy/credentials/server/kek-loader.ts +37 -0
- package/src/auth/multi-tenancy/index.ts +3 -0
- package/src/auth/multi-tenancy/invitations/api-schemas.ts +104 -107
- package/src/auth/multi-tenancy/memberships/api-schemas.ts +6 -5
- package/src/auth/multi-tenancy/memberships/membership.ts +130 -138
- package/src/auth/multi-tenancy/permissions.ts +12 -5
- package/src/auth/multi-tenancy/role-management/api-schemas.ts +78 -0
- package/src/auth/multi-tenancy/role-management/index.ts +16 -0
- package/src/business/acquisition/activity-events.ts +142 -0
- package/src/business/acquisition/api-schemas.ts +694 -689
- package/src/business/acquisition/derive-actions.ts +90 -0
- package/src/business/acquisition/index.ts +111 -109
- package/src/execution/engine/index.ts +434 -434
- package/src/execution/engine/tools/integration/server/adapters/apify/__tests__/apify-run-actor.integration.test.ts +298 -293
- package/src/execution/engine/tools/integration/server/adapters/attio/__tests__/attio-crud.integration.test.ts +0 -1
- package/src/execution/engine/tools/integration/service.test.ts +214 -0
- package/src/execution/engine/tools/integration/service.ts +169 -161
- package/src/execution/engine/tools/lead-service-types.ts +882 -879
- package/src/execution/engine/tools/registry.ts +699 -700
- package/src/execution/engine/tools/tool-maps.ts +777 -780
- package/src/integrations/credentials/__tests__/api-schemas.test.ts +420 -496
- package/src/integrations/credentials/api-schemas.ts +127 -143
- package/src/integrations/webhook-endpoints/__tests__/api-schemas.test.ts +327 -318
- package/src/integrations/webhook-endpoints/api-schemas.ts +103 -102
- package/src/integrations/webhook-endpoints/types.ts +58 -51
- package/src/operations/activities/api-schemas.ts +80 -79
- package/src/operations/activities/types.ts +64 -63
- package/src/organization-model/contracts.ts +1 -0
- package/src/organization-model/defaults.ts +6 -0
- package/src/organization-model/domains/navigation.ts +37 -23
- package/src/organization-model/organization-graph.mdx +2 -2
- package/src/organization-model/published.ts +2 -1
- package/src/platform/constants/versions.ts +1 -1
- package/src/reference/_generated/contracts.md +27 -270
- package/src/scaffold-registry/__tests__/index.test.ts +72 -7
- package/src/scaffold-registry/index.ts +163 -29
- package/src/scaffold-registry/schema.ts +68 -62
- package/src/server.ts +281 -272
- package/src/supabase/database.types.ts +16 -10
- package/src/test-utils/rls/RLSTestContext.ts +585 -553
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntegrationService — credential cache regression tests
|
|
3
|
+
*
|
|
4
|
+
* Regression guard for the context.store plumbing bug fixed in commit 75205cd8e:
|
|
5
|
+
* IntegrationService.call() reads context.store.get(cacheKey) for per-execution
|
|
6
|
+
* credential caching. Before the fix, dispatchIntegrationTool never threaded `store`
|
|
7
|
+
* into the context, causing a V8 TypeError on every integration tool call.
|
|
8
|
+
*
|
|
9
|
+
* These tests exercise the real IntegrationService.call() code path — not a mock —
|
|
10
|
+
* so any future regression that removes context.store from the dispatch path will
|
|
11
|
+
* cause a real crash here, not a silent type error.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
15
|
+
import { IntegrationService } from './service'
|
|
16
|
+
import type { BaseIntegrationAdapter } from './base-integration-adapter'
|
|
17
|
+
import type { ExecutionContext } from '../../base/types'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Mock the registry that IntegrationService.call() uses internally
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const mockGetCredential = vi.fn()
|
|
24
|
+
|
|
25
|
+
vi.mock('../registry', () => ({
|
|
26
|
+
getToolServices: () => ({
|
|
27
|
+
credentialsService: {
|
|
28
|
+
getCredential: mockGetCredential
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** Minimal stub adapter — always validates, always succeeds */
|
|
38
|
+
function makeStubAdapter(name: string): BaseIntegrationAdapter {
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
validateCredentials: () => true,
|
|
42
|
+
call: vi.fn().mockResolvedValue({ stubResult: true })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Minimal ExecutionContext with a real Map for store */
|
|
47
|
+
function makeContext(store: Map<string, unknown> = new Map()): ExecutionContext {
|
|
48
|
+
return {
|
|
49
|
+
executionId: 'exec-test-001',
|
|
50
|
+
organizationId: 'org-test-001',
|
|
51
|
+
organizationName: 'Test Org',
|
|
52
|
+
resourceId: 'resource-test-001',
|
|
53
|
+
executionDepth: 0,
|
|
54
|
+
logger: {
|
|
55
|
+
debug: () => {},
|
|
56
|
+
info: () => {},
|
|
57
|
+
warn: () => {},
|
|
58
|
+
error: () => {}
|
|
59
|
+
},
|
|
60
|
+
store
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Tests
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe('IntegrationService — credential cache (context.store)', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.clearAllMocks()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('call() with a valid store Map', () => {
|
|
74
|
+
it('does not throw when context.store is a real Map (regression: was crashing with undefined.get)', async () => {
|
|
75
|
+
const adapter = makeStubAdapter('stripe')
|
|
76
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
77
|
+
const credentials = { apiKey: 'sk_test_123' }
|
|
78
|
+
|
|
79
|
+
mockGetCredential.mockResolvedValue(credentials)
|
|
80
|
+
|
|
81
|
+
const context = makeContext()
|
|
82
|
+
|
|
83
|
+
// This call must NOT throw "Cannot read properties of undefined (reading 'get')"
|
|
84
|
+
const result = await service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', context)
|
|
85
|
+
|
|
86
|
+
expect(result).toEqual({ stubResult: true })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('fetches credentials from credentialsService on first call', async () => {
|
|
90
|
+
const adapter = makeStubAdapter('stripe')
|
|
91
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
92
|
+
const credentials = { apiKey: 'sk_test_123' }
|
|
93
|
+
|
|
94
|
+
mockGetCredential.mockResolvedValue(credentials)
|
|
95
|
+
|
|
96
|
+
await service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', makeContext())
|
|
97
|
+
|
|
98
|
+
expect(mockGetCredential).toHaveBeenCalledOnce()
|
|
99
|
+
expect(mockGetCredential).toHaveBeenCalledWith('org-test-001', 'stripe-cred')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('hits the cache and skips credentialsService on second call with the same store', async () => {
|
|
103
|
+
// This is the core regression: the second call should reuse the cached credential
|
|
104
|
+
// (store.get returns the value set by the first call) and NOT call credentialsService again.
|
|
105
|
+
const adapter = makeStubAdapter('stripe')
|
|
106
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
107
|
+
const credentials = { apiKey: 'sk_test_123' }
|
|
108
|
+
|
|
109
|
+
mockGetCredential.mockResolvedValue(credentials)
|
|
110
|
+
|
|
111
|
+
// Use a SHARED store Map across both calls — as worker-executor does
|
|
112
|
+
const sharedStore = new Map<string, unknown>()
|
|
113
|
+
const ctx = makeContext(sharedStore)
|
|
114
|
+
|
|
115
|
+
await service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', ctx)
|
|
116
|
+
await service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', ctx)
|
|
117
|
+
|
|
118
|
+
// credentialsService should only have been called once — second call hit the cache
|
|
119
|
+
expect(mockGetCredential).toHaveBeenCalledOnce()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('fetches credentials again when a fresh store is used (different execution)', async () => {
|
|
123
|
+
// Two distinct executions each own their own store Map.
|
|
124
|
+
// The credential cache must NOT leak across executions.
|
|
125
|
+
const adapter = makeStubAdapter('stripe')
|
|
126
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
127
|
+
const credentials = { apiKey: 'sk_test_123' }
|
|
128
|
+
|
|
129
|
+
mockGetCredential.mockResolvedValue(credentials)
|
|
130
|
+
|
|
131
|
+
// Execution A — fresh store
|
|
132
|
+
await service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', makeContext(new Map()))
|
|
133
|
+
// Execution B — different fresh store
|
|
134
|
+
await service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', makeContext(new Map()))
|
|
135
|
+
|
|
136
|
+
// Each execution must independently fetch the credential
|
|
137
|
+
expect(mockGetCredential).toHaveBeenCalledTimes(2)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('writes the credential to store.set after fetching so subsequent calls can retrieve it', async () => {
|
|
141
|
+
const adapter = makeStubAdapter('stripe')
|
|
142
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
143
|
+
const credentials = { apiKey: 'sk_test_456' }
|
|
144
|
+
|
|
145
|
+
mockGetCredential.mockResolvedValue(credentials)
|
|
146
|
+
|
|
147
|
+
const store = new Map<string, unknown>()
|
|
148
|
+
await service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', makeContext(store))
|
|
149
|
+
|
|
150
|
+
// The credential must have been written into the store under the expected cache key
|
|
151
|
+
const cacheKey = '__cred_cache__:stripe-cred'
|
|
152
|
+
expect(store.has(cacheKey)).toBe(true)
|
|
153
|
+
expect(store.get(cacheKey)).toEqual(credentials)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('call() error conditions', () => {
|
|
158
|
+
it('throws with credentials_missing when credentialsService returns null', async () => {
|
|
159
|
+
const adapter = makeStubAdapter('stripe')
|
|
160
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
161
|
+
|
|
162
|
+
mockGetCredential.mockResolvedValue(null)
|
|
163
|
+
|
|
164
|
+
await expect(service.call('stripe', 'listPaymentLinks', {}, 'missing-cred', makeContext())).rejects.toMatchObject(
|
|
165
|
+
{
|
|
166
|
+
errorType: 'credentials_missing'
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('throws with adapter_not_found when integration is not registered', async () => {
|
|
172
|
+
const service = new IntegrationService(new Map())
|
|
173
|
+
|
|
174
|
+
await expect(service.call('nonexistent', 'someMethod', {}, 'some-cred', makeContext())).rejects.toMatchObject({
|
|
175
|
+
errorType: 'adapter_not_found'
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('throws validation_error when context is undefined', async () => {
|
|
180
|
+
const adapter = makeStubAdapter('stripe')
|
|
181
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
182
|
+
|
|
183
|
+
await expect(service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', undefined)).rejects.toMatchObject({
|
|
184
|
+
errorType: 'validation_error'
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('throws TypeError when context.store is undefined (simulates the pre-fix bug)', async () => {
|
|
189
|
+
// This documents the exact failure mode that shipped: dispatchIntegrationTool
|
|
190
|
+
// constructed a context without `store`. The crash was:
|
|
191
|
+
// "Cannot read properties of undefined (reading 'get')"
|
|
192
|
+
const adapter = makeStubAdapter('stripe')
|
|
193
|
+
const service = new IntegrationService(new Map([['stripe', adapter]]))
|
|
194
|
+
const credentials = { apiKey: 'sk_test_123' }
|
|
195
|
+
|
|
196
|
+
mockGetCredential.mockResolvedValue(credentials)
|
|
197
|
+
|
|
198
|
+
// Deliberately construct a context without `store` (mimics the pre-fix bug)
|
|
199
|
+
const brokenContext = {
|
|
200
|
+
executionId: 'exec-broken',
|
|
201
|
+
organizationId: 'org-test-001',
|
|
202
|
+
organizationName: 'Test Org',
|
|
203
|
+
resourceId: 'resource-broken',
|
|
204
|
+
executionDepth: 0,
|
|
205
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }
|
|
206
|
+
// store is intentionally absent — this was the bug
|
|
207
|
+
} as unknown as ExecutionContext
|
|
208
|
+
|
|
209
|
+
await expect(service.call('stripe', 'listPaymentLinks', {}, 'stripe-cred', brokenContext)).rejects.toThrow(
|
|
210
|
+
/Cannot read properties of undefined \(reading '(get|set)'\)/
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -1,161 +1,169 @@
|
|
|
1
|
-
import type { ExecutionContext } from '../../base/types'
|
|
2
|
-
import type { BaseIntegrationAdapter } from './base-integration-adapter'
|
|
3
|
-
import type { RateLimiter, RetryPolicy } from '../../../../platform/resilience'
|
|
4
|
-
import { ToolingError } from '../types'
|
|
5
|
-
import { withRetry, InMemoryRateLimiter, DEFAULT_RETRY_POLICY } from '../../../../platform/resilience'
|
|
6
|
-
import {
|
|
7
|
-
INTEGRATION_RATE_LIMIT_CAPACITY,
|
|
8
|
-
INTEGRATION_RATE_LIMIT_WINDOW_MS
|
|
9
|
-
} from '../../../../platform/constants/resilience'
|
|
10
|
-
import { getToolServices } from '../registry'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Integration Service
|
|
14
|
-
*
|
|
15
|
-
* Central service for managing integration adapters, rate limiting, and retry.
|
|
16
|
-
* Credentials accessed via global tool services registry.
|
|
17
|
-
*
|
|
18
|
-
* Pattern: Active Record (justified - owns state + behavior)
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* const service = new IntegrationService(
|
|
22
|
-
* new Map([['gmail', gmailAdapter]])
|
|
23
|
-
* )
|
|
24
|
-
*
|
|
25
|
-
* const result = await service.call('gmail', 'sendEmail', params, 'gmail-cred', context)
|
|
26
|
-
*/
|
|
27
|
-
export class IntegrationService {
|
|
28
|
-
private readonly rateLimiter: RateLimiter
|
|
29
|
-
private readonly retryPolicy: RetryPolicy
|
|
30
|
-
|
|
31
|
-
constructor(public readonly adapters: Map<string, BaseIntegrationAdapter>) {
|
|
32
|
-
this.rateLimiter = new InMemoryRateLimiter(INTEGRATION_RATE_LIMIT_CAPACITY, INTEGRATION_RATE_LIMIT_WINDOW_MS)
|
|
33
|
-
this.retryPolicy = DEFAULT_RETRY_POLICY
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Call integration method with full orchestration
|
|
38
|
-
*
|
|
39
|
-
* Orchestrates the full integration call flow:
|
|
40
|
-
* 1. Validate execution context
|
|
41
|
-
* 2. Validate adapter exists
|
|
42
|
-
* 3. Check rate limit (per organization per integration)
|
|
43
|
-
* 4. Get credentials from registry
|
|
44
|
-
* 5. Validate credentials
|
|
45
|
-
* 6. Call adapter with retry
|
|
46
|
-
*
|
|
47
|
-
* @param integration - Integration adapter type (e.g., 'gmail', 'slack')
|
|
48
|
-
* @param method - Method name (e.g., 'sendEmail', 'postMessage')
|
|
49
|
-
* @param params - Method parameters
|
|
50
|
-
* @param credentialName - Credential name from credentials table
|
|
51
|
-
* @param context - Execution context (required for multi-tenant isolation)
|
|
52
|
-
* @returns Method result
|
|
53
|
-
* @throws ToolingError if call fails
|
|
54
|
-
*/
|
|
55
|
-
async call(
|
|
56
|
-
integration: string,
|
|
57
|
-
method: string,
|
|
58
|
-
params: unknown,
|
|
59
|
-
credentialName: string,
|
|
60
|
-
context: ExecutionContext | undefined
|
|
61
|
-
): Promise<unknown> {
|
|
62
|
-
// 1. Validate execution context (required for multi-tenant isolation)
|
|
63
|
-
if (!context) {
|
|
64
|
-
throw new ToolingError('validation_error', 'ExecutionContext required for integration calls', {
|
|
65
|
-
integration,
|
|
66
|
-
method,
|
|
67
|
-
credentialName
|
|
68
|
-
})
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// 2. Validate adapter exists
|
|
72
|
-
const adapter = this.adapters.get(integration)
|
|
73
|
-
if (!adapter) {
|
|
74
|
-
throw new ToolingError('adapter_not_found', `Integration adapter not found: ${integration}`, {
|
|
75
|
-
integration,
|
|
76
|
-
availableAdapters: Array.from(this.adapters.keys())
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 3. Check rate limit (per organization per integration)
|
|
81
|
-
const rateLimitKey = `${context.organizationId}:${integration}`
|
|
82
|
-
const allowed = await this.rateLimiter.allow(rateLimitKey)
|
|
83
|
-
if (!allowed) {
|
|
84
|
-
const remaining = await this.rateLimiter.remaining(rateLimitKey)
|
|
85
|
-
throw new ToolingError('rate_limit_exceeded', 'Rate limit exceeded', {
|
|
86
|
-
integration,
|
|
87
|
-
organizationId: context.organizationId,
|
|
88
|
-
remaining
|
|
89
|
-
})
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// 4. Get credentials from registry
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
context.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
integration,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
*
|
|
148
|
-
* @
|
|
149
|
-
*/
|
|
150
|
-
|
|
151
|
-
this.adapters.
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
*
|
|
156
|
-
* @param
|
|
157
|
-
*/
|
|
158
|
-
|
|
159
|
-
this.adapters.
|
|
160
|
-
}
|
|
161
|
-
|
|
1
|
+
import type { ExecutionContext } from '../../base/types'
|
|
2
|
+
import type { BaseIntegrationAdapter } from './base-integration-adapter'
|
|
3
|
+
import type { RateLimiter, RetryPolicy } from '../../../../platform/resilience'
|
|
4
|
+
import { ToolingError } from '../types'
|
|
5
|
+
import { withRetry, InMemoryRateLimiter, DEFAULT_RETRY_POLICY } from '../../../../platform/resilience'
|
|
6
|
+
import {
|
|
7
|
+
INTEGRATION_RATE_LIMIT_CAPACITY,
|
|
8
|
+
INTEGRATION_RATE_LIMIT_WINDOW_MS
|
|
9
|
+
} from '../../../../platform/constants/resilience'
|
|
10
|
+
import { getToolServices } from '../registry'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Integration Service
|
|
14
|
+
*
|
|
15
|
+
* Central service for managing integration adapters, rate limiting, and retry.
|
|
16
|
+
* Credentials accessed via global tool services registry.
|
|
17
|
+
*
|
|
18
|
+
* Pattern: Active Record (justified - owns state + behavior)
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const service = new IntegrationService(
|
|
22
|
+
* new Map([['gmail', gmailAdapter]])
|
|
23
|
+
* )
|
|
24
|
+
*
|
|
25
|
+
* const result = await service.call('gmail', 'sendEmail', params, 'gmail-cred', context)
|
|
26
|
+
*/
|
|
27
|
+
export class IntegrationService {
|
|
28
|
+
private readonly rateLimiter: RateLimiter
|
|
29
|
+
private readonly retryPolicy: RetryPolicy
|
|
30
|
+
|
|
31
|
+
constructor(public readonly adapters: Map<string, BaseIntegrationAdapter>) {
|
|
32
|
+
this.rateLimiter = new InMemoryRateLimiter(INTEGRATION_RATE_LIMIT_CAPACITY, INTEGRATION_RATE_LIMIT_WINDOW_MS)
|
|
33
|
+
this.retryPolicy = DEFAULT_RETRY_POLICY
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Call integration method with full orchestration
|
|
38
|
+
*
|
|
39
|
+
* Orchestrates the full integration call flow:
|
|
40
|
+
* 1. Validate execution context
|
|
41
|
+
* 2. Validate adapter exists
|
|
42
|
+
* 3. Check rate limit (per organization per integration)
|
|
43
|
+
* 4. Get credentials from registry
|
|
44
|
+
* 5. Validate credentials
|
|
45
|
+
* 6. Call adapter with retry
|
|
46
|
+
*
|
|
47
|
+
* @param integration - Integration adapter type (e.g., 'gmail', 'slack')
|
|
48
|
+
* @param method - Method name (e.g., 'sendEmail', 'postMessage')
|
|
49
|
+
* @param params - Method parameters
|
|
50
|
+
* @param credentialName - Credential name from credentials table
|
|
51
|
+
* @param context - Execution context (required for multi-tenant isolation)
|
|
52
|
+
* @returns Method result
|
|
53
|
+
* @throws ToolingError if call fails
|
|
54
|
+
*/
|
|
55
|
+
async call(
|
|
56
|
+
integration: string,
|
|
57
|
+
method: string,
|
|
58
|
+
params: unknown,
|
|
59
|
+
credentialName: string,
|
|
60
|
+
context: ExecutionContext | undefined
|
|
61
|
+
): Promise<unknown> {
|
|
62
|
+
// 1. Validate execution context (required for multi-tenant isolation)
|
|
63
|
+
if (!context) {
|
|
64
|
+
throw new ToolingError('validation_error', 'ExecutionContext required for integration calls', {
|
|
65
|
+
integration,
|
|
66
|
+
method,
|
|
67
|
+
credentialName
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Validate adapter exists
|
|
72
|
+
const adapter = this.adapters.get(integration)
|
|
73
|
+
if (!adapter) {
|
|
74
|
+
throw new ToolingError('adapter_not_found', `Integration adapter not found: ${integration}`, {
|
|
75
|
+
integration,
|
|
76
|
+
availableAdapters: Array.from(this.adapters.keys())
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Check rate limit (per organization per integration)
|
|
81
|
+
const rateLimitKey = `${context.organizationId}:${integration}`
|
|
82
|
+
const allowed = await this.rateLimiter.allow(rateLimitKey)
|
|
83
|
+
if (!allowed) {
|
|
84
|
+
const remaining = await this.rateLimiter.remaining(rateLimitKey)
|
|
85
|
+
throw new ToolingError('rate_limit_exceeded', 'Rate limit exceeded', {
|
|
86
|
+
integration,
|
|
87
|
+
organizationId: context.organizationId,
|
|
88
|
+
remaining
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4. Get credentials from registry (per-execution cache keyed by credential name)
|
|
93
|
+
const cacheKey = `__cred_cache__:${credentialName}`
|
|
94
|
+
let credentials = context.store.get(cacheKey) as Record<string, unknown> | undefined
|
|
95
|
+
|
|
96
|
+
if (!credentials) {
|
|
97
|
+
const { credentialsService } = getToolServices()
|
|
98
|
+
if (!credentialsService) {
|
|
99
|
+
throw new ToolingError(
|
|
100
|
+
'service_unavailable',
|
|
101
|
+
'CredentialsService not initialized. Ensure toolServicesRegistry.initialize() was called in apps/api/src/main.ts',
|
|
102
|
+
{ integration, credentialName }
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fetched = await credentialsService.getCredential(context.organizationId, credentialName)
|
|
107
|
+
if (!fetched) {
|
|
108
|
+
throw new ToolingError('credentials_missing', `No credentials found for credential name: ${credentialName}`, {
|
|
109
|
+
integration,
|
|
110
|
+
credentialName,
|
|
111
|
+
organizationId: context.organizationId
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
context.store.set(cacheKey, fetched)
|
|
116
|
+
credentials = fetched
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Log credential retrieval for OAuth integrations
|
|
120
|
+
const isOAuth = !!credentials.provider
|
|
121
|
+
if (isOAuth) {
|
|
122
|
+
const creds = credentials
|
|
123
|
+
context.logger?.debug(
|
|
124
|
+
`OAuth credential retrieved: integration=${integration}, credentialName=${credentialName}, provider=${creds.provider}, hasRefreshToken=${!!creds.refreshToken}, hasExpiresAt=${!!creds.expiresAt}, expiresAt=${creds.expiresAt}`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 5. Validate credentials
|
|
129
|
+
const isValid = adapter.validateCredentials(credentials)
|
|
130
|
+
if (!isValid) {
|
|
131
|
+
throw new ToolingError('credentials_invalid', `Invalid credentials for integration: ${integration}`, {
|
|
132
|
+
integration,
|
|
133
|
+
organizationId: context.organizationId
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 6. Call adapter with retry (pass credentialName in context for token refresh persistence)
|
|
138
|
+
const contextWithCredential: ExecutionContext = {
|
|
139
|
+
...context,
|
|
140
|
+
credentialName
|
|
141
|
+
}
|
|
142
|
+
return withRetry(() => adapter.call(method, params, credentials, contextWithCredential), this.retryPolicy)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get adapter by name
|
|
147
|
+
* @param integration - Integration name
|
|
148
|
+
* @returns Adapter or undefined
|
|
149
|
+
*/
|
|
150
|
+
getAdapter(integration: string): BaseIntegrationAdapter | undefined {
|
|
151
|
+
return this.adapters.get(integration)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Register adapter
|
|
156
|
+
* @param adapter - Adapter to register
|
|
157
|
+
*/
|
|
158
|
+
registerAdapter(adapter: BaseIntegrationAdapter): void {
|
|
159
|
+
this.adapters.set(adapter.name, adapter)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Unregister adapter
|
|
164
|
+
* @param integration - Integration name to unregister
|
|
165
|
+
*/
|
|
166
|
+
unregisterAdapter(integration: string): void {
|
|
167
|
+
this.adapters.delete(integration)
|
|
168
|
+
}
|
|
169
|
+
}
|